From 6a79259893403ca93260b5dc217978abc7dd3bca Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:51:15 +0200 Subject: [PATCH 001/117] Add Kimi Code CLI as a supported MCP client (#4788) * Add Kimi Code CLI as a supported MCP client * Regenerate CLI and API docs for kimi-cli * Fix indentation in AddAndVerifyMCPServer switch block * Add kimi-cli to swagger.yaml ClientApp enum --- docs/cli/thv_client_register.md | 1 + docs/cli/thv_client_remove.md | 1 + docs/server/docs.go | 6 ++++-- docs/server/swagger.json | 6 ++++-- docs/server/swagger.yaml | 2 ++ pkg/client/config.go | 20 ++++++++++++++++++++ pkg/client/config_test.go | 19 ++++++++++++++----- pkg/client/skills_test.go | 26 ++++++++++++++++++++++++-- 8 files changed, 70 insertions(+), 11 deletions(-) diff --git a/docs/cli/thv_client_register.md b/docs/cli/thv_client_register.md index bd7a02c046..11e6dd78f3 100644 --- a/docs/cli/thv_client_register.md +++ b/docs/cli/thv_client_register.md @@ -31,6 +31,7 @@ Valid clients: - cursor: Cursor editor - gemini-cli: Google Gemini CLI - goose: Goose AI agent + - kimi-cli: Kimi Code CLI - kiro: Kiro AI IDE - lm-studio: LM Studio application - mistral-vibe: Mistral Vibe IDE diff --git a/docs/cli/thv_client_remove.md b/docs/cli/thv_client_remove.md index 34d1caf431..74ed6d1a2f 100644 --- a/docs/cli/thv_client_remove.md +++ b/docs/cli/thv_client_remove.md @@ -31,6 +31,7 @@ Valid clients: - cursor: Cursor editor - gemini-cli: Google Gemini CLI - goose: Goose AI agent + - kimi-cli: Kimi Code CLI - kiro: Kiro AI IDE - lm-studio: LM Studio application - mistral-vibe: Mistral Vibe IDE diff --git a/docs/server/docs.go b/docs/server/docs.go index 3394d76499..3c09f2ebb8 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -827,7 +827,8 @@ const docTemplate = `{ "gemini-cli", "vscode-server", "mistral-vibe", - "codex" + "codex", + "kimi-cli" ], "type": "string", "x-enum-varnames": [ @@ -855,7 +856,8 @@ const docTemplate = `{ "GeminiCli", "VSCodeServer", "MistralVibe", - "Codex" + "Codex", + "KimiCli" ] }, "github_com_stacklok_toolhive_pkg_client.ClientAppStatus": { diff --git a/docs/server/swagger.json b/docs/server/swagger.json index b6d1508236..51997b4091 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -820,7 +820,8 @@ "gemini-cli", "vscode-server", "mistral-vibe", - "codex" + "codex", + "kimi-cli" ], "type": "string", "x-enum-varnames": [ @@ -848,7 +849,8 @@ "GeminiCli", "VSCodeServer", "MistralVibe", - "Codex" + "Codex", + "KimiCli" ] }, "github_com_stacklok_toolhive_pkg_client.ClientAppStatus": { diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 6382e5df05..75d03d2e2d 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -800,6 +800,7 @@ components: - vscode-server - mistral-vibe - codex + - kimi-cli type: string x-enum-varnames: - RooCode @@ -827,6 +828,7 @@ components: - VSCodeServer - MistralVibe - Codex + - KimiCli github_com_stacklok_toolhive_pkg_client.ClientAppStatus: properties: client_type: diff --git a/pkg/client/config.go b/pkg/client/config.go index ce95ba6cbc..8338ae7b89 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -82,6 +82,8 @@ const ( MistralVibe ClientApp = "mistral-vibe" // Codex represents the OpenAI Codex CLI. Codex ClientApp = "codex" + // KimiCli represents the Kimi Code CLI. + KimiCli ClientApp = "kimi-cli" ) // Extension is extension of the client config file. @@ -744,6 +746,24 @@ var supportedClientIntegrations = []clientAppConfig{ SkillsGlobalPath: []string{".agents", "skills"}, SkillsProjectPath: []string{".agents", "skills"}, }, + { + ClientType: KimiCli, + Description: "Kimi Code CLI", + SettingsFile: "mcp.json", + MCPServersPathPrefix: "/mcpServers", + RelPath: []string{".kimi"}, + Extension: JSON, + // Kimi CLI does not use a transport type field in the config file + IsTransportTypeFieldSupported: false, + MCPServersUrlLabelMap: map[types.TransportType]string{ + types.TransportTypeStdio: "url", + types.TransportTypeSSE: "url", + types.TransportTypeStreamableHTTP: "url", + }, + SupportsSkills: true, + SkillsGlobalPath: []string{".kimi", "skills"}, + SkillsProjectPath: []string{".kimi", "skills"}, + }, } // GetAllClients returns a slice of all supported MCP client types, sorted alphabetically. diff --git a/pkg/client/config_test.go b/pkg/client/config_test.go index 90b8dd9268..547819d102 100644 --- a/pkg/client/config_test.go +++ b/pkg/client/config_test.go @@ -209,6 +209,14 @@ func createMockClientConfigs() []clientAppConfig { Extension: TOML, TOMLStorageType: TOMLStorageTypeMap, }, + { + ClientType: KimiCli, + Description: "Kimi Code CLI (Mock)", + RelPath: []string{"mock_kimi"}, + SettingsFile: "mcp.json", + MCPServersPathPrefix: "/mcpServers", + Extension: JSON, + }, } } @@ -394,6 +402,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { string(VSCodeServer), string(MistralVibe), string(Codex), + string(KimiCli), }, }, } @@ -486,7 +495,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { case AmpWindsurf: assert.Contains(t, string(content), `"mcpServers":`, "AmpWindsurf config should contain mcpServers key") - case LMStudio, Trae, Kiro, Antigravity, GeminiCli: + case LMStudio, Trae, Kiro, Antigravity, GeminiCli, KimiCli: assert.Contains(t, string(content), `"mcpServers":`, "Config should contain mcpServers key") case VSCodeServer: @@ -545,7 +554,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { "VSCode config should contain the server URL") case Cursor, RooCode, ClaudeCode, Cline, Windsurf, WindsurfJetBrains, AmpCli, AmpVSCode, AmpCursor, AmpVSCodeInsider, AmpWindsurf, LMStudio, Goose, Trae, Continue, OpenCode, Kiro, Antigravity, Zed, GeminiCli, VSCodeServer, - MistralVibe, Codex: + MistralVibe, Codex, KimiCli: assert.Contains(t, string(content), testURL, "Config should contain the server URL") } @@ -1274,8 +1283,8 @@ func TestGetAllClients(t *testing.T) { clients := GetAllClients() - // Should return all 25 supported clients - assert.Len(t, clients, 25, "Expected 25 supported clients") + // Should return all 26 supported clients + assert.Len(t, clients, 26, "Expected 26 supported clients") // Verify the list is sorted alphabetically for i := 1; i < len(clients); i++ { @@ -1463,5 +1472,5 @@ func TestGetClientListCSV(t *testing.T) { // Count the number of clients (should be 25) clients := strings.Split(csv, ", ") - assert.Len(t, clients, 25, "Expected 25 clients in CSV list") + assert.Len(t, clients, 26, "Expected 26 clients in CSV list") } diff --git a/pkg/client/skills_test.go b/pkg/client/skills_test.go index 6176197cd4..f0b0eca31b 100644 --- a/pkg/client/skills_test.go +++ b/pkg/client/skills_test.go @@ -46,6 +46,12 @@ func testSkillClientIntegrations() []clientAppConfig { SkillsGlobalPath: []string{".cursor", "skills"}, SkillsProjectPath: []string{".cursor", "skills"}, }, + { + ClientType: KimiCli, + SupportsSkills: true, + SkillsGlobalPath: []string{".kimi", "skills"}, + SkillsProjectPath: []string{".kimi", "skills"}, + }, { ClientType: VSCode, // SupportsSkills defaults to false @@ -77,6 +83,7 @@ func TestSupportsSkills(t *testing.T) { {name: "Codex supports skills", client: Codex, expected: true}, {name: "OpenCode supports skills", client: OpenCode, expected: true}, {name: "Cursor supports skills", client: Cursor, expected: true}, + {name: "KimiCli supports skills", client: KimiCli, expected: true}, {name: "unknown client returns false", client: ClientApp("nonexistent"), expected: false}, } @@ -93,8 +100,8 @@ func TestListSkillSupportingClients(t *testing.T) { cm := newTestSkillManager() clients := cm.ListSkillSupportingClients() - // Should include ClaudeCode, Codex, Cursor, OpenCode, and our test-only no-paths-client - require.Len(t, clients, 5, "unexpected number of skill-supporting clients: %v", clients) + // Should include ClaudeCode, Codex, Cursor, KimiCli, OpenCode, and our test-only no-paths-client + require.Len(t, clients, 6, "unexpected number of skill-supporting clients: %v", clients) // Verify sorted order for i := 1; i < len(clients); i++ { @@ -206,6 +213,21 @@ func TestGetSkillPath(t *testing.T) { projectRoot: "/tmp/myproject", wantPath: filepath.Join("/tmp/myproject", ".cursor", "skills", "my-skill"), }, + { + name: "ScopeUser KimiCli", + client: KimiCli, + skillName: "my-skill", + scope: skills.ScopeUser, + wantPath: filepath.Join(testHomeDir, ".kimi", "skills", "my-skill"), + }, + { + name: "ScopeProject KimiCli with explicit root", + client: KimiCli, + skillName: "my-skill", + scope: skills.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".kimi", "skills", "my-skill"), + }, { name: "client that does not support skills", client: VSCode, From 052ecdd199a0bc151906e4cd9dc55af84263d21f Mon Sep 17 00:00:00 2001 From: gkatz2 <93550828+gkatz2@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:59:08 -0700 Subject: [PATCH 002/117] Support stateless streamable-HTTP remote servers (#4515) * Support stateless streamable-HTTP remote servers Remote MCP servers implementing the streamable-HTTP transport in stateless mode (POST-only, no SSE) show zero tools when proxied by ToolHive. The transparent proxy forwards all HTTP methods including GET; stateless servers don't serve SSE on GET, so clients never complete tool discovery. Add a --stateless flag to thv run that gates GET, HEAD, and DELETE requests at the proxy with 405, and switches the health check to a POST-based JSON-RPC ping appropriate for servers without an SSE endpoint. Fixes #4514 Signed-off-by: Greg Katz * Add --stateless flag to generated docs Regenerate CLI and Swagger docs to include the new --stateless flag added to the run command. Signed-off-by: Greg Katz * Apply stateless mode to all transports Move stateless configuration outside the remote-URL guard so --stateless works for both remote URLs and local container workloads. Add Allow header to 405 responses per RFC 9110. Update comments and flag text to remove remote-only language. Signed-off-by: Greg Katz * Add E2E test for stateless proxy mode Verify that --stateless makes the proxy reject GET and DELETE with 405 and forward POST to the upstream MCP server. Uses an in-process mock server to avoid external dependencies. Signed-off-by: Greg Katz --------- Signed-off-by: Greg Katz --- cmd/thv/app/run_flags.go | 7 + docs/cli/thv_run.md | 1 + docs/server/docs.go | 4 + docs/server/swagger.json | 4 + docs/server/swagger.yaml | 7 + pkg/runner/config.go | 6 + pkg/runner/config_builder.go | 8 + pkg/runner/runner.go | 11 + pkg/transport/http.go | 33 ++- .../proxy/transparent/method_gate_test.go | 73 +++++ pkg/transport/proxy/transparent/pinger.go | 66 +++++ .../proxy/transparent/pinger_test.go | 154 ++++++++++ .../proxy/transparent/transparent_proxy.go | 39 ++- test/e2e/stateless_proxy_test.go | 263 ++++++++++++++++++ 14 files changed, 666 insertions(+), 10 deletions(-) create mode 100644 pkg/transport/proxy/transparent/method_gate_test.go create mode 100644 pkg/transport/proxy/transparent/pinger_test.go create mode 100644 test/e2e/stateless_proxy_test.go diff --git a/cmd/thv/app/run_flags.go b/cmd/thv/app/run_flags.go index cb874584e7..d17e310f8e 100644 --- a/cmd/thv/app/run_flags.go +++ b/cmd/thv/app/run_flags.go @@ -59,6 +59,9 @@ type RunFlags struct { // Remote MCP server support RemoteURL string + // Stateless indicates the server is stateless (POST-only, no SSE) + Stateless bool + // Security and audit AuthzConfig string AuditConfig string @@ -258,6 +261,9 @@ func AddRunFlags(cmd *cobra.Command, config *RunFlags) { cmd.Flags().BoolVar(&config.TrustProxyHeaders, "trust-proxy-headers", false, "Trust X-Forwarded-* headers from reverse proxies (X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, X-Forwarded-Prefix) "+ "(default false)") + cmd.Flags().BoolVar(&config.Stateless, "stateless", false, + "Declare the server as stateless (POST-only, no SSE). "+ + "Use for MCP servers implementing streamable-HTTP stateless mode.") cmd.Flags().StringVar(&config.EndpointPrefix, "endpoint-prefix", "", "Path prefix to prepend to SSE endpoint URLs (e.g., /playwright)") cmd.Flags().StringVar(&config.Network, "network", "", @@ -658,6 +664,7 @@ func buildRunnerConfig( runner.WithNetworkIsolation(runFlags.IsolateNetwork), runner.WithAllowDockerGateway(runFlags.AllowDockerGateway), runner.WithTrustProxyHeaders(runFlags.TrustProxyHeaders), + runner.WithStateless(runFlags.Stateless), runner.WithEndpointPrefix(runFlags.EndpointPrefix), runner.WithNetworkMode(runFlags.Network), runner.WithK8sPodPatch(runFlags.K8sPodPatch), diff --git a/docs/cli/thv_run.md b/docs/cli/thv_run.md index 443473ca5e..257705f733 100644 --- a/docs/cli/thv_run.md +++ b/docs/cli/thv_run.md @@ -177,6 +177,7 @@ thv run [flags] SERVER_OR_IMAGE_OR_PROTOCOL [-- ARGS...] --runtime-add-package stringArray Add additional packages to install in the builder and runtime stages (can be repeated) --runtime-image string Override the default base image for protocol schemes (e.g., golang:1.24-alpine, node:20-alpine, python:3.11-slim) --secret stringArray Specify a secret to be fetched from the secrets manager and set as an environment variable (format: NAME,target=TARGET) + --stateless Declare the server as stateless (POST-only, no SSE). Use for MCP servers implementing streamable-HTTP stateless mode. --target-host string Host to forward traffic to (only applicable to SSE or Streamable HTTP transport) (default "127.0.0.1") --target-port int Port for the container to expose (only applicable to SSE or Streamable HTTP transport) --thv-ca-bundle string Path to CA certificate bundle for ToolHive HTTP operations (JWKS, OIDC discovery, etc.) diff --git a/docs/server/docs.go b/docs/server/docs.go index 3c09f2ebb8..aac4a5ffe8 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1285,6 +1285,10 @@ const docTemplate = `{ "type": "array", "uniqueItems": false }, + "stateless": { + "description": "Stateless indicates the server only supports POST (no SSE/GET).\nWhen true, the proxy returns 405 for incoming GET requests and uses a\nPOST-based health check instead of the default GET probe.\nApplies to both remote URLs and local container workloads.", + "type": "boolean" + }, "target_host": { "description": "TargetHost is the host to forward traffic to (only applicable to SSE transport)", "type": "string" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 51997b4091..0df2a39676 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1278,6 +1278,10 @@ "type": "array", "uniqueItems": false }, + "stateless": { + "description": "Stateless indicates the server only supports POST (no SSE/GET).\nWhen true, the proxy returns 405 for incoming GET requests and uses a\nPOST-based health check instead of the default GET probe.\nApplies to both remote URLs and local container workloads.", + "type": "boolean" + }, "target_host": { "description": "TargetHost is the host to forward traffic to (only applicable to SSE transport)", "type": "string" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 75d03d2e2d..689da8daff 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1212,6 +1212,13 @@ components: type: string type: array uniqueItems: false + stateless: + description: |- + Stateless indicates the server only supports POST (no SSE/GET). + When true, the proxy returns 405 for incoming GET requests and uses a + POST-based health check instead of the default GET probe. + Applies to both remote URLs and local container workloads. + type: boolean target_host: description: TargetHost is the host to forward traffic to (only applicable to SSE transport) diff --git a/pkg/runner/config.go b/pkg/runner/config.go index c0f651f04d..519f277abd 100644 --- a/pkg/runner/config.go +++ b/pkg/runner/config.go @@ -191,6 +191,12 @@ type RunConfig struct { // TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies TrustProxyHeaders bool `json:"trust_proxy_headers,omitempty" yaml:"trust_proxy_headers,omitempty"` + // Stateless indicates the server only supports POST (no SSE/GET). + // When true, the proxy returns 405 for incoming GET requests and uses a + // POST-based health check instead of the default GET probe. + // Applies to both remote URLs and local container workloads. + Stateless bool `json:"stateless,omitempty" yaml:"stateless,omitempty"` + // ProxyMode is the effective HTTP protocol the proxy uses. // For stdio transports, this is the configured mode (sse or streamable-http). // For direct transports (sse/streamable-http), this matches the transport type. diff --git a/pkg/runner/config_builder.go b/pkg/runner/config_builder.go index 35070410e4..89cdf2c318 100644 --- a/pkg/runner/config_builder.go +++ b/pkg/runner/config_builder.go @@ -338,6 +338,14 @@ func WithTrustProxyHeaders(trust bool) RunConfigBuilderOption { } } +// WithStateless declares the server is stateless (POST-only, no SSE). +func WithStateless(stateless bool) RunConfigBuilderOption { + return func(b *runConfigBuilder) error { + b.config.Stateless = stateless + return nil + } +} + // WithEndpointPrefix sets the path prefix for SSE endpoint URLs func WithEndpointPrefix(prefix string) RunConfigBuilderOption { return func(b *runConfigBuilder) error { diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index ccec7eca4a..9f36a1a15a 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -443,6 +443,17 @@ func (r *Runner) Run(ctx context.Context) error { }) } + // Configure stateless mode if requested. Stateless mode applies to any + // streamable-HTTP server (remote or local container) where the upstream + // only accepts POST and does not support SSE-based sessions. + if r.Config.Stateless { + httpT, ok := transportHandler.(*transport.HTTPTransport) + if !ok { + return fmt.Errorf("--stateless requires streamable-HTTP or SSE transport, got %T", transportHandler) + } + httpT.SetStateless(true) + } + // Start the transport (which also starts the container and monitoring) slog.Debug("starting transport", "transport", r.Config.Transport, "container", r.Config.ContainerName) if err := transportHandler.Start(ctx); err != nil { diff --git a/pkg/transport/http.go b/pkg/transport/http.go index a9c81bd343..53b9af6d12 100644 --- a/pkg/transport/http.go +++ b/pkg/transport/http.go @@ -58,6 +58,9 @@ type HTTPTransport struct { // Remote MCP server support remoteURL string + // stateless indicates the server is POST-only (no SSE/GET support) + stateless bool + // tokenSource is the OAuth token source for remote authentication tokenSource oauth2.TokenSource @@ -152,6 +155,11 @@ func (t *HTTPTransport) SetOnHealthCheckFailed(callback types.HealthCheckFailedC t.onHealthCheckFailed = callback } +// SetStateless configures the transport for a stateless server. +func (t *HTTPTransport) SetStateless(stateless bool) { + t.stateless = stateless +} + // SetOnUnauthorizedResponse sets the callback for 401 Unauthorized responses // The callback is wrapped to check the unauthorized flag to prevent repeated status updates func (t *HTTPTransport) SetOnUnauthorizedResponse(callback types.UnauthorizedResponseCallback) { @@ -321,14 +329,7 @@ func (t *HTTPTransport) Start(ctx context.Context) error { enableHealthCheck := shouldEnableHealthCheck(isRemote) // Build proxy options - var proxyOptions []transparent.Option - if remoteBasePath != "" { - proxyOptions = append(proxyOptions, transparent.WithRemoteBasePath(remoteBasePath)) - } - proxyOptions = append(proxyOptions, transparent.WithRemoteRawQuery(remoteRawQuery)) - if t.sessionStorage != nil { - proxyOptions = append(proxyOptions, transparent.WithSessionStorage(t.sessionStorage)) - } + proxyOptions := t.buildProxyOptions(remoteBasePath, remoteRawQuery) // Create the transparent proxy t.proxy = transparent.NewTransparentProxyWithOptions( @@ -421,6 +422,22 @@ func (t *HTTPTransport) Stop(ctx context.Context) error { return nil } +// buildProxyOptions constructs the transparent proxy options for this transport. +func (t *HTTPTransport) buildProxyOptions(remoteBasePath, remoteRawQuery string) []transparent.Option { + var opts []transparent.Option + if remoteBasePath != "" { + opts = append(opts, transparent.WithRemoteBasePath(remoteBasePath)) + } + opts = append(opts, transparent.WithRemoteRawQuery(remoteRawQuery)) + if t.stateless { + opts = append(opts, transparent.WithStateless()) + } + if t.sessionStorage != nil { + opts = append(opts, transparent.WithSessionStorage(t.sessionStorage)) + } + return opts +} + // handleContainerExit handles container exit events. // It loops to support reconnecting the monitor when a container is restarted // by Docker (e.g., via restart policy) rather than truly exiting. diff --git a/pkg/transport/proxy/transparent/method_gate_test.go b/pkg/transport/proxy/transparent/method_gate_test.go new file mode 100644 index 0000000000..9bfc2e35c5 --- /dev/null +++ b/pkg/transport/proxy/transparent/method_gate_test.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package transparent + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStatelessMethodGate(t *testing.T) { + t.Parallel() + + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + tests := []struct { + name string + method string + expectedStatus int + expectAllow bool + }{ + { + name: "GET returns 405 with Allow header", + method: http.MethodGet, + expectedStatus: http.StatusMethodNotAllowed, + expectAllow: true, + }, + { + name: "HEAD returns 405 with Allow header", + method: http.MethodHead, + expectedStatus: http.StatusMethodNotAllowed, + expectAllow: true, + }, + { + name: "DELETE returns 405 with Allow header", + method: http.MethodDelete, + expectedStatus: http.StatusMethodNotAllowed, + expectAllow: true, + }, + { + name: "POST is forwarded", + method: http.MethodPost, + expectedStatus: http.StatusOK, + }, + { + name: "PUT is forwarded", + method: http.MethodPut, + expectedStatus: http.StatusOK, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + handler := statelessMethodGate(inner) + rec := httptest.NewRecorder() + req := httptest.NewRequest(tc.method, "/", nil) + + handler.ServeHTTP(rec, req) + + assert.Equal(t, tc.expectedStatus, rec.Code) + if tc.expectAllow { + assert.Equal(t, "POST, OPTIONS", rec.Header().Get("Allow")) + } + }) + } +} diff --git a/pkg/transport/proxy/transparent/pinger.go b/pkg/transport/proxy/transparent/pinger.go index 008024dc8c..9144bff464 100644 --- a/pkg/transport/proxy/transparent/pinger.go +++ b/pkg/transport/proxy/transparent/pinger.go @@ -9,6 +9,7 @@ import ( "fmt" "log/slog" "net/http" + "strings" "time" "github.com/stacklok/toolhive/pkg/healthcheck" @@ -87,3 +88,68 @@ func (p *MCPPinger) Ping(ctx context.Context) (time.Duration, error) { return duration, fmt.Errorf("SSE server health check failed with status %d", resp.StatusCode) } + +// StatelessMCPPinger health-checks stateless streamable-HTTP servers via POST ping. +// Stateless servers don't support GET, so we send a minimal JSON-RPC ping instead. +type StatelessMCPPinger struct { + targetURL string + client *http.Client +} + +// NewStatelessMCPPinger creates a pinger for stateless streamable-HTTP servers. +func NewStatelessMCPPinger(targetURL string) healthcheck.MCPPinger { + return NewStatelessMCPPingerWithTimeout(targetURL, DefaultPingerTimeout) +} + +// NewStatelessMCPPingerWithTimeout creates a stateless pinger with a custom timeout. +func NewStatelessMCPPingerWithTimeout(targetURL string, timeout time.Duration) healthcheck.MCPPinger { + if timeout <= 0 { + timeout = DefaultPingerTimeout + } + return &StatelessMCPPinger{ + targetURL: targetURL, + client: &http.Client{ + Timeout: timeout, + }, + } +} + +// Ping sends a JSON-RPC ping POST to check if the stateless server is reachable. +// Accepts any 2xx-4xx response as healthy; only network errors and 5xx indicate failure. +func (p *StatelessMCPPinger) Ping(ctx context.Context) (time.Duration, error) { + start := time.Now() + + body := `{"jsonrpc":"2.0","id":0,"method":"ping","params":{}}` + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.targetURL, strings.NewReader(body)) + if err != nil { + return 0, fmt.Errorf("failed to create stateless ping request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + //nolint:gosec // G706: logging target URL from config + slog.Debug("checking stateless MCP server health via POST ping", "target", p.targetURL) + + resp, err := p.client.Do(req) + if err != nil { + return time.Since(start), fmt.Errorf("stateless ping failed to connect: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Debug("failed to close ping response body", "error", err) + } + }() + + duration := time.Since(start) + + // Accept 2xx-4xx: even 401/403 means the server is reachable. + // Only 5xx or network errors indicate the server is down. + if resp.StatusCode >= 200 && resp.StatusCode < 500 { + //nolint:gosec // G706: logging HTTP status code from health check response + slog.Debug("stateless MCP server health check successful", + "duration", duration, "status", resp.StatusCode) + return duration, nil + } + + return duration, fmt.Errorf("stateless ping returned status %d", resp.StatusCode) +} diff --git a/pkg/transport/proxy/transparent/pinger_test.go b/pkg/transport/proxy/transparent/pinger_test.go new file mode 100644 index 0000000000..1c675c3f5a --- /dev/null +++ b/pkg/transport/proxy/transparent/pinger_test.go @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package transparent + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStatelessMCPPinger_Ping(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + serverFunc func(w http.ResponseWriter, r *http.Request) + wantErr bool + wantHealthy bool // true = nil error, positive duration + }{ + { + name: "200 OK is healthy", + serverFunc: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + wantErr: false, + wantHealthy: true, + }, + { + name: "401 unauthorized is treated as healthy", + serverFunc: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }, + wantErr: false, + wantHealthy: true, + }, + { + name: "403 forbidden is treated as healthy", + serverFunc: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + }, + wantErr: false, + wantHealthy: true, + }, + { + name: "500 server error returns an error", + serverFunc: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(tc.serverFunc)) + defer srv.Close() + + pinger := NewStatelessMCPPinger(srv.URL) + duration, err := pinger.Ping(context.Background()) + + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Positive(t, duration, "duration should be positive on success") + }) + } +} + +func TestStatelessMCPPinger_Ping_ConnectionRefused(t *testing.T) { + t.Parallel() + + // Point at a port where nothing is listening. Use a server, start it, + // close it immediately so the port is definitely not in use. + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + addr := srv.URL + srv.Close() + + pinger := NewStatelessMCPPingerWithTimeout(addr, 2*time.Second) + _, err := pinger.Ping(context.Background()) + require.Error(t, err, "should return error when connection is refused") +} + +func TestStatelessMCPPinger_Ping_UsesPost(t *testing.T) { + t.Parallel() + + var receivedMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedMethod = r.Method + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + pinger := NewStatelessMCPPinger(srv.URL) + _, err := pinger.Ping(context.Background()) + require.NoError(t, err) + + assert.Equal(t, http.MethodPost, receivedMethod, "pinger should use POST method") +} + +func TestStatelessMCPPinger_Ping_SendsJsonBody(t *testing.T) { + t.Parallel() + + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(r.Body) + require.NoError(t, err) + err = json.Unmarshal(raw, &body) + require.NoError(t, err) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + pinger := NewStatelessMCPPinger(srv.URL) + _, err := pinger.Ping(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "2.0", body["jsonrpc"], "body should contain jsonrpc field") + assert.Equal(t, "ping", body["method"], "body should contain method field") + _, hasID := body["id"] + assert.True(t, hasID, "body should contain id field") +} + +func TestNewStatelessMCPPingerWithTimeout_ZeroTimeout(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + // Zero timeout should be replaced by DefaultPingerTimeout — the pinger + // must still work (i.e., not time out immediately on a live server). + pinger := NewStatelessMCPPingerWithTimeout(srv.URL, 0) + _, err := pinger.Ping(context.Background()) + require.NoError(t, err, "pinger with zero timeout should default to DefaultPingerTimeout and succeed") + + // Verify the underlying client has the default timeout set. + sp, ok := pinger.(*StatelessMCPPinger) + require.True(t, ok, "pinger should be *StatelessMCPPinger") + assert.Equal(t, DefaultPingerTimeout, sp.client.Timeout) +} diff --git a/pkg/transport/proxy/transparent/transparent_proxy.go b/pkg/transport/proxy/transparent/transparent_proxy.go index f0f72c539d..e7078886b8 100644 --- a/pkg/transport/proxy/transparent/transparent_proxy.go +++ b/pkg/transport/proxy/transparent/transparent_proxy.go @@ -90,6 +90,9 @@ type TransparentProxy struct { // Transport type (sse, streamable-http) transportType string + // stateless indicates the server is POST-only (no SSE/GET support) + stateless bool + // Callback when health check fails (for remote servers) onHealthCheckFailed types.HealthCheckFailedCallback @@ -236,6 +239,15 @@ func WithRemoteRawQuery(rawQuery string) Option { } } +// WithStateless configures the proxy for stateless streamable-HTTP servers. +// In stateless mode, incoming GET and DELETE requests receive 405 Method Not Allowed +// instead of being forwarded, and health checks use POST ping instead of GET. +func WithStateless() Option { + return func(p *TransparentProxy) { + p.stateless = true + } +} + // withHealthCheckPingTimeout sets the health check ping timeout. // This is primarily useful for testing with shorter timeouts. // Ignores non-positive timeouts; default will be used. @@ -448,7 +460,11 @@ func NewTransparentProxyWithOptions( if pingTimeout == 0 { pingTimeout = DefaultPingerTimeout } - mcpPinger = NewMCPPingerWithTimeout(targetURI, pingTimeout) + if proxy.stateless { + mcpPinger = NewStatelessMCPPingerWithTimeout(targetURI, pingTimeout) + } else { + mcpPinger = NewMCPPingerWithTimeout(targetURI, pingTimeout) + } } proxy.healthChecker = healthcheck.NewHealthChecker(transportType, mcpPinger) @@ -1109,7 +1125,11 @@ func (p *TransparentProxy) Start(ctx context.Context) error { // 5. Catch-all proxy handler (least specific - ServeMux routing handles precedence) // Note: No manual path checking needed - ServeMux longest-match routing ensures - // more specific paths registered above take precedence over this catch-all + // more specific paths registered above take precedence over this catch-all. + // In stateless mode, wrap with a method gate that rejects GET/DELETE with 405. + if p.stateless { + finalHandler = statelessMethodGate(finalHandler) + } mux.Handle("/", finalHandler) // Use ListenConfig with SO_REUSEADDR to allow port reuse after unclean shutdown @@ -1368,3 +1388,18 @@ func (*TransparentProxy) SendMessageToDestination(_ jsonrpc2.Message) error { func (*TransparentProxy) ForwardResponseToClients(_ context.Context, _ jsonrpc2.Message) error { return fmt.Errorf("ForwardResponseToClients not implemented for TransparentProxy") } + +// statelessMethodGate wraps a handler to reject GET, HEAD, and DELETE requests with 405. +// Used in stateless mode where the server only supports POST. +// HEAD is blocked alongside GET because HEAD is semantically a GET without a response body; +// a server that cannot handle GET will not handle HEAD either. +func statelessMethodGate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodDelete { + w.Header().Set("Allow", "POST, OPTIONS") + http.Error(w, "method not allowed: server is stateless (POST only)", http.StatusMethodNotAllowed) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/test/e2e/stateless_proxy_test.go b/test/e2e/stateless_proxy_test.go new file mode 100644 index 0000000000..3b9c64baf8 --- /dev/null +++ b/test/e2e/stateless_proxy_test.go @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package e2e_test + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "strings" + "sync/atomic" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stacklok/toolhive/test/e2e" +) + +var _ = Describe("Stateless Proxy Mode", Label("proxy", "stateless", "streamable-http", "e2e"), Serial, func() { + var ( + config *e2e.TestConfig + serverName string + mockServer *statelessMockMCPServer + ) + + BeforeEach(func() { + config = e2e.NewTestConfig() + serverName = e2e.GenerateUniqueServerName("stateless") + + err := e2e.CheckTHVBinaryAvailable(config) + Expect(err).ToNot(HaveOccurred(), "thv binary should be available") + }) + + AfterEach(func() { + if mockServer != nil { + mockServer.Stop() + mockServer = nil + } + + if config.CleanupAfter { + err := e2e.StopAndRemoveMCPServer(config, serverName) + Expect(err).ToNot(HaveOccurred(), "Should be able to stop and remove server") + } + }) + + Describe("Method gating for stateless servers", func() { + Context("when --stateless flag is set on a remote server", func() { + It("should reject GET requests and forward POST requests", func() { + By("Starting a stateless mock MCP server") + var err error + mockServer, err = newStatelessMockMCPServer() + Expect(err).ToNot(HaveOccurred(), "Should be able to start mock server") + + mockServerURL := mockServer.URL() + GinkgoWriter.Printf("Mock server started at: %s\n", mockServerURL) + + By("Starting thv with --stateless flag") + thvCmd := exec.Command(config.THVBinary, "run", + "--name", serverName, + "--stateless", + mockServerURL+"/mcp") + thvCmd.Env = append(os.Environ(), + "TOOLHIVE_REMOTE_HEALTHCHECKS=true", + ) + thvCmd.Stdout = GinkgoWriter + thvCmd.Stderr = GinkgoWriter + + err = thvCmd.Start() + Expect(err).ToNot(HaveOccurred(), "Should be able to start thv") + + thvPID := thvCmd.Process.Pid + GinkgoWriter.Printf("thv process started with PID: %d\n", thvPID) + + defer func() { + if proc, err := os.FindProcess(thvPID); err == nil { + _ = proc.Kill() + } + }() + + By("Waiting for thv to register as running") + err = e2e.WaitForMCPServer(config, serverName, 60*time.Second) + Expect(err).ToNot(HaveOccurred(), "Server should be running within 60 seconds") + + By("Getting the proxy URL") + proxyURL, err := e2e.GetMCPServerURL(config, serverName) + Expect(err).ToNot(HaveOccurred(), "Should be able to get proxy URL") + // Ensure URL has /mcp suffix + if !strings.HasSuffix(proxyURL, "/mcp") { + proxyURL += "/mcp" + } + GinkgoWriter.Printf("Proxy URL: %s\n", proxyURL) + + By("Verifying GET requests are rejected with 405") + resp, err := http.Get(proxyURL) + Expect(err).ToNot(HaveOccurred(), "Should be able to connect to proxy") + resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusMethodNotAllowed), + "GET request should be rejected with 405 Method Not Allowed") + + By("Verifying POST requests are forwarded successfully") + initReq := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"e2e-test","version":"1.0"}}}` + postResp, err := http.Post(proxyURL, "application/json", strings.NewReader(initReq)) + Expect(err).ToNot(HaveOccurred(), "Should be able to POST to proxy") + defer postResp.Body.Close() + + Expect(postResp.StatusCode).To(Equal(http.StatusOK), + "POST request should be forwarded and return 200") + + body, err := io.ReadAll(postResp.Body) + Expect(err).ToNot(HaveOccurred(), "Should be able to read response body") + + var jsonRPC map[string]interface{} + err = json.Unmarshal(body, &jsonRPC) + Expect(err).ToNot(HaveOccurred(), "Response should be valid JSON-RPC") + Expect(jsonRPC).To(HaveKey("result"), "Response should have a result field") + + By("Verifying DELETE requests are also rejected") + delReq, err := http.NewRequest(http.MethodDelete, proxyURL, nil) + Expect(err).ToNot(HaveOccurred()) + delResp, err := http.DefaultClient.Do(delReq) + Expect(err).ToNot(HaveOccurred(), "Should be able to send DELETE to proxy") + delResp.Body.Close() + Expect(delResp.StatusCode).To(Equal(http.StatusMethodNotAllowed), + "DELETE request should be rejected with 405") + + By("Verifying the mock server received POST requests through the proxy") + Expect(mockServer.GetCount()).To(BeNumerically(">", 0), + "Mock server should have received at least one POST request") + }) + }) + }) +}) + +// statelessMockMCPServer is a minimal MCP server that only accepts POST. +// It tracks whether any GET requests reached it (which would indicate +// the proxy's method gate is not working). +type statelessMockMCPServer struct { + server *http.Server + listener net.Listener + port int + gotGET atomic.Bool + postHits atomic.Int32 +} + +func newStatelessMockMCPServer() (*statelessMockMCPServer, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("failed to create listener: %w", err) + } + + port := listener.Addr().(*net.TCPAddr).Port + + mock := &statelessMockMCPServer{ + listener: listener, + port: port, + } + + mock.server = &http.Server{ + Handler: http.HandlerFunc(mock.handleRequest), + } + + go func() { + if err := mock.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + GinkgoWriter.Printf("Stateless mock server error: %v\n", err) + } + }() + + time.Sleep(100 * time.Millisecond) + + return mock, nil +} + +func (m *statelessMockMCPServer) handleRequest(w http.ResponseWriter, r *http.Request) { + // Always return 404 for OAuth well-known URIs + if strings.HasPrefix(r.URL.Path, "/.well-known/") { + w.WriteHeader(http.StatusNotFound) + return + } + + if r.Method == http.MethodGet { + m.gotGET.Store(true) + // A real stateless server would reject GETs, but we accept them here + // so the test can detect if any GETs leaked through the proxy. + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + m.postHits.Add(1) + + // Parse the JSON-RPC request to return appropriate responses + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + var req map[string]interface{} + if err := json.Unmarshal(body, &req); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + method, _ := req["method"].(string) + id := req["id"] + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + switch method { + case "initialize": + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "result": map[string]interface{}{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]interface{}{}, + "serverInfo": map[string]interface{}{ + "name": "stateless-mock", + "version": "1.0.0", + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + case "ping": + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "result": map[string]interface{}{}, + } + _ = json.NewEncoder(w).Encode(resp) + default: + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "result": map[string]interface{}{}, + } + _ = json.NewEncoder(w).Encode(resp) + } +} + +func (m *statelessMockMCPServer) URL() string { + return fmt.Sprintf("http://127.0.0.1:%d", m.port) +} + +func (m *statelessMockMCPServer) Stop() { + if m.server != nil { + _ = m.server.Close() + } +} + +func (m *statelessMockMCPServer) GetCount() int32 { + return m.postHits.Load() +} + +func (m *statelessMockMCPServer) GotGET() bool { + return m.gotGET.Load() +} From 08e4b5282e45c0452bbce8dc01a22ab84a847e24 Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota Date: Tue, 14 Apr 2026 09:28:58 +0200 Subject: [PATCH 003/117] Remove redundant storage.Load from NotifyBackendExpired (#4779) Accept the caller's metadata map directly rather than re-loading it from storage. Any caller that detects a backend expiry already holds the session metadata (e.g. from MultiSession.GetMetadata), so the extra round-trip is unnecessary. Passing nil is treated as a silent no-op. Safety is preserved: updateMetadata uses SET XX, so a concurrently terminated (deleted) session is never resurrected. Closes #4667 Co-authored-by: taskbot --- pkg/vmcp/server/session_manager_interface.go | 14 +++-- .../server/sessionmanager/session_manager.go | 47 +++++++------- .../sessionmanager/session_manager_test.go | 63 ++++++++++++------- 3 files changed, 75 insertions(+), 49 deletions(-) diff --git a/pkg/vmcp/server/session_manager_interface.go b/pkg/vmcp/server/session_manager_interface.go index 3022d58b5c..d9b35c15ff 100644 --- a/pkg/vmcp/server/session_manager_interface.go +++ b/pkg/vmcp/server/session_manager_interface.go @@ -50,9 +50,13 @@ type SessionManager interface { Terminate(sessionID string) (bool, error) // NotifyBackendExpired updates session metadata in storage to reflect that the - // backend identified by workloadID is no longer connected. It is a best-effort, - // metadata-only operation intended to be called by keepalive or health-monitoring - // components when they detect that a backend session has expired or been lost. - // Storage errors are logged but not returned. - NotifyBackendExpired(sessionID, workloadID string) + // backend identified by workloadID is no longer connected. The caller must + // supply the session metadata it already holds (e.g. from MultiSession.GetMetadata); + // passing nil is treated as "no metadata available" and is a silent no-op. + // The metadata map is treated as read-only; the implementation copies it before + // making any modifications. + // It is a best-effort, metadata-only operation intended to be called by keepalive + // or health-monitoring components when they detect that a backend session has + // expired or been lost. Storage errors are logged but not returned. + NotifyBackendExpired(sessionID, workloadID string, metadata map[string]string) } diff --git a/pkg/vmcp/server/sessionmanager/session_manager.go b/pkg/vmcp/server/sessionmanager/session_manager.go index 54f151b356..49ab10ad86 100644 --- a/pkg/vmcp/server/sessionmanager/session_manager.go +++ b/pkg/vmcp/server/sessionmanager/session_manager.go @@ -206,11 +206,9 @@ const terminateTimeout = 5 * time.Second // performs a single Redis SET. 5 s is consistent with terminateTimeout. const decorateTimeout = 5 * time.Second -// notifyBackendExpiredTimeout bounds each individual storage operation inside -// NotifyBackendExpired() — one Load and one Upsert, each capped independently. -// Each is a single-key Redis operation, so 5 s per call is consistent with -// terminateTimeout and decorateTimeout. Worst-case wall-clock for the function -// is 2 × 5 s = 10 s. +// notifyBackendExpiredTimeout bounds the storage.Update call inside +// NotifyBackendExpired() — a single-key Redis operation, consistent with +// terminateTimeout and decorateTimeout. const notifyBackendExpiredTimeout = 5 * time.Second // Generate implements the SDK's SessionIdManager.Generate(). @@ -521,26 +519,21 @@ func (sm *Manager) Terminate(sessionID string) (isNotAllowed bool, err error) { // cross-pod RestoreSession call does not attempt to reconnect to the expired // backend session. // +// The caller supplies the session metadata it already holds (e.g. from +// MultiSession.GetMetadata). Passing nil metadata is treated as "no metadata +// available" and is a silent no-op, avoiding a redundant storage round-trip. +// // After a successful storage update, the cached entry is not immediately evicted. // On the next GetMultiSession call, checkSession detects that the stored // MetadataKeyBackendIDs differs from the cached session's value, evicts the stale // entry via onEvict, and triggers RestoreSession with the updated metadata. // On storage error, no eviction occurs and the caller retries on the next access. // -// This is a best-effort operation. If the session is absent from storage (not -// found or terminated) the call is a silent no-op. Storage errors are logged -// but not returned. -func (sm *Manager) NotifyBackendExpired(sessionID, workloadID string) { - loadCtx, loadCancel := context.WithTimeout(context.Background(), notifyBackendExpiredTimeout) - defer loadCancel() - metadata, err := sm.storage.Load(loadCtx, sessionID) - if err != nil { - if !errors.Is(err, transportsession.ErrSessionNotFound) { - slog.Warn("NotifyBackendExpired: failed to load session from storage", - "session_id", sessionID, - "workload_id", workloadID, - "error", err) - } +// This is a best-effort operation. If the session key is absent from storage +// (terminated or expired), updateMetadata's SET XX is a no-op. Storage errors +// are logged but not returned. +func (sm *Manager) NotifyBackendExpired(sessionID, workloadID string, metadata map[string]string) { + if metadata == nil { return } if metadata[MetadataKeyTerminated] == MetadataValTrue { @@ -563,16 +556,26 @@ func (sm *Manager) NotifyBackendExpired(sessionID, workloadID string) { // populateBackendMetadata, which uses key presence to distinguish an // explicit zero-backend state from absent/corrupted metadata in // RestoreSession. Trim spaces and drop empty parts for robustness. - delete(metadata, vmcpsession.MetadataKeyBackendSessionPrefix+workloadID) + // + // Copy before mutating so the caller's map is not modified. Mutating the + // caller's map would silently corrupt the in-memory session state, which + // would defeat lazy eviction: checkSession compares stored vs cached + // MetadataKeyBackendIDs to detect drift, so the values must differ after + // this update for eviction to trigger on the next GetMultiSession call. + updated := make(map[string]string, len(metadata)) + for k, v := range metadata { + updated[k] = v + } + delete(updated, vmcpsession.MetadataKeyBackendSessionPrefix+workloadID) var remaining []string for _, p := range strings.Split(backendIDs, ",") { if t := strings.TrimSpace(p); t != "" && t != workloadID { remaining = append(remaining, t) } } - metadata[vmcpsession.MetadataKeyBackendIDs] = strings.Join(remaining, ",") + updated[vmcpsession.MetadataKeyBackendIDs] = strings.Join(remaining, ",") - if err := sm.updateMetadata(sessionID, metadata); err != nil { + if err := sm.updateMetadata(sessionID, updated); err != nil { slog.Warn("NotifyBackendExpired: failed to persist backend expiry to storage", "session_id", sessionID, "workload_id", workloadID, diff --git a/pkg/vmcp/server/sessionmanager/session_manager_test.go b/pkg/vmcp/server/sessionmanager/session_manager_test.go index 8a54f09ca3..49dc56b180 100644 --- a/pkg/vmcp/server/sessionmanager/session_manager_test.go +++ b/pkg/vmcp/server/sessionmanager/session_manager_test.go @@ -6,6 +6,7 @@ package sessionmanager import ( "context" "errors" + "maps" "strings" "testing" "time" @@ -2094,8 +2095,9 @@ func TestNotifyBackendExpired(t *testing.T) { // seedBackendMetadata stores backend metadata directly in storage so that // NotifyBackendExpired has something to operate on. This simulates what - // populateBackendMetadata writes during session creation. - seedBackendMetadata := func(t *testing.T, storage transportsession.DataStorage, sessionID string, ids []string, sessionIDs map[string]string) { + // populateBackendMetadata writes during session creation. It returns the + // metadata map so callers can pass it directly to NotifyBackendExpired. + seedBackendMetadata := func(t *testing.T, storage transportsession.DataStorage, sessionID string, ids []string, sessionIDs map[string]string) map[string]string { t.Helper() meta := map[string]string{ vmcpsession.MetadataKeyBackendIDs: strings.Join(ids, ","), @@ -2104,6 +2106,7 @@ func TestNotifyBackendExpired(t *testing.T) { meta[vmcpsession.MetadataKeyBackendSessionPrefix+workloadID] = sessID } require.NoError(t, storage.Upsert(context.Background(), sessionID, meta)) + return meta } t.Run("clears backend session key and removes from MetadataKeyBackendIDs", func(t *testing.T) { @@ -2120,12 +2123,14 @@ func TestNotifyBackendExpired(t *testing.T) { _, err := sm.CreateSession(t.Context(), sessionID) require.NoError(t, err) - seedBackendMetadata(t, storage, sessionID, + meta := seedBackendMetadata(t, storage, sessionID, []string{"workload-a", "workload-b"}, map[string]string{"workload-a": "sess-a", "workload-b": "sess-b"}, ) - sm.NotifyBackendExpired(sessionID, "workload-a") + metaBefore := maps.Clone(meta) + sm.NotifyBackendExpired(sessionID, "workload-a", meta) + assert.Equal(t, metaBefore, meta, "NotifyBackendExpired must not mutate the caller's metadata map") got, loadErr := storage.Load(context.Background(), sessionID) require.NoError(t, loadErr) @@ -2150,12 +2155,14 @@ func TestNotifyBackendExpired(t *testing.T) { _, err := sm.CreateSession(t.Context(), sessionID) require.NoError(t, err) - seedBackendMetadata(t, storage, sessionID, + meta := seedBackendMetadata(t, storage, sessionID, []string{"workload-a"}, map[string]string{"workload-a": "sess-a"}, ) - sm.NotifyBackendExpired(sessionID, "workload-a") + metaBefore := maps.Clone(meta) + sm.NotifyBackendExpired(sessionID, "workload-a", meta) + assert.Equal(t, metaBefore, meta, "NotifyBackendExpired must not mutate the caller's metadata map") got, loadErr := storage.Load(context.Background(), sessionID) require.NoError(t, loadErr) @@ -2176,12 +2183,15 @@ func TestNotifyBackendExpired(t *testing.T) { sessionID := sm.Generate() // Seed metadata that is missing MetadataKeyBackendIDs — simulates // corrupted or partially-written storage. - require.NoError(t, storage.Upsert(context.Background(), sessionID, map[string]string{ + corruptedMeta := map[string]string{ vmcpsession.MetadataKeyBackendSessionPrefix + "workload-a": "sess-a", // MetadataKeyBackendIDs intentionally absent - })) + } + require.NoError(t, storage.Upsert(context.Background(), sessionID, corruptedMeta)) - sm.NotifyBackendExpired(sessionID, "workload-a") + corruptedMetaBefore := maps.Clone(corruptedMeta) + sm.NotifyBackendExpired(sessionID, "workload-a", corruptedMeta) + assert.Equal(t, corruptedMetaBefore, corruptedMeta, "NotifyBackendExpired must not mutate the caller's metadata map") // Storage must be unchanged — clobbering with "" would drop all backends. got, loadErr := storage.Load(context.Background(), sessionID) @@ -2199,7 +2209,7 @@ func TestNotifyBackendExpired(t *testing.T) { factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) sm, _ := newTestSessionManager(t, factory, newFakeRegistry()) - sm.NotifyBackendExpired("nonexistent-session", "workload-a") // must not panic + sm.NotifyBackendExpired("nonexistent-session", "workload-a", nil) // must not panic }) t.Run("placeholder session (no backend IDs) is a no-op", func(t *testing.T) { @@ -2211,7 +2221,7 @@ func TestNotifyBackendExpired(t *testing.T) { // Generate creates a placeholder with empty metadata. sessionID := sm.Generate() - sm.NotifyBackendExpired(sessionID, "workload-a") + sm.NotifyBackendExpired(sessionID, "workload-a", map[string]string{}) // Placeholder must still exist and be unmodified. got, loadErr := storage.Load(context.Background(), sessionID) @@ -2242,7 +2252,11 @@ func TestNotifyBackendExpired(t *testing.T) { _, err = sm.Terminate(sessionID) require.NoError(t, err) - sm.NotifyBackendExpired(sessionID, "workload-a") + // Caller holds the metadata it observed before termination; updateMetadata's + // SET XX is a no-op because Terminate already deleted the key. + sm.NotifyBackendExpired(sessionID, "workload-a", map[string]string{ + vmcpsession.MetadataKeyBackendIDs: "workload-a", + }) // Session must remain absent — Load after Terminate deletes from storage. _, loadErr := storage.Load(context.Background(), sessionID) @@ -2268,7 +2282,7 @@ func TestNotifyBackendExpired(t *testing.T) { _, err := sm.CreateSession(t.Context(), sessionID) require.NoError(t, err) - seedBackendMetadata(t, storage, sessionID, + meta := seedBackendMetadata(t, storage, sessionID, []string{"workload-a"}, map[string]string{"workload-a": "sess-a"}, ) @@ -2279,7 +2293,9 @@ func TestNotifyBackendExpired(t *testing.T) { // storage.Update (SET XX) in updateMetadata returns (false, nil) because // the key no longer exists — NotifyBackendExpired must bail without // recreating the record. - sm.NotifyBackendExpired(sessionID, "workload-a") + metaBefore := maps.Clone(meta) + sm.NotifyBackendExpired(sessionID, "workload-a", meta) + assert.Equal(t, metaBefore, meta, "NotifyBackendExpired must not mutate the caller's metadata map") _, loadErr := storage.Load(context.Background(), sessionID) assert.ErrorIs(t, loadErr, transportsession.ErrSessionNotFound, @@ -2300,19 +2316,20 @@ func TestNotifyBackendExpired(t *testing.T) { _, err := sm.CreateSession(t.Context(), sessionID) require.NoError(t, err) - seedBackendMetadata(t, storage, sessionID, + meta := seedBackendMetadata(t, storage, sessionID, []string{"workload-a"}, map[string]string{"workload-a": "sess-a"}, ) // Simulate cross-pod termination: another pod called storage.Delete while - // this pod was inside NotifyBackendExpired (after the Load, before the - // Upsert). We delete the key here to represent that state. + // this pod was inside NotifyBackendExpired (before the Upsert). + // We delete the key here to represent that state. require.NoError(t, storage.Delete(context.Background(), sessionID)) - // updateMetadata must re-check storage before upserting; seeing - // ErrSessionNotFound it must bail without recreating the record. - sm.NotifyBackendExpired(sessionID, "workload-a") + // updateMetadata's SET XX sees the absent key and bails without recreating. + metaBefore := maps.Clone(meta) + sm.NotifyBackendExpired(sessionID, "workload-a", meta) + assert.Equal(t, metaBefore, meta, "NotifyBackendExpired must not mutate the caller's metadata map") _, loadErr := storage.Load(context.Background(), sessionID) assert.ErrorIs(t, loadErr, transportsession.ErrSessionNotFound, @@ -2336,12 +2353,14 @@ func TestNotifyBackendExpired(t *testing.T) { // Session must be in cache after CreateSession. assert.Equal(t, 1, sm.sessions.Len(), "session must be in node-local cache after CreateSession") - seedBackendMetadata(t, storage, sessionID, + meta := seedBackendMetadata(t, storage, sessionID, []string{"workload-a"}, map[string]string{"workload-a": "sess-a"}, ) - sm.NotifyBackendExpired(sessionID, "workload-a") + metaBefore := maps.Clone(meta) + sm.NotifyBackendExpired(sessionID, "workload-a", meta) + assert.Equal(t, metaBefore, meta, "NotifyBackendExpired must not mutate the caller's metadata map") // With lazy eviction, session is still in cache immediately after NotifyBackendExpired. // checkSession detects drift on the next GetMultiSession call. From c1653297c42481e511f7d0f75f745d62ed63a0a4 Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota Date: Tue, 14 Apr 2026 09:31:12 +0200 Subject: [PATCH 004/117] Add more claude rules (#4782) * Add more claude rules Generate more rules about style and testing, extrapolated from the comments and bugs in latest prs and reviews * changes from review --------- Co-authored-by: taskbot --- .claude/rules/go-style.md | 66 +++++++++++++++++++++++++++++++++++++++ .claude/rules/testing.md | 49 +++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/.claude/rules/go-style.md b/.claude/rules/go-style.md index 4e78f72c08..886aa651c2 100644 --- a/.claude/rules/go-style.md +++ b/.claude/rules/go-style.md @@ -76,6 +76,72 @@ if someCondition { } ``` +## Copy Before Mutating Caller Input + +Never mutate a value passed in by a caller. Maps and slices have reference semantics — passing them copies the header but shares the underlying data, so mutations are visible to the caller. Pointer parameters (`*T`) directly expose the caller's original value. Plain struct values (`T`) are copies and safe to modify, but structs passed as `*T`, or whose fields include maps, slices, or pointers, can still reach caller-visible data through those fields. In-place mutation surprises callers, can cause data races, and breaks the assumption that the caller's original value is unchanged after the call. + +Always copy the input first and mutate the copy: + +```go +// Good +meta := maps.Clone(callerMeta) +meta["key"] = "value" + +// Avoid +callerMeta["key"] = "value" // mutates the caller's map +``` + +Note that `maps.Clone` (and `slices.Clone`) perform a **shallow copy** — if map values or slice elements contain pointers, slices, or nested maps, mutating those nested values will still affect the caller's data. Use a deep copy when the value type requires it. + +This applies to function parameters, values extracted from context, and values returned by storage/cache loads. If the function's doc comment does not explicitly state "the caller's value will be modified", treat all inputs as read-only. + +## Keep Comments Synchronized With Code + +When you change behavior, update every comment that describes it. A comment that contradicts the code is worse than no comment — it actively misleads future readers and causes incorrect changes. + +- After any refactor, search for comments referencing the old behavior and update them. +- If a comment names a specific function, variable, or mechanism, verify the name is still accurate. +- Comments describing concurrency semantics (eviction timing, lazy vs. eager, which lock is held) are especially prone to drift — treat them as part of the implementation, not decoration. + +## Constructor Validation: Fail Loudly on Invalid Input + +Constructors must validate their required inputs and fail loudly (return an error or panic) rather than silently accepting invalid values and producing surprising behavior. + +- Required parameters: check for nil and return a descriptive error. +- Numeric bounds: reject values outside the valid range (e.g., `capacity < 1`). Zero is Go's default — don't let it silently mean "unlimited" or "disabled". +- Enum/string config: reject unknown values explicitly; don't fall back silently to a default that the caller didn't request. + +Misconfiguration that fails at startup is far easier to diagnose than misconfiguration that silently degrades behavior at runtime. + +## One Synchronization Primitive Per Data Structure + +Use a single synchronization mechanism per data set. Mixing `sync.Mutex` and `sync.Map` (or channels) on the same underlying data is a correctness hazard — future contributors cannot reason about which operations are atomic with respect to each other. + +If atomicity requirements grow beyond what `sync.Map` provides (e.g., you need read-modify-write), replace it with a plain `map` guarded by a `sync.Mutex` for all operations. The performance difference at typical cardinalities is negligible compared to the clarity gained. + +## Drain HTTP Response Bodies Before Closing + +Always drain a response body before closing it in error paths. Closing without reading prevents `net/http` from reusing the underlying TCP connection, causing unnecessary connection churn. + +```go +// Good +_, _ = io.Copy(io.Discard, resp.Body) +resp.Body.Close() + +// Avoid — prevents connection reuse +resp.Body.Close() +``` + +This applies in every code path that discards a response early (error handling, retries, fallbacks). + +## Write to Durable Storage Before Updating In-Memory State + +When a write must update both durable storage (database, Redis, file) and an in-memory structure (cache, map, struct field), always write to the authoritative store first. Update local state only after the durable write succeeds. + +- If the durable write fails, leave in-memory state unchanged — the next read will reload from the source of truth. +- If the process crashes after the durable write but before the in-memory update, the next read reloads correctly. +- Reversing the order leaves a window where in-memory state diverges permanently from durable state on any error. + ## Error Handling - Return errors by default — never silently swallow errors diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index efc4666572..5cdbcca79b 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -35,6 +35,36 @@ Applies to test files and test directories. 5. **Naming**: Test names must match what they actually assert — if the assertion changes, update the name too. 6. **Boilerplate**: Minimize setup code; extract shared setup into helpers with `t.Helper()` +## Running Operator E2E Tests + +Operator E2E tests live in `test/e2e/thv-operator/` and require a Kind cluster. All tasks are defined in `cmd/thv-operator/Taskfile.yml` and must be run from the repo root with `task -d cmd/thv-operator ` (or `cd cmd/thv-operator && task `). + +**Full automated run** (creates cluster, deploys, tests, destroys on exit): +``` +task -d cmd/thv-operator thv-operator-e2e-test +``` + +**Iterative manual workflow** (keep the cluster alive between test runs): +``` +task -d cmd/thv-operator kind-setup-e2e # Kind cluster with NodePort mappings +task -d cmd/thv-operator operator-install-crds +task -d cmd/thv-operator operator-deploy-local # builds & loads local images via ko +task -d cmd/thv-operator thv-operator-e2e-test-run # re-run as many times as needed +task -d cmd/thv-operator kind-destroy # when done +``` + +**Cluster variants:** +- `kind-setup` — plain cluster, no port mappings (general use) +- `kind-setup-e2e` — cluster with NodePort mappings required by Ginkgo E2E tests + +**Chainsaw (operator unit-level E2E):** +``` +task -d cmd/thv-operator operator-e2e-test +``` +Runs `chainsaw` against `test/e2e/chainsaw/operator/` scenarios. Installs `chainsaw` automatically if missing. + +The Ginkgo suite runs with `--procs=8` and uses `kconfig.yaml` (written to repo root by the kind-setup tasks) as its `KUBECONFIG`. + ## E2E Test Coverage E2E tests must verify functional behavior, not just infrastructure state. Confirming that pods are ready or that counts are correct is not sufficient — the test must also exercise the actual code path (send traffic, trigger the feature) to prove it works end-to-end. @@ -70,6 +100,25 @@ Avoid adding test-only hook fields (nil-checked `func()` fields) to production s Existing instances in the codebase are legacy — do not expand them. When touching a struct that already has hook fields, consider extracting them as part of the change. +## Use `t.Cleanup` for Resource Teardown in Parallel Tests + +In tests using `t.Parallel()`, always register resource teardown (stopping servers, closing connections, cancelling contexts) with `t.Cleanup`, not just `defer`. + +In parallel tests, `defer` runs when the parent test function returns — which can happen before `t.Parallel()` subtests finish. `t.Cleanup` handlers are tied to the test's full lifecycle and run after all subtests complete, preventing leaked goroutines, ports, and connections. + +Note: `require.*` uses `runtime.Goexit`, and panics unwind the stack — both run deferred functions. The difference is not about defers being skipped; it's about *when* they run relative to subtests. + +```go +// Good — runs after all subtests complete +server := httptest.NewServer(handler) +t.Cleanup(server.Close) + +// Avoid in parallel tests — may run before subtests finish +defer server.Close() +``` + +Make stop/close functions idempotent (`sync.Once`) when registering with both `t.Cleanup` and an explicit mid-test shutdown. + ## Concurrent Tests: Always Add Timeouts to Blocking Barriers Blocking operations in tests (`WaitGroup.Wait()`, channel receives, `sync.Cond.Wait()`) must have a timeout/fail-fast path. Without one, a panicking goroutine or regression in synchronization logic causes the test to hang until the global `go test` timeout. From 32c23f49c7b29ca1663fb3d4909692e811206dfe Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:50:26 +0200 Subject: [PATCH 005/117] Add VS Code and VS Code Insiders as skill-supporting clients (#4773) * Add VS Code as a skill-supporting client * Add VS Code Insiders as a skill-supporting client --- pkg/client/config.go | 6 ++++ pkg/client/discovery_test.go | 15 ++++++---- pkg/client/skills_test.go | 53 +++++++++++++++++++++++++++++++++--- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/pkg/client/config.go b/pkg/client/config.go index 8338ae7b89..fea07c77a4 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -268,6 +268,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "url", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".copilot", "skills"}, + SkillsProjectPath: []string{".github", "skills"}, }, { ClientType: VSCode, @@ -294,6 +297,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "url", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".copilot", "skills"}, + SkillsProjectPath: []string{".github", "skills"}, }, { ClientType: Cursor, diff --git a/pkg/client/discovery_test.go b/pkg/client/discovery_test.go index 3fae6de698..6597575319 100644 --- a/pkg/client/discovery_test.go +++ b/pkg/client/discovery_test.go @@ -121,11 +121,14 @@ func TestGetClientStatus(t *testing.T) { SkillsProjectPath: []string{".cursor", "skills"}, }, { - ClientType: VSCode, - Description: "Visual Studio Code (Test)", - SettingsFile: "mcp.json", - RelPath: []string{".config", "Code", "User"}, // This path won't exist in test - Extension: JSON, + ClientType: VSCode, + Description: "Visual Studio Code (Test)", + SettingsFile: "mcp.json", + RelPath: []string{".config", "Code", "User"}, // This path won't exist in test + Extension: JSON, + SupportsSkills: true, + SkillsGlobalPath: []string{".copilot", "skills"}, + SkillsProjectPath: []string{".github", "skills"}, }, } @@ -157,7 +160,7 @@ func TestGetClientStatus(t *testing.T) { assert.True(t, exists) assert.False(t, vscodeStatus.Installed) assert.False(t, vscodeStatus.Registered) - assert.False(t, vscodeStatus.SupportsSkills) + assert.True(t, vscodeStatus.SupportsSkills) } func TestGetClientStatus_Sorting(t *testing.T) { diff --git a/pkg/client/skills_test.go b/pkg/client/skills_test.go index f0b0eca31b..ab3f193a4a 100644 --- a/pkg/client/skills_test.go +++ b/pkg/client/skills_test.go @@ -53,7 +53,19 @@ func testSkillClientIntegrations() []clientAppConfig { SkillsProjectPath: []string{".kimi", "skills"}, }, { - ClientType: VSCode, + ClientType: VSCode, + SupportsSkills: true, + SkillsGlobalPath: []string{".copilot", "skills"}, + SkillsProjectPath: []string{".github", "skills"}, + }, + { + ClientType: VSCodeInsider, + SupportsSkills: true, + SkillsGlobalPath: []string{".copilot", "skills"}, + SkillsProjectPath: []string{".github", "skills"}, + }, + { + ClientType: Windsurf, // SupportsSkills defaults to false }, { @@ -84,6 +96,9 @@ func TestSupportsSkills(t *testing.T) { {name: "OpenCode supports skills", client: OpenCode, expected: true}, {name: "Cursor supports skills", client: Cursor, expected: true}, {name: "KimiCli supports skills", client: KimiCli, expected: true}, + {name: "VSCode supports skills", client: VSCode, expected: true}, + {name: "VSCodeInsider supports skills", client: VSCodeInsider, expected: true}, + {name: "Windsurf does not support skills", client: Windsurf, expected: false}, {name: "unknown client returns false", client: ClientApp("nonexistent"), expected: false}, } @@ -100,8 +115,8 @@ func TestListSkillSupportingClients(t *testing.T) { cm := newTestSkillManager() clients := cm.ListSkillSupportingClients() - // Should include ClaudeCode, Codex, Cursor, KimiCli, OpenCode, and our test-only no-paths-client - require.Len(t, clients, 6, "unexpected number of skill-supporting clients: %v", clients) + // Should include ClaudeCode, Codex, Cursor, KimiCli, OpenCode, VSCode, VSCodeInsider, and our test-only no-paths-client + require.Len(t, clients, 8, "unexpected number of skill-supporting clients: %v", clients) // Verify sorted order for i := 1; i < len(clients); i++ { @@ -229,10 +244,40 @@ func TestGetSkillPath(t *testing.T) { wantPath: filepath.Join("/tmp/myproject", ".kimi", "skills", "my-skill"), }, { - name: "client that does not support skills", + name: "ScopeUser VSCode", client: VSCode, skillName: "my-skill", scope: skills.ScopeUser, + wantPath: filepath.Join(testHomeDir, ".copilot", "skills", "my-skill"), + }, + { + name: "ScopeProject VSCode with explicit root", + client: VSCode, + skillName: "my-skill", + scope: skills.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".github", "skills", "my-skill"), + }, + { + name: "ScopeUser VSCodeInsider", + client: VSCodeInsider, + skillName: "my-skill", + scope: skills.ScopeUser, + wantPath: filepath.Join(testHomeDir, ".copilot", "skills", "my-skill"), + }, + { + name: "ScopeProject VSCodeInsider with explicit root", + client: VSCodeInsider, + skillName: "my-skill", + scope: skills.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".github", "skills", "my-skill"), + }, + { + name: "client that does not support skills", + client: Windsurf, + skillName: "my-skill", + scope: skills.ScopeUser, wantErr: ErrSkillsNotSupported, }, { From f6903bba102461de541828a0906ec516c5cf64c4 Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:17:10 +0200 Subject: [PATCH 006/117] Fix skill install failure when multiple clients share the same directory (#4796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Deduplicate client directories during skill extraction VS Code and VS Code Insiders both resolve to ~/.copilot/skills/ for user-scoped skills. When installing without --clients (the default), the system expands to all skill-supporting clients and attempts to extract to the same directory twice — the second extraction fails because the directory already exists. Add uniqueDirClients() to skip duplicate directories during extraction, pre-checks, and rollback while still recording all client names in the DB. Applied to all five extraction paths: fresh OCI, same-digest new clients, digest upgrade, fresh git, and existing git. Made-with: Cursor * Seed uniqueDirClients with existing client directories When a user first installs with --clients vscode and later re-installs without --clients (expanding to both vscode and vscode-insider which share ~/.copilot/skills/), toWrite contains only vscode-insider. The previous uniqueDirClients had nothing to deduplicate, so the stat check fired on the directory that vscode already created, returning a false StatusConflict. Add an occupiedDirs parameter to uniqueDirClients and a companion existingClientDirs helper that builds the seed set from the existing installed clients. The two affected paths (installExtractionSameDigest NewClients and applyGitInstallExisting new-clients branch) now pass the existing directories so that new clients sharing an already-owned directory are skipped. When all new clients are skipped, the DB record is still updated to include the new client names. --- pkg/skills/skillsvc/skillsvc.go | 88 ++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 13 deletions(-) diff --git a/pkg/skills/skillsvc/skillsvc.go b/pkg/skills/skillsvc/skillsvc.go index c3623d659f..91c43d1912 100644 --- a/pkg/skills/skillsvc/skillsvc.go +++ b/pkg/skills/skillsvc/skillsvc.go @@ -792,8 +792,10 @@ func (s *service) applyGitInstallExisting( if err != nil { return nil, err } + // Deduplicate so clients sharing the same directory don't conflict. + dirsToWrite := uniqueDirClients(allClients, allDirs, nil) return s.gitWriteMultiAndPersist(ctx, opts, scope, allClients, allDirs, files, - allClients, nil, true, true) + dirsToWrite, nil, true, true) } clientsExplicit := len(opts.Clients) > 0 if clientsContainAll(existing.Clients, clientTypes) || @@ -804,7 +806,13 @@ func (s *service) applyGitInstallExisting( if len(toWrite) == 0 { return &skills.InstallResult{Skill: existing}, nil } - for _, ct := range toWrite { + // Deduplicate and skip directories already owned by existing clients. + dirsToWrite := uniqueDirClients(toWrite, clientDirs, existingClientDirs(existing.Clients, clientDirs)) + if len(dirsToWrite) == 0 { + return s.gitWriteMultiAndPersist(ctx, opts, scope, clientTypes, clientDirs, files, + nil, existing.Clients, true, false) + } + for _, ct := range dirsToWrite { dir := filepath.Clean(clientDirs[ct]) if _, statErr := os.Stat(dir); statErr == nil && !opts.Force { // lgtm[go/path-injection] return nil, httperr.WithCode( @@ -814,7 +822,7 @@ func (s *service) applyGitInstallExisting( } } return s.gitWriteMultiAndPersist(ctx, opts, scope, clientTypes, clientDirs, files, - toWrite, existing.Clients, true, false) + dirsToWrite, existing.Clients, true, false) } func missingClients(existing, requested []string) []string { @@ -835,7 +843,9 @@ func (s *service) applyGitInstallFresh( clientDirs map[string]string, files []gitresolver.FileEntry, ) (*skills.InstallResult, error) { - for _, ct := range clientTypes { + // Deduplicate so clients sharing the same directory don't conflict. + dirsToCheck := uniqueDirClients(clientTypes, clientDirs, nil) + for _, ct := range dirsToCheck { dir := filepath.Clean(clientDirs[ct]) if _, statErr := os.Stat(dir); statErr == nil && !opts.Force { // lgtm[go/path-injection] return nil, httperr.WithCode( @@ -845,7 +855,7 @@ func (s *service) applyGitInstallFresh( } } return s.gitWriteMultiAndPersist(ctx, opts, scope, clientTypes, clientDirs, files, - clientTypes, nil, false, false) + dirsToCheck, nil, false, false) } // gitWriteMultiAndPersist writes git files to the given client directories, @@ -1412,8 +1422,18 @@ func (s *service) installExtractionSameDigestNewClients( if len(toWrite) == 0 { return &skills.InstallResult{Skill: existing}, nil } + // Deduplicate and skip directories already owned by existing clients. + dirsToWrite := uniqueDirClients(toWrite, clientDirs, existingClientDirs(existing.Clients, clientDirs)) + if len(dirsToWrite) == 0 { + // All new clients share directories with existing ones — no-op. + sk := buildInstalledSkill(opts, scope, clientTypes, existing.Clients) + if err := s.store.Update(ctx, sk); err != nil { + return nil, err + } + return &skills.InstallResult{Skill: sk}, nil + } var written []string - for _, ct := range toWrite { + for _, ct := range dirsToWrite { dir := filepath.Clean(clientDirs[ct]) if _, statErr := os.Stat(dir); statErr == nil && !opts.Force { // lgtm[go/path-injection] removeSkillDirs(s.installer, clientDirs, written) @@ -1442,6 +1462,45 @@ func removeSkillDirs(inst skills.Installer, clientDirs map[string]string, client } } +// uniqueDirClients returns the subset of clients whose resolved directory is +// unique. When multiple clients share the same path (e.g. vscode and +// vscode-insider both using ~/.copilot/skills), only the first is returned. +// This prevents double-extraction while still recording all clients in the DB. +// +// occupiedDirs is pre-seeded into the seen set so that new clients whose +// directory is already owned by an existing installed client are also skipped. +// Pass nil when there are no pre-existing directories to exclude. +func uniqueDirClients(clients []string, clientDirs map[string]string, occupiedDirs map[string]struct{}) []string { + seen := make(map[string]struct{}, len(clients)+len(occupiedDirs)) + for dir := range occupiedDirs { + seen[dir] = struct{}{} + } + out := make([]string, 0, len(clients)) + for _, ct := range clients { + dir := filepath.Clean(clientDirs[ct]) + if _, ok := seen[dir]; ok { + continue + } + seen[dir] = struct{}{} + out = append(out, ct) + } + return out +} + +// existingClientDirs builds the set of directories already occupied by the +// given installed clients. Used to seed uniqueDirClients so that new clients +// sharing a directory with an existing client are skipped rather than +// triggering a false "directory exists" conflict. +func existingClientDirs(existing []string, clientDirs map[string]string) map[string]struct{} { + dirs := make(map[string]struct{}, len(existing)) + for _, ct := range existing { + if dir, ok := clientDirs[ct]; ok { + dirs[filepath.Clean(dir)] = struct{}{} + } + } + return dirs +} + func (s *service) installExtractionUpgradeDigest( ctx context.Context, opts skills.InstallOptions, @@ -1455,8 +1514,10 @@ func (s *service) installExtractionUpgradeDigest( if err != nil { return nil, err } + // Deduplicate so clients sharing the same directory don't conflict. + dirsToWrite := uniqueDirClients(allClients, allDirs, nil) var written []string - for _, ct := range allClients { + for _, ct := range dirsToWrite { dir := filepath.Clean(allDirs[ct]) if _, exErr := s.installer.Extract(opts.LayerData, dir, true); exErr != nil { removeSkillDirs(s.installer, allDirs, written) @@ -1466,7 +1527,7 @@ func (s *service) installExtractionUpgradeDigest( } sk := buildInstalledSkill(opts, scope, allClients, nil) if err := s.store.Update(ctx, sk); err != nil { - removeSkillDirs(s.installer, allDirs, allClients) + removeSkillDirs(s.installer, allDirs, dirsToWrite) return nil, err } return &skills.InstallResult{Skill: sk}, nil @@ -1479,7 +1540,10 @@ func (s *service) installExtractionFresh( clientTypes []string, clientDirs map[string]string, ) (*skills.InstallResult, error) { - for _, ct := range clientTypes { + // Deduplicate so clients sharing the same directory don't conflict. + dirsToWrite := uniqueDirClients(clientTypes, clientDirs, nil) + + for _, ct := range dirsToWrite { dir := filepath.Clean(clientDirs[ct]) if _, statErr := os.Stat(dir); statErr == nil && !opts.Force { // lgtm[go/path-injection] return nil, httperr.WithCode( @@ -1489,7 +1553,7 @@ func (s *service) installExtractionFresh( } } var written []string - for _, ct := range clientTypes { + for _, ct := range dirsToWrite { dir := filepath.Clean(clientDirs[ct]) if _, exErr := s.installer.Extract(opts.LayerData, dir, opts.Force); exErr != nil { removeSkillDirs(s.installer, clientDirs, written) @@ -1499,9 +1563,7 @@ func (s *service) installExtractionFresh( } sk := buildInstalledSkill(opts, scope, clientTypes, nil) if err := s.store.Create(ctx, sk); err != nil { - for _, ct := range clientTypes { - _ = s.installer.Remove(filepath.Clean(clientDirs[ct])) - } + removeSkillDirs(s.installer, clientDirs, dirsToWrite) return nil, err } return &skills.InstallResult{Skill: sk}, nil From 71c8585c91f8bc88b07afa7524c5cbff4cc44808 Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:34:46 +0200 Subject: [PATCH 007/117] Add Factory.ai as a supported MCP client (#4795) * Add Factory.ai as a supported MCP client * Add Factory.ai as a supported MCP client * Add Factory.ai as a supported MCP client --- docs/cli/thv_client_register.md | 1 + docs/cli/thv_client_remove.md | 1 + docs/server/docs.go | 6 ++++-- docs/server/swagger.json | 6 ++++-- docs/server/swagger.yaml | 2 ++ pkg/client/config.go | 24 ++++++++++++++++++++++++ pkg/client/config_test.go | 19 ++++++++++++++----- pkg/client/skills_test.go | 26 ++++++++++++++++++++++++-- 8 files changed, 74 insertions(+), 11 deletions(-) diff --git a/docs/cli/thv_client_register.md b/docs/cli/thv_client_register.md index 11e6dd78f3..41c42e0a11 100644 --- a/docs/cli/thv_client_register.md +++ b/docs/cli/thv_client_register.md @@ -29,6 +29,7 @@ Valid clients: - codex: OpenAI Codex CLI - continue: Continue.dev IDE plugins - cursor: Cursor editor + - factory: Factory.ai Droid CLI - gemini-cli: Google Gemini CLI - goose: Goose AI agent - kimi-cli: Kimi Code CLI diff --git a/docs/cli/thv_client_remove.md b/docs/cli/thv_client_remove.md index 74ed6d1a2f..f1d6044c01 100644 --- a/docs/cli/thv_client_remove.md +++ b/docs/cli/thv_client_remove.md @@ -29,6 +29,7 @@ Valid clients: - codex: OpenAI Codex CLI - continue: Continue.dev IDE plugins - cursor: Cursor editor + - factory: Factory.ai Droid CLI - gemini-cli: Google Gemini CLI - goose: Goose AI agent - kimi-cli: Kimi Code CLI diff --git a/docs/server/docs.go b/docs/server/docs.go index aac4a5ffe8..f361099ef0 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -828,7 +828,8 @@ const docTemplate = `{ "vscode-server", "mistral-vibe", "codex", - "kimi-cli" + "kimi-cli", + "factory" ], "type": "string", "x-enum-varnames": [ @@ -857,7 +858,8 @@ const docTemplate = `{ "VSCodeServer", "MistralVibe", "Codex", - "KimiCli" + "KimiCli", + "Factory" ] }, "github_com_stacklok_toolhive_pkg_client.ClientAppStatus": { diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 0df2a39676..532cac0f7c 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -821,7 +821,8 @@ "vscode-server", "mistral-vibe", "codex", - "kimi-cli" + "kimi-cli", + "factory" ], "type": "string", "x-enum-varnames": [ @@ -850,7 +851,8 @@ "VSCodeServer", "MistralVibe", "Codex", - "KimiCli" + "KimiCli", + "Factory" ] }, "github_com_stacklok_toolhive_pkg_client.ClientAppStatus": { diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 689da8daff..8bcebfe7a0 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -801,6 +801,7 @@ components: - mistral-vibe - codex - kimi-cli + - factory type: string x-enum-varnames: - RooCode @@ -829,6 +830,7 @@ components: - MistralVibe - Codex - KimiCli + - Factory github_com_stacklok_toolhive_pkg_client.ClientAppStatus: properties: client_type: diff --git a/pkg/client/config.go b/pkg/client/config.go index fea07c77a4..420a621823 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -84,6 +84,8 @@ const ( Codex ClientApp = "codex" // KimiCli represents the Kimi Code CLI. KimiCli ClientApp = "kimi-cli" + // Factory represents the Factory.ai Droid CLI. + Factory ClientApp = "factory" ) // Extension is extension of the client config file. @@ -770,6 +772,28 @@ var supportedClientIntegrations = []clientAppConfig{ SkillsGlobalPath: []string{".kimi", "skills"}, SkillsProjectPath: []string{".kimi", "skills"}, }, + { + ClientType: Factory, + Description: "Factory.ai Droid CLI", + SettingsFile: "mcp.json", + MCPServersPathPrefix: "/mcpServers", + RelPath: []string{".factory"}, + Extension: JSON, + SupportedTransportTypesMap: map[types.TransportType]string{ + types.TransportTypeStdio: "http", + types.TransportTypeSSE: "sse", + types.TransportTypeStreamableHTTP: "http", + }, + IsTransportTypeFieldSupported: true, + MCPServersUrlLabelMap: map[types.TransportType]string{ + types.TransportTypeStdio: "url", + types.TransportTypeSSE: "url", + types.TransportTypeStreamableHTTP: "url", + }, + SupportsSkills: true, + SkillsGlobalPath: []string{".factory", "skills"}, + SkillsProjectPath: []string{".factory", "skills"}, + }, } // GetAllClients returns a slice of all supported MCP client types, sorted alphabetically. diff --git a/pkg/client/config_test.go b/pkg/client/config_test.go index 547819d102..d401189cba 100644 --- a/pkg/client/config_test.go +++ b/pkg/client/config_test.go @@ -217,6 +217,14 @@ func createMockClientConfigs() []clientAppConfig { MCPServersPathPrefix: "/mcpServers", Extension: JSON, }, + { + ClientType: Factory, + Description: "Factory.ai Droid CLI (Mock)", + RelPath: []string{"mock_factory"}, + SettingsFile: "mcp.json", + MCPServersPathPrefix: "/mcpServers", + Extension: JSON, + }, } } @@ -403,6 +411,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { string(MistralVibe), string(Codex), string(KimiCli), + string(Factory), }, }, } @@ -495,7 +504,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { case AmpWindsurf: assert.Contains(t, string(content), `"mcpServers":`, "AmpWindsurf config should contain mcpServers key") - case LMStudio, Trae, Kiro, Antigravity, GeminiCli, KimiCli: + case LMStudio, Trae, Kiro, Antigravity, GeminiCli, KimiCli, Factory: assert.Contains(t, string(content), `"mcpServers":`, "Config should contain mcpServers key") case VSCodeServer: @@ -554,7 +563,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { "VSCode config should contain the server URL") case Cursor, RooCode, ClaudeCode, Cline, Windsurf, WindsurfJetBrains, AmpCli, AmpVSCode, AmpCursor, AmpVSCodeInsider, AmpWindsurf, LMStudio, Goose, Trae, Continue, OpenCode, Kiro, Antigravity, Zed, GeminiCli, VSCodeServer, - MistralVibe, Codex, KimiCli: + MistralVibe, Codex, KimiCli, Factory: assert.Contains(t, string(content), testURL, "Config should contain the server URL") } @@ -1283,8 +1292,8 @@ func TestGetAllClients(t *testing.T) { clients := GetAllClients() - // Should return all 26 supported clients - assert.Len(t, clients, 26, "Expected 26 supported clients") + // Should return all 27 supported clients + assert.Len(t, clients, 27, "Expected 27 supported clients") // Verify the list is sorted alphabetically for i := 1; i < len(clients); i++ { @@ -1472,5 +1481,5 @@ func TestGetClientListCSV(t *testing.T) { // Count the number of clients (should be 25) clients := strings.Split(csv, ", ") - assert.Len(t, clients, 26, "Expected 26 clients in CSV list") + assert.Len(t, clients, 27, "Expected 27 clients in CSV list") } diff --git a/pkg/client/skills_test.go b/pkg/client/skills_test.go index ab3f193a4a..6ec9ea6b65 100644 --- a/pkg/client/skills_test.go +++ b/pkg/client/skills_test.go @@ -52,6 +52,12 @@ func testSkillClientIntegrations() []clientAppConfig { SkillsGlobalPath: []string{".kimi", "skills"}, SkillsProjectPath: []string{".kimi", "skills"}, }, + { + ClientType: Factory, + SupportsSkills: true, + SkillsGlobalPath: []string{".factory", "skills"}, + SkillsProjectPath: []string{".factory", "skills"}, + }, { ClientType: VSCode, SupportsSkills: true, @@ -98,6 +104,7 @@ func TestSupportsSkills(t *testing.T) { {name: "KimiCli supports skills", client: KimiCli, expected: true}, {name: "VSCode supports skills", client: VSCode, expected: true}, {name: "VSCodeInsider supports skills", client: VSCodeInsider, expected: true}, + {name: "Factory supports skills", client: Factory, expected: true}, {name: "Windsurf does not support skills", client: Windsurf, expected: false}, {name: "unknown client returns false", client: ClientApp("nonexistent"), expected: false}, } @@ -115,8 +122,8 @@ func TestListSkillSupportingClients(t *testing.T) { cm := newTestSkillManager() clients := cm.ListSkillSupportingClients() - // Should include ClaudeCode, Codex, Cursor, KimiCli, OpenCode, VSCode, VSCodeInsider, and our test-only no-paths-client - require.Len(t, clients, 8, "unexpected number of skill-supporting clients: %v", clients) + // Should include ClaudeCode, Codex, Cursor, Factory, KimiCli, OpenCode, VSCode, VSCodeInsider, and our test-only no-paths-client + require.Len(t, clients, 9, "unexpected number of skill-supporting clients: %v", clients) // Verify sorted order for i := 1; i < len(clients); i++ { @@ -243,6 +250,21 @@ func TestGetSkillPath(t *testing.T) { projectRoot: "/tmp/myproject", wantPath: filepath.Join("/tmp/myproject", ".kimi", "skills", "my-skill"), }, + { + name: "ScopeUser Factory", + client: Factory, + skillName: "my-skill", + scope: skills.ScopeUser, + wantPath: filepath.Join(testHomeDir, ".factory", "skills", "my-skill"), + }, + { + name: "ScopeProject Factory with explicit root", + client: Factory, + skillName: "my-skill", + scope: skills.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".factory", "skills", "my-skill"), + }, { name: "ScopeUser VSCode", client: VSCode, From 0ef63686211f15041bd7b36d07698af8136de15a Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota Date: Tue, 14 Apr 2026 11:35:49 +0200 Subject: [PATCH 008/117] Add regression guard for auth context propagation through Close() (#4781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an integration test for the ListCapabilities → Close() path that would fail if the context.Background() regression from #4613 were reintroduced. Uses a proper streamable-HTTP server (NewStreamableHTTPServer) so mcp-go issues a session ID and actually sends a DELETE on Close(). The assertion is a server-side atomic flag: if auth fails for the DELETE, authRoundTripper drops the request before it reaches the server, the flag stays false, and the test fails. Closes #4725 Co-authored-by: taskbot --- .../auth_propagation_integration_test.go | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 pkg/vmcp/client/auth_propagation_integration_test.go diff --git a/pkg/vmcp/client/auth_propagation_integration_test.go b/pkg/vmcp/client/auth_propagation_integration_test.go new file mode 100644 index 0000000000..7844d1a8ae --- /dev/null +++ b/pkg/vmcp/client/auth_propagation_integration_test.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client_test + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/vmcp" + vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth" + "github.com/stacklok/toolhive/pkg/vmcp/auth/strategies" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" + vmcpclient "github.com/stacklok/toolhive/pkg/vmcp/client" + healthcontext "github.com/stacklok/toolhive/pkg/vmcp/health/context" +) + +// TestListCapabilities_AuthContextPropagatedThroughClose is a regression test +// for the bug fixed in #4613, where mcp-go's Close() emits a DELETE request +// with context.Background(), discarding the health-check marker and identity +// from the original ListCapabilities context. +// +// Without the fix, UpstreamInjectStrategy fails with "no identity found in +// context" for the DELETE request. When auth fails, authRoundTripper returns +// an error before sending the request, so the DELETE never reaches the server. +// +// This test would fail if identityPropagatingRoundTripper stopped capturing +// isHealthCheck at transport creation time and re-injecting it into every +// outgoing request. +func TestListCapabilities_AuthContextPropagatedThroughClose(t *testing.T) { + t.Parallel() + + // Track whether the server received the DELETE that mcp-go sends on Close(). + // If auth fails in the transport, the request is dropped before reaching the + // server and this stays false. + var deleteReceived atomic.Bool + + mcpServer := server.NewMCPServer("test-backend", "1.0.0") + streamServer := server.NewStreamableHTTPServer(mcpServer) + + mux := http.NewServeMux() + mux.HandleFunc("/mcp", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + deleteReceived.Store(true) + } + streamServer.ServeHTTP(w, r) + }) + ts := httptest.NewServer(mux) + defer ts.Close() + + registry := vmcpauth.NewDefaultOutgoingAuthRegistry() + err := registry.RegisterStrategy(authtypes.StrategyTypeUpstreamInject, strategies.NewUpstreamInjectStrategy()) + require.NoError(t, err) + + backendClient, err := vmcpclient.NewHTTPBackendClient(registry) + require.NoError(t, err) + + target := &vmcp.BackendTarget{ + WorkloadID: "test-backend", + WorkloadName: "Test Backend", + BaseURL: ts.URL + "/mcp", + TransportType: "streamable-http", + AuthConfig: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeUpstreamInject, + UpstreamInject: &authtypes.UpstreamInjectConfig{ + ProviderName: "test-provider", + }, + }, + } + + // Health-check context: UpstreamInjectStrategy skips auth when the + // health-check marker is present, so no user identity is needed. + ctx, cancel := context.WithTimeout( + healthcontext.WithHealthCheckMarker(context.Background()), + 5*time.Second, + ) + defer cancel() + + // Call ListCapabilities — this exercises the full path: + // defaultClientFactory (captures isHealthCheck=true in transport) + // → Initialize → ListTools/Resources/Prompts + // → deferred c.Close() (mcp-go emits DELETE with context.Background()) + capabilities, err := backendClient.ListCapabilities(ctx, target) + require.NoError(t, err) + require.NotNil(t, capabilities) + + // REGRESSION GUARD: the transport must re-inject the health-check marker + // into the DELETE from Close(). If it doesn't, UpstreamInjectStrategy + // fails with "no identity found in context", authRoundTripper drops the + // request before sending, and the server never sees the DELETE. + assert.True(t, deleteReceived.Load(), + "server did not receive DELETE: auth context propagation regression (#4613) likely reintroduced") +} From c63b10ee5e7ab8a75d626e62572f4b17f433a8ca Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:11:31 +0100 Subject: [PATCH 009/117] Propagate protectedResourceAllowPrivateIP through OIDC resolver and converter (#4784) * Propagate protectedResourceAllowPrivateIP through OIDC resolver and converter The protectedResourceAllowPrivateIP CRD field was defined but never propagated through the OIDC resolver, and the vmcpconfig converter incorrectly mapped it from jwksAllowPrivateIP. This meant the field had no effect and could not be controlled independently. Add ProtectedResourceAllowPrivateIP to the resolver OIDCConfig struct, propagate it through inline, shared inline, and ConfigMap resolve paths, fix the converter to use the correct source field, and add the missing JwksAllowPrivateIP mapping. Fixes bugs 2 and 3 from #3142. Co-Authored-By: Claude Opus 4.6 (1M context) * Add ConfigMap independence test for protectedResourceAllowPrivateIP Address review feedback (F3): add a ConfigMap test case where jwksAllowPrivateIP is true but protectedResourceAllowPrivateIP is absent, verifying they resolve independently. Co-Authored-By: Claude Opus 4.6 (1M context) * Document runtime OR behavior on private IP fields and fix missing converter mappings Add doc comments to JWKSAllowPrivateIP and ProtectedResourceAllowPrivateIP on both InlineOIDCSharedConfig and InlineOIDCConfig noting that at runtime either field being true enables private IPs for all OIDC HTTP requests. Also add missing JWKSURL and IntrospectionURL mappings in mapResolvedOIDCToVmcpConfigFromRef, which silently dropped these fields for the MCPOIDCConfig reference path. Co-Authored-By: Claude Opus 4.6 (1M context) * Regenerate CRD manifests after doc comment updates Co-Authored-By: Claude Opus 4.6 (1M context) * Regenerate CRD reference docs Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../api/v1alpha1/mcpoidcconfig_types.go | 8 +- .../api/v1alpha1/mcpserver_types.go | 12 +- cmd/thv-operator/pkg/oidc/resolver.go | 79 ++++++----- .../pkg/oidc/resolver_configref_test.go | 25 ++++ cmd/thv-operator/pkg/oidc/resolver_test.go | 129 ++++++++++++++---- cmd/thv-operator/pkg/vmcpconfig/converter.go | 8 +- .../pkg/vmcpconfig/converter_test.go | 19 ++- .../toolhive.stacklok.dev_mcpoidcconfigs.yaml | 12 +- ...oolhive.stacklok.dev_mcpremoteproxies.yaml | 12 +- .../toolhive.stacklok.dev_mcpservers.yaml | 12 +- ...olhive.stacklok.dev_virtualmcpservers.yaml | 12 +- .../toolhive.stacklok.dev_mcpoidcconfigs.yaml | 12 +- ...oolhive.stacklok.dev_mcpremoteproxies.yaml | 12 +- .../toolhive.stacklok.dev_mcpservers.yaml | 12 +- ...olhive.stacklok.dev_virtualmcpservers.yaml | 12 +- docs/operator/crd-api.md | 8 +- 16 files changed, 273 insertions(+), 111 deletions(-) diff --git a/cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go b/cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go index fede8a3070..71e7d71fa2 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go @@ -127,12 +127,16 @@ type InlineOIDCSharedConfig struct { // +optional JWKSAuthTokenPath string `json:"jwksAuthTokenPath,omitempty"` - // JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses + // JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. + // Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP + // is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). // +kubebuilder:default=false // +optional JWKSAllowPrivateIP bool `json:"jwksAllowPrivateIP"` - // ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses + // ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. + // Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP + // is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). // +kubebuilder:default=false // +optional ProtectedResourceAllowPrivateIP bool `json:"protectedResourceAllowPrivateIP"` diff --git a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go b/cmd/thv-operator/api/v1alpha1/mcpserver_types.go index 61b514cf13..893ce20b92 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpserver_types.go @@ -815,14 +815,18 @@ type InlineOIDCConfig struct { // +optional JWKSAuthTokenPath string `json:"jwksAuthTokenPath,omitempty"` - // JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses - // Use with caution - only enable for trusted internal IDPs + // JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. + // Use with caution - only enable for trusted internal IDPs. + // Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP + // is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). // +kubebuilder:default=false // +optional JWKSAllowPrivateIP bool `json:"jwksAllowPrivateIP"` - // ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses - // Use with caution - only enable for trusted internal IDPs or testing + // ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. + // Use with caution - only enable for trusted internal IDPs or testing. + // Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP + // is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). // +kubebuilder:default=false // +optional ProtectedResourceAllowPrivateIP bool `json:"protectedResourceAllowPrivateIP"` diff --git a/cmd/thv-operator/pkg/oidc/resolver.go b/cmd/thv-operator/pkg/oidc/resolver.go index 9c7462d647..22a6fbdff2 100644 --- a/cmd/thv-operator/pkg/oidc/resolver.go +++ b/cmd/thv-operator/pkg/oidc/resolver.go @@ -25,22 +25,24 @@ const ( defaultK8sTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec defaultK8sIssuer = "https://kubernetes.default.svc" defaultK8sAudience = "toolhive" + configMapBoolTrue = "true" ) // OIDCConfig represents the resolved OIDC configuration values type OIDCConfig struct { //nolint:revive // Keeping OIDCConfig name for backward compatibility - Issuer string - Audience string - JWKSURL string - IntrospectionURL string - ClientID string - ClientSecret string // #nosec G117 -- not a hardcoded credential, populated at runtime from config - ThvCABundlePath string - JWKSAuthTokenPath string - ResourceURL string - JWKSAllowPrivateIP bool - InsecureAllowHTTP bool - Scopes []string + Issuer string + Audience string + JWKSURL string + IntrospectionURL string + ClientID string + ClientSecret string // #nosec G117 -- not a hardcoded credential, populated at runtime from config + ThvCABundlePath string + JWKSAuthTokenPath string + ResourceURL string + JWKSAllowPrivateIP bool + ProtectedResourceAllowPrivateIP bool + InsecureAllowHTTP bool + Scopes []string } // OIDCConfigurable is an interface for resources that have OIDC configuration @@ -198,17 +200,18 @@ func (*resolver) resolveFromInlineSharedConfig( } return &OIDCConfig{ - Issuer: config.Issuer, - Audience: ref.Audience, - JWKSURL: config.JWKSURL, - IntrospectionURL: config.IntrospectionURL, - ClientID: config.ClientID, - ThvCABundlePath: computeCABundlePath(config.CABundleRef), - JWKSAuthTokenPath: config.JWKSAuthTokenPath, - ResourceURL: resourceURL, - JWKSAllowPrivateIP: config.JWKSAllowPrivateIP, - InsecureAllowHTTP: config.InsecureAllowHTTP, - Scopes: ref.Scopes, + Issuer: config.Issuer, + Audience: ref.Audience, + JWKSURL: config.JWKSURL, + IntrospectionURL: config.IntrospectionURL, + ClientID: config.ClientID, + ThvCABundlePath: computeCABundlePath(config.CABundleRef), + JWKSAuthTokenPath: config.JWKSAuthTokenPath, + ResourceURL: resourceURL, + JWKSAllowPrivateIP: config.JWKSAllowPrivateIP, + ProtectedResourceAllowPrivateIP: config.ProtectedResourceAllowPrivateIP, + InsecureAllowHTTP: config.InsecureAllowHTTP, + Scopes: ref.Scopes, }, nil } @@ -311,10 +314,13 @@ func (r *resolver) resolveConfigMapConfig( config.JWKSAuthTokenPath = getMapValue(configMap.Data, "jwksAuthTokenPath") // Handle boolean values - if v, exists := configMap.Data["jwksAllowPrivateIP"]; exists && v == "true" { + if v, exists := configMap.Data["jwksAllowPrivateIP"]; exists && v == configMapBoolTrue { config.JWKSAllowPrivateIP = true } - if v, exists := configMap.Data["insecureAllowHTTP"]; exists && v == "true" { + if v, exists := configMap.Data["protectedResourceAllowPrivateIP"]; exists && v == configMapBoolTrue { + config.ProtectedResourceAllowPrivateIP = true + } + if v, exists := configMap.Data["insecureAllowHTTP"]; exists && v == configMapBoolTrue { config.InsecureAllowHTTP = true } @@ -344,17 +350,18 @@ func (*resolver) resolveInlineConfig( } return &OIDCConfig{ - Issuer: config.Issuer, - Audience: config.Audience, - JWKSURL: config.JWKSURL, - IntrospectionURL: config.IntrospectionURL, - ClientID: config.ClientID, - ThvCABundlePath: computeCABundlePath(config.CABundleRef), - JWKSAuthTokenPath: config.JWKSAuthTokenPath, - ResourceURL: resourceURL, - JWKSAllowPrivateIP: config.JWKSAllowPrivateIP, - InsecureAllowHTTP: config.InsecureAllowHTTP, - Scopes: config.Scopes, + Issuer: config.Issuer, + Audience: config.Audience, + JWKSURL: config.JWKSURL, + IntrospectionURL: config.IntrospectionURL, + ClientID: config.ClientID, + ThvCABundlePath: computeCABundlePath(config.CABundleRef), + JWKSAuthTokenPath: config.JWKSAuthTokenPath, + ResourceURL: resourceURL, + JWKSAllowPrivateIP: config.JWKSAllowPrivateIP, + ProtectedResourceAllowPrivateIP: config.ProtectedResourceAllowPrivateIP, + InsecureAllowHTTP: config.InsecureAllowHTTP, + Scopes: config.Scopes, }, nil } diff --git a/cmd/thv-operator/pkg/oidc/resolver_configref_test.go b/cmd/thv-operator/pkg/oidc/resolver_configref_test.go index 157937ad97..0f0c6199d9 100644 --- a/cmd/thv-operator/pkg/oidc/resolver_configref_test.go +++ b/cmd/thv-operator/pkg/oidc/resolver_configref_test.go @@ -154,6 +154,31 @@ func TestResolveFromConfigRef_InlineType(t *testing.T) { Scopes: []string{"openid", "email"}, }, }, + { + name: "protectedResourceAllowPrivateIP propagated from shared inline config", + ref: &mcpv1alpha1.MCPOIDCConfigReference{ + Name: "i", Audience: "inline-aud", + }, + oidcCfg: &mcpv1alpha1.MCPOIDCConfig{ + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: "https://accounts.google.com", + ClientID: "gid", + ProtectedResourceAllowPrivateIP: true, + JWKSAllowPrivateIP: false, + }, + }, + }, + expected: &OIDCConfig{ + Issuer: "https://accounts.google.com", + Audience: "inline-aud", + ClientID: "gid", + ResourceURL: "http://srv.default.svc.cluster.local:8080", + ProtectedResourceAllowPrivateIP: true, + JWKSAllowPrivateIP: false, + }, + }, { name: "nil inline config returns nil", ref: &mcpv1alpha1.MCPOIDCConfigReference{ diff --git a/cmd/thv-operator/pkg/oidc/resolver_test.go b/cmd/thv-operator/pkg/oidc/resolver_test.go index 30d39bf6d0..2e79d3a018 100644 --- a/cmd/thv-operator/pkg/oidc/resolver_test.go +++ b/cmd/thv-operator/pkg/oidc/resolver_test.go @@ -168,28 +168,30 @@ func TestResolve_ConfigMapType(t *testing.T) { Namespace: "test-ns", }, Data: map[string]string{ - "issuer": "https://auth.example.com", - "audience": "test-audience", - "jwksUrl": "https://auth.example.com/.well-known/jwks.json", - "introspectionUrl": "https://auth.example.com/introspect", - "clientId": "test-client", - "clientSecret": "test-secret", - "thvCABundlePath": "/etc/ssl/ca.pem", - "jwksAuthTokenPath": "/etc/auth/token", - "jwksAllowPrivateIP": "true", + "issuer": "https://auth.example.com", + "audience": "test-audience", + "jwksUrl": "https://auth.example.com/.well-known/jwks.json", + "introspectionUrl": "https://auth.example.com/introspect", + "clientId": "test-client", + "clientSecret": "test-secret", + "thvCABundlePath": "/etc/ssl/ca.pem", + "jwksAuthTokenPath": "/etc/auth/token", + "jwksAllowPrivateIP": "true", + "protectedResourceAllowPrivateIP": "true", }, }, expected: &OIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - JWKSURL: "https://auth.example.com/.well-known/jwks.json", - IntrospectionURL: "https://auth.example.com/introspect", - ClientID: "test-client", - ClientSecret: "test-secret", - ThvCABundlePath: "/etc/ssl/ca.pem", - JWKSAuthTokenPath: "/etc/auth/token", - ResourceURL: "http://test-server.test-ns.svc.cluster.local:8080", - JWKSAllowPrivateIP: true, + Issuer: "https://auth.example.com", + Audience: "test-audience", + JWKSURL: "https://auth.example.com/.well-known/jwks.json", + IntrospectionURL: "https://auth.example.com/introspect", + ClientID: "test-client", + ClientSecret: "test-secret", + ThvCABundlePath: "/etc/ssl/ca.pem", + JWKSAuthTokenPath: "/etc/auth/token", + ResourceURL: "http://test-server.test-ns.svc.cluster.local:8080", + JWKSAllowPrivateIP: true, + ProtectedResourceAllowPrivateIP: true, }, }, { @@ -227,6 +229,43 @@ func TestResolve_ConfigMapType(t *testing.T) { JWKSAllowPrivateIP: false, }, }, + { + name: "configmap with jwksAllowPrivateIP independent of protectedResourceAllowPrivateIP", + mcpServer: &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "independent-server", + Namespace: "test-ns", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + ProxyPort: 8080, + OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ + Name: "independent-config", + }, + }, + }, + }, + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "independent-config", + Namespace: "test-ns", + }, + Data: map[string]string{ + "issuer": "https://auth.example.com", + "audience": "test-audience", + "jwksAllowPrivateIP": "true", + // protectedResourceAllowPrivateIP intentionally absent + }, + }, + expected: &OIDCConfig{ + Issuer: "https://auth.example.com", + Audience: "test-audience", + ResourceURL: "http://independent-server.test-ns.svc.cluster.local:8080", + JWKSAllowPrivateIP: true, + ProtectedResourceAllowPrivateIP: false, + }, + }, { name: "configmap with insecureAllowHTTP enabled", mcpServer: &mcpv1alpha1.MCPServer{ @@ -513,22 +552,24 @@ func TestResolve_InlineType(t *testing.T) { LocalObjectReference: corev1.LocalObjectReference{Name: "inline-ca"}, }, }, - JWKSAuthTokenPath: "/etc/auth/inline-token", - JWKSAllowPrivateIP: true, + JWKSAuthTokenPath: "/etc/auth/inline-token", + JWKSAllowPrivateIP: true, + ProtectedResourceAllowPrivateIP: true, }, }, }, }, expected: &OIDCConfig{ - Issuer: "https://inline.example.com", - Audience: "inline-audience", - JWKSURL: "https://inline.example.com/.well-known/jwks.json", - IntrospectionURL: "https://inline.example.com/introspect", - ClientID: "inline-client", - ThvCABundlePath: "/config/certs/inline-ca/ca.crt", - JWKSAuthTokenPath: "/etc/auth/inline-token", - ResourceURL: "http://test-server.test-ns.svc.cluster.local:8080", - JWKSAllowPrivateIP: true, + Issuer: "https://inline.example.com", + Audience: "inline-audience", + JWKSURL: "https://inline.example.com/.well-known/jwks.json", + IntrospectionURL: "https://inline.example.com/introspect", + ClientID: "inline-client", + ThvCABundlePath: "/config/certs/inline-ca/ca.crt", + JWKSAuthTokenPath: "/etc/auth/inline-token", + ResourceURL: "http://test-server.test-ns.svc.cluster.local:8080", + JWKSAllowPrivateIP: true, + ProtectedResourceAllowPrivateIP: true, }, }, { @@ -586,6 +627,34 @@ func TestResolve_InlineType(t *testing.T) { InsecureAllowHTTP: true, }, }, + { + name: "inline with protectedResourceAllowPrivateIP independent of jwksAllowPrivateIP", + mcpServer: &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "protected-resource-server", + Namespace: "test-ns", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + ProxyPort: 8080, + OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCConfig{ + Issuer: "https://auth.example.com", + Audience: "test-audience", + ProtectedResourceAllowPrivateIP: true, + JWKSAllowPrivateIP: false, + }, + }, + }, + }, + expected: &OIDCConfig{ + Issuer: "https://auth.example.com", + Audience: "test-audience", + ResourceURL: "http://protected-resource-server.test-ns.svc.cluster.local:8080", + ProtectedResourceAllowPrivateIP: true, + JWKSAllowPrivateIP: false, + }, + }, { name: "inline with scopes", mcpServer: &mcpv1alpha1.MCPServer{ diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter.go b/cmd/thv-operator/pkg/vmcpconfig/converter.go index 2b29aaefce..6783e53186 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go @@ -276,7 +276,8 @@ func mapResolvedOIDCToVmcpConfig( Resource: resolved.ResourceURL, JWKSURL: resolved.JWKSURL, IntrospectionURL: resolved.IntrospectionURL, - ProtectedResourceAllowPrivateIP: resolved.JWKSAllowPrivateIP, + ProtectedResourceAllowPrivateIP: resolved.ProtectedResourceAllowPrivateIP, + JwksAllowPrivateIP: resolved.JWKSAllowPrivateIP, InsecureAllowHTTP: resolved.InsecureAllowHTTP, Scopes: resolved.Scopes, } @@ -319,7 +320,10 @@ func mapResolvedOIDCToVmcpConfigFromRef( ClientID: resolved.ClientID, Audience: resolved.Audience, Resource: resolved.ResourceURL, - ProtectedResourceAllowPrivateIP: resolved.JWKSAllowPrivateIP, + JWKSURL: resolved.JWKSURL, + IntrospectionURL: resolved.IntrospectionURL, + ProtectedResourceAllowPrivateIP: resolved.ProtectedResourceAllowPrivateIP, + JwksAllowPrivateIP: resolved.JWKSAllowPrivateIP, InsecureAllowHTTP: resolved.InsecureAllowHTTP, Scopes: resolved.Scopes, } diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go index aff80f5368..a849fb1e1a 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go @@ -88,7 +88,8 @@ func TestConverter_OIDCResolution(t *testing.T) { oidcConfig: &mcpv1alpha1.OIDCConfigRef{Type: mcpv1alpha1.OIDCConfigTypeKubernetes}, mockReturn: &oidc.OIDCConfig{ Issuer: "https://issuer.example.com", Audience: "my-audience", - ResourceURL: "https://resource.example.com", JWKSAllowPrivateIP: true, + ResourceURL: "https://resource.example.com", + JWKSAllowPrivateIP: true, ProtectedResourceAllowPrivateIP: true, JWKSURL: "https://issuer.example.com/jwks", IntrospectionURL: "https://issuer.example.com/introspect", }, validate: func(t *testing.T, config *vmcpconfig.Config, err error) { @@ -101,6 +102,22 @@ func TestConverter_OIDCResolution(t *testing.T) { assert.Equal(t, "https://issuer.example.com/jwks", config.IncomingAuth.OIDC.JWKSURL) assert.Equal(t, "https://issuer.example.com/introspect", config.IncomingAuth.OIDC.IntrospectionURL) assert.True(t, config.IncomingAuth.OIDC.ProtectedResourceAllowPrivateIP) + assert.True(t, config.IncomingAuth.OIDC.JwksAllowPrivateIP) + }, + }, + { + name: "fields mapped independently - jwksAllowPrivateIP true, protectedResourceAllowPrivateIP false", + oidcConfig: &mcpv1alpha1.OIDCConfigRef{Type: mcpv1alpha1.OIDCConfigTypeKubernetes}, + mockReturn: &oidc.OIDCConfig{ + Issuer: "https://issuer.example.com", Audience: "my-audience", + JWKSAllowPrivateIP: true, ProtectedResourceAllowPrivateIP: false, + }, + validate: func(t *testing.T, config *vmcpconfig.Config, err error) { + t.Helper() + require.NoError(t, err) + require.NotNil(t, config.IncomingAuth.OIDC) + assert.True(t, config.IncomingAuth.OIDC.JwksAllowPrivateIP) + assert.False(t, config.IncomingAuth.OIDC.ProtectedResourceAllowPrivateIP) }, }, { diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpoidcconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpoidcconfigs.yaml index 724c56e0f1..acf6ea3887 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpoidcconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpoidcconfigs.yaml @@ -131,8 +131,10 @@ spec: type: string jwksAllowPrivateIP: default: false - description: JWKSAllowPrivateIP allows JWKS/OIDC endpoints on - private IP addresses + description: |- + JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. + Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean jwksAuthTokenPath: description: JWKSAuthTokenPath is the path to file containing @@ -143,8 +145,10 @@ spec: type: string protectedResourceAllowPrivateIP: default: false - description: ProtectedResourceAllowPrivateIP allows protected - resource endpoint on private IP addresses + description: |- + ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. + Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean required: - issuer diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml index 27f364f3d6..7536126e1b 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -348,8 +348,10 @@ spec: jwksAllowPrivateIP: default: false description: |- - JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses - Use with caution - only enable for trusted internal IDPs + JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. + Use with caution - only enable for trusted internal IDPs. + Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean jwksAuthTokenPath: description: |- @@ -362,8 +364,10 @@ spec: protectedResourceAllowPrivateIP: default: false description: |- - ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses - Use with caution - only enable for trusted internal IDPs or testing + ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. + Use with caution - only enable for trusted internal IDPs or testing. + Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean scopes: description: |- diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml index f323a25ff2..49798ed54d 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml @@ -343,8 +343,10 @@ spec: jwksAllowPrivateIP: default: false description: |- - JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses - Use with caution - only enable for trusted internal IDPs + JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. + Use with caution - only enable for trusted internal IDPs. + Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean jwksAuthTokenPath: description: |- @@ -357,8 +359,10 @@ spec: protectedResourceAllowPrivateIP: default: false description: |- - ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses - Use with caution - only enable for trusted internal IDPs or testing + ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. + Use with caution - only enable for trusted internal IDPs or testing. + Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean scopes: description: |- diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index f919af400e..213aa451e9 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -1977,8 +1977,10 @@ spec: jwksAllowPrivateIP: default: false description: |- - JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses - Use with caution - only enable for trusted internal IDPs + JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. + Use with caution - only enable for trusted internal IDPs. + Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean jwksAuthTokenPath: description: |- @@ -1991,8 +1993,10 @@ spec: protectedResourceAllowPrivateIP: default: false description: |- - ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses - Use with caution - only enable for trusted internal IDPs or testing + ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. + Use with caution - only enable for trusted internal IDPs or testing. + Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean scopes: description: |- diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpoidcconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpoidcconfigs.yaml index 3753184b94..76ee9d9977 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpoidcconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpoidcconfigs.yaml @@ -134,8 +134,10 @@ spec: type: string jwksAllowPrivateIP: default: false - description: JWKSAllowPrivateIP allows JWKS/OIDC endpoints on - private IP addresses + description: |- + JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. + Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean jwksAuthTokenPath: description: JWKSAuthTokenPath is the path to file containing @@ -146,8 +148,10 @@ spec: type: string protectedResourceAllowPrivateIP: default: false - description: ProtectedResourceAllowPrivateIP allows protected - resource endpoint on private IP addresses + description: |- + ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. + Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean required: - issuer diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml index 80487bc48a..c56ef0e206 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -351,8 +351,10 @@ spec: jwksAllowPrivateIP: default: false description: |- - JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses - Use with caution - only enable for trusted internal IDPs + JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. + Use with caution - only enable for trusted internal IDPs. + Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean jwksAuthTokenPath: description: |- @@ -365,8 +367,10 @@ spec: protectedResourceAllowPrivateIP: default: false description: |- - ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses - Use with caution - only enable for trusted internal IDPs or testing + ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. + Use with caution - only enable for trusted internal IDPs or testing. + Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean scopes: description: |- diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml index 9f2732ef2a..00287c3ec7 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml @@ -346,8 +346,10 @@ spec: jwksAllowPrivateIP: default: false description: |- - JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses - Use with caution - only enable for trusted internal IDPs + JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. + Use with caution - only enable for trusted internal IDPs. + Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean jwksAuthTokenPath: description: |- @@ -360,8 +362,10 @@ spec: protectedResourceAllowPrivateIP: default: false description: |- - ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses - Use with caution - only enable for trusted internal IDPs or testing + ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. + Use with caution - only enable for trusted internal IDPs or testing. + Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean scopes: description: |- diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 8e7e666468..a3a775f477 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -1980,8 +1980,10 @@ spec: jwksAllowPrivateIP: default: false description: |- - JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses - Use with caution - only enable for trusted internal IDPs + JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. + Use with caution - only enable for trusted internal IDPs. + Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean jwksAuthTokenPath: description: |- @@ -1994,8 +1996,10 @@ spec: protectedResourceAllowPrivateIP: default: false description: |- - ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses - Use with caution - only enable for trusted internal IDPs or testing + ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. + Use with caution - only enable for trusted internal IDPs or testing. + Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). type: boolean scopes: description: |- diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index d89dccb5d0..dd79a4f147 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -1327,8 +1327,8 @@ _Appears in:_ | `clientSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | ClientSecretRef is a reference to a Kubernetes Secret containing the client secret | | Optional: \{\}
| | `caBundleRef` _[api.v1alpha1.CABundleSource](#apiv1alpha1cabundlesource)_ | CABundleRef references a ConfigMap containing the CA certificate bundle.
When specified, ToolHive auto-mounts the ConfigMap and auto-computes the CA bundle path. | | Optional: \{\}
| | `jwksAuthTokenPath` _string_ | JWKSAuthTokenPath is the path to file containing bearer token for JWKS/OIDC requests
The file must be mounted into the pod (e.g., via Secret volume) | | Optional: \{\}
| -| `jwksAllowPrivateIP` _boolean_ | JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses
Use with caution - only enable for trusted internal IDPs | false | Optional: \{\}
| -| `protectedResourceAllowPrivateIP` _boolean_ | ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses
Use with caution - only enable for trusted internal IDPs or testing | false | Optional: \{\}
| +| `jwksAllowPrivateIP` _boolean_ | JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses.
Use with caution - only enable for trusted internal IDPs.
Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP
is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). | false | Optional: \{\}
| +| `protectedResourceAllowPrivateIP` _boolean_ | ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses.
Use with caution - only enable for trusted internal IDPs or testing.
Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP
is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). | false | Optional: \{\}
| | `insecureAllowHTTP` _boolean_ | InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing
WARNING: This is insecure and should NEVER be used in production
Only enable for local development, testing, or trusted internal networks | false | Optional: \{\}
| | `scopes` _string array_ | Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)
If empty, defaults to ["openid"] | | Optional: \{\}
| @@ -1355,8 +1355,8 @@ _Appears in:_ | `clientSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | ClientSecretRef is a reference to a Kubernetes Secret containing the client secret | | Optional: \{\}
| | `caBundleRef` _[api.v1alpha1.CABundleSource](#apiv1alpha1cabundlesource)_ | CABundleRef references a ConfigMap containing the CA certificate bundle.
When specified, ToolHive auto-mounts the ConfigMap and auto-computes ThvCABundlePath. | | Optional: \{\}
| | `jwksAuthTokenPath` _string_ | JWKSAuthTokenPath is the path to file containing bearer token for JWKS/OIDC requests | | Optional: \{\}
| -| `jwksAllowPrivateIP` _boolean_ | JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses | false | Optional: \{\}
| -| `protectedResourceAllowPrivateIP` _boolean_ | ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses | false | Optional: \{\}
| +| `jwksAllowPrivateIP` _boolean_ | JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses.
Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP
is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). | false | Optional: \{\}
| +| `protectedResourceAllowPrivateIP` _boolean_ | ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses.
Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP
is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). | false | Optional: \{\}
| | `insecureAllowHTTP` _boolean_ | InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing.
WARNING: This is insecure and should NEVER be used in production. | false | Optional: \{\}
| From c49ec9e79d6e4be271b9364f20674670c5ef07a7 Mon Sep 17 00:00:00 2001 From: gkatz2 <93550828+gkatz2@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:17:45 -0700 Subject: [PATCH 010/117] Clean up health check dead code and timeout (#4791) The constructor and functional options guarantee positive values for healthCheckInterval and healthCheckPingTimeout, making two zero-value guards unreachable. The redundant context timeout in checkMCPStatus capped the configurable ping timeout at 5s; the MCPPinger already enforces its own timeout internally. Fixes #4790 Signed-off-by: Greg Katz Co-authored-by: Claude Opus 4.6 (1M context) --- pkg/healthcheck/healthcheck.go | 6 +----- .../proxy/transparent/transparent_proxy.go | 14 +++----------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/pkg/healthcheck/healthcheck.go b/pkg/healthcheck/healthcheck.go index b4327d71ba..dc64ab023d 100644 --- a/pkg/healthcheck/healthcheck.go +++ b/pkg/healthcheck/healthcheck.go @@ -106,11 +106,7 @@ func (hc *HealthChecker) checkMCPStatus(ctx context.Context) *MCPStatus { LastChecked: time.Now(), } - // Create a context with timeout for the ping - pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - duration, err := hc.mcpPinger.Ping(pingCtx) + duration, err := hc.mcpPinger.Ping(ctx) if err != nil { status.Available = false diff --git a/pkg/transport/proxy/transparent/transparent_proxy.go b/pkg/transport/proxy/transparent/transparent_proxy.go index e7078886b8..f5d9599b9a 100644 --- a/pkg/transport/proxy/transparent/transparent_proxy.go +++ b/pkg/transport/proxy/transparent/transparent_proxy.go @@ -456,14 +456,10 @@ func NewTransparentProxyWithOptions( // Create health checker always for Kubernetes probes var mcpPinger healthcheck.MCPPinger if enableHealthCheck { - pingTimeout := proxy.healthCheckPingTimeout - if pingTimeout == 0 { - pingTimeout = DefaultPingerTimeout - } if proxy.stateless { - mcpPinger = NewStatelessMCPPingerWithTimeout(targetURI, pingTimeout) + mcpPinger = NewStatelessMCPPingerWithTimeout(targetURI, proxy.healthCheckPingTimeout) } else { - mcpPinger = NewMCPPingerWithTimeout(targetURI, pingTimeout) + mcpPinger = NewMCPPingerWithTimeout(targetURI, proxy.healthCheckPingTimeout) } } proxy.healthChecker = healthcheck.NewHealthChecker(transportType, mcpPinger) @@ -1246,11 +1242,7 @@ func (p *TransparentProxy) handleHealthCheckFailure( } func (p *TransparentProxy) monitorHealth(parentCtx context.Context) { - interval := p.healthCheckInterval - if interval == 0 { - interval = DefaultHealthCheckInterval - } - ticker := time.NewTicker(interval) + ticker := time.NewTicker(p.healthCheckInterval) defer ticker.Stop() consecutiveFailures := 0 From 15dad99e88d81c45978ec8b4c698a886c845453b Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:37:11 +0200 Subject: [PATCH 011/117] Add Goose, Gemini CLI, and Amp as skill-supporting clients (#4799) * Add Goose, Gemini CLI, and Amp as skill-supporting clients Enable skill installation for three additional AI clients, all using the .agents/skills/ convention for both user and project scope. Each client also discovers skills from its native path (.gemini/skills/, ~/.config/agents/skills/, etc.) but .agents/ is the cross-client standard. * Fix --client flag name and default behavior in skills docs The CLI uses --clients (plural), and the default targets all skill-supporting clients, not just the first one. --- docs/arch/12-skills-system.md | 8 ++-- pkg/client/config.go | 9 +++++ pkg/client/skills_test.go | 70 ++++++++++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/docs/arch/12-skills-system.md b/docs/arch/12-skills-system.md index 46602f2fb9..4db139cc06 100644 --- a/docs/arch/12-skills-system.md +++ b/docs/arch/12-skills-system.md @@ -137,11 +137,11 @@ Skills install to one of two scopes: Skills can be installed for multiple AI clients simultaneously. Each client has its own skill directory structure, so installing a skill for `claude-code` places files differently than for `cursor`. ```bash -# Install for default client (first skill-supporting client) +# Install for all skill-supporting clients (default) thv skill install code-review -# Install for specific client -thv skill install code-review --client claude-code +# Install for specific clients +thv skill install code-review --clients claude-code ``` The `PathResolver` interface maps (client, skill-name, scope, project-root) to the correct filesystem path for each client. @@ -244,7 +244,7 @@ flowchart TD 3. **Supply chain validation**: For OCI installs, the skill name in the artifact must match the repository name in the reference. -4. **Client targeting**: When no `--client` flag is provided, the first skill-supporting client is used by default. Specify `--client claude-code` to target a particular client. +4. **Client targeting**: When no `--clients` flag is provided, all skill-supporting clients are targeted by default. Specify `--clients claude-code` to target a particular client. **Implementation:** `pkg/skills/skillsvc/skillsvc.go` (Install) diff --git a/pkg/client/config.go b/pkg/client/config.go index 420a621823..d1d861ec82 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -410,6 +410,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "url", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, }, { ClientType: AmpVSCode, @@ -556,6 +559,9 @@ var supportedClientIntegrations = []clientAppConfig{ "timeout": 60, "description": "", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, }, { ClientType: Trae, @@ -697,6 +703,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "httpUrl", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, }, { ClientType: VSCodeServer, diff --git a/pkg/client/skills_test.go b/pkg/client/skills_test.go index 6ec9ea6b65..206d40afe3 100644 --- a/pkg/client/skills_test.go +++ b/pkg/client/skills_test.go @@ -70,6 +70,24 @@ func testSkillClientIntegrations() []clientAppConfig { SkillsGlobalPath: []string{".copilot", "skills"}, SkillsProjectPath: []string{".github", "skills"}, }, + { + ClientType: Goose, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, + }, + { + ClientType: GeminiCli, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, + }, + { + ClientType: AmpCli, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, + }, { ClientType: Windsurf, // SupportsSkills defaults to false @@ -105,6 +123,9 @@ func TestSupportsSkills(t *testing.T) { {name: "VSCode supports skills", client: VSCode, expected: true}, {name: "VSCodeInsider supports skills", client: VSCodeInsider, expected: true}, {name: "Factory supports skills", client: Factory, expected: true}, + {name: "Goose supports skills", client: Goose, expected: true}, + {name: "GeminiCli supports skills", client: GeminiCli, expected: true}, + {name: "AmpCli supports skills", client: AmpCli, expected: true}, {name: "Windsurf does not support skills", client: Windsurf, expected: false}, {name: "unknown client returns false", client: ClientApp("nonexistent"), expected: false}, } @@ -122,8 +143,8 @@ func TestListSkillSupportingClients(t *testing.T) { cm := newTestSkillManager() clients := cm.ListSkillSupportingClients() - // Should include ClaudeCode, Codex, Cursor, Factory, KimiCli, OpenCode, VSCode, VSCodeInsider, and our test-only no-paths-client - require.Len(t, clients, 9, "unexpected number of skill-supporting clients: %v", clients) + // Should include AmpCli, ClaudeCode, Codex, Cursor, Factory, GeminiCli, Goose, KimiCli, OpenCode, VSCode, VSCodeInsider, and our test-only no-paths-client + require.Len(t, clients, 12, "unexpected number of skill-supporting clients: %v", clients) // Verify sorted order for i := 1; i < len(clients); i++ { @@ -295,6 +316,51 @@ func TestGetSkillPath(t *testing.T) { projectRoot: "/tmp/myproject", wantPath: filepath.Join("/tmp/myproject", ".github", "skills", "my-skill"), }, + { + name: "ScopeUser Goose", + client: Goose, + skillName: "my-skill", + scope: skills.ScopeUser, + wantPath: filepath.Join(testHomeDir, ".agents", "skills", "my-skill"), + }, + { + name: "ScopeProject Goose with explicit root", + client: Goose, + skillName: "my-skill", + scope: skills.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".agents", "skills", "my-skill"), + }, + { + name: "ScopeUser GeminiCli", + client: GeminiCli, + skillName: "my-skill", + scope: skills.ScopeUser, + wantPath: filepath.Join(testHomeDir, ".agents", "skills", "my-skill"), + }, + { + name: "ScopeProject GeminiCli with explicit root", + client: GeminiCli, + skillName: "my-skill", + scope: skills.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".agents", "skills", "my-skill"), + }, + { + name: "ScopeUser AmpCli", + client: AmpCli, + skillName: "my-skill", + scope: skills.ScopeUser, + wantPath: filepath.Join(testHomeDir, ".agents", "skills", "my-skill"), + }, + { + name: "ScopeProject AmpCli with explicit root", + client: AmpCli, + skillName: "my-skill", + scope: skills.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".agents", "skills", "my-skill"), + }, { name: "client that does not support skills", client: Windsurf, From 943bafbd0851dd812b936125e0312e4bdf801068 Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:21:33 +0200 Subject: [PATCH 012/117] Add OpenAPI annotations to v0.1 skills registry endpoints (#4800) Add OpenAPI annotations to v0.1 skills API endpoints Add swag annotations to the two registry skills handlers introduced in #4753 so they appear in the generated OpenAPI spec: - GET /registry/{name}/v0.1/x/dev.toolhive/skills (list, paginated) - GET /registry/{name}/v0.1/x/dev.toolhive/skills/{ns}/{skill} (get) Add @Description doc comments to skillsV01Response and skillsV01Metadata so swag emits them as named schema components. Regenerate docs/server/ to include the new paths and schemas. --- docs/server/docs.go | 348 ++++++++++++++++++++++++++++++ docs/server/swagger.json | 348 ++++++++++++++++++++++++++++++ docs/server/swagger.yaml | 255 ++++++++++++++++++++++ pkg/api/v1/registry_v01_skills.go | 41 +++- 4 files changed, 990 insertions(+), 2 deletions(-) diff --git a/docs/server/docs.go b/docs/server/docs.go index f361099ef0..df70eb0529 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -2506,6 +2506,17 @@ const docTemplate = `{ }, "type": "object" }, + "pkg_api_v1.registryErrorResponse": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "type": "object" + }, "pkg_api_v1.registryInfo": { "description": "Basic information about a registry", "properties": { @@ -2675,6 +2686,41 @@ const docTemplate = `{ }, "type": "object" }, + "pkg_api_v1.skillsV01Metadata": { + "description": "Metadata contains pagination information", + "properties": { + "limit": { + "description": "Limit is the maximum number of skills per page", + "type": "integer" + }, + "page": { + "description": "Page is the current page number (1-based)", + "type": "integer" + }, + "total": { + "description": "Total is the total number of skills matching the query", + "type": "integer" + } + }, + "type": "object" + }, + "pkg_api_v1.skillsV01Response": { + "description": "Paginated list of skills from the registry", + "properties": { + "metadata": { + "$ref": "#/components/schemas/pkg_api_v1.skillsV01Metadata" + }, + "skills": { + "description": "Skills is the list of skills on the current page", + "items": { + "$ref": "#/components/schemas/registry.Skill" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "pkg_api_v1.toolOverride": { "description": "Tool override", "properties": { @@ -3273,6 +3319,152 @@ const docTemplate = `{ }, "type": "object" }, + "registry.Skill": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "Meta is an opaque payload with extended meta data details of the skill.", + "type": "object" + }, + "allowedTools": { + "description": "AllowedTools is the list of tools that the skill is compatible with.\nThis is experimental.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "compatibility": { + "description": "Compatibility is the environment requirements of the skill.", + "type": "string" + }, + "description": { + "description": "Description is the description of the skill.", + "type": "string" + }, + "icons": { + "description": "Icons is the list of icons for the skill.", + "items": { + "$ref": "#/components/schemas/registry.SkillIcon" + }, + "type": "array", + "uniqueItems": false + }, + "license": { + "description": "License is the SPDX license identifier of the skill.", + "type": "string" + }, + "metadata": { + "additionalProperties": {}, + "description": "Metadata is the official metadata of the skill as reported in the\nSKILL.md file.", + "type": "object" + }, + "name": { + "description": "Name is the name of the skill.\nThe format is that of identifiers, e.g. \"my-skill\".", + "type": "string" + }, + "namespace": { + "description": "Namespace is the namespace of the skill.\nThe format is reverse-DNS, e.g. \"io.github.user\".", + "type": "string" + }, + "packages": { + "description": "Packages is the list of packages for the skill.", + "items": { + "$ref": "#/components/schemas/registry.SkillPackage" + }, + "type": "array", + "uniqueItems": false + }, + "repository": { + "$ref": "#/components/schemas/registry.SkillRepository" + }, + "status": { + "description": "Status is the status of the skill.\nCan be one of \"active\", \"deprecated\", or \"archived\".", + "type": "string" + }, + "title": { + "description": "Title is the title of the skill.\nThis is for human consumption, not an identifier.", + "type": "string" + }, + "version": { + "description": "Version is the version of the skill.\nAny non-empty string is valid, but ideally it should be either a\nsemantic version or a commit hash.", + "type": "string" + } + }, + "type": "object" + }, + "registry.SkillIcon": { + "properties": { + "label": { + "description": "Label is the label of the icon.", + "type": "string" + }, + "size": { + "description": "Size is the size of the icon.", + "type": "string" + }, + "src": { + "description": "Src is the source of the icon.", + "type": "string" + }, + "type": { + "description": "Type is the type of the icon.", + "type": "string" + } + }, + "type": "object" + }, + "registry.SkillPackage": { + "properties": { + "commit": { + "description": "Commit is the commit of the package.", + "type": "string" + }, + "digest": { + "description": "Digest is the digest of the package.", + "type": "string" + }, + "identifier": { + "description": "Identifier is the OCI identifier of the package.", + "type": "string" + }, + "mediaType": { + "description": "MediaType is the media type of the package.", + "type": "string" + }, + "ref": { + "description": "Ref is the reference of the package.", + "type": "string" + }, + "registryType": { + "description": "RegistryType is the type of registry the package is from.\nCan be \"oci\" or \"git\".", + "type": "string" + }, + "subfolder": { + "description": "Subfolder is the subfolder of the package.", + "type": "string" + }, + "url": { + "description": "URL is the URL of the package.", + "type": "string" + } + }, + "type": "object" + }, + "registry.SkillRepository": { + "description": "Repository is the source repository of the skill.", + "properties": { + "type": { + "description": "Type is the type of the repository.", + "type": "string" + }, + "url": { + "description": "URL is the URL of the repository.", + "type": "string" + } + }, + "type": "object" + }, "registry.VerifiedAttestation": { "properties": { "predicate": {}, @@ -5968,6 +6160,162 @@ const docTemplate = `{ "system" ] } + }, + "/registry/{registryName}/v0.1/x/dev.toolhive/skills": { + "get": { + "description": "Get a paginated list of skills from the registry. Supports optional full-text search and pagination.", + "parameters": [ + { + "description": "Registry name (currently ignored, uses the default provider)", + "in": "path", + "name": "registryName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Search filter — matches against skill name, namespace, and description", + "in": "query", + "name": "q", + "schema": { + "type": "string" + } + }, + { + "description": "Page number, 1-based (default: 1)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "Items per page, max 200 (default: 50)", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.skillsV01Response" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Registry authentication required or upstream registry unavailable" + } + }, + "summary": "List available registry skills", + "tags": [ + "registry-skills" + ] + } + }, + "/registry/{registryName}/v0.1/x/dev.toolhive/skills/{namespace}/{skillName}": { + "get": { + "description": "Retrieve a single skill by its namespace and name from the registry.", + "parameters": [ + { + "description": "Registry name (currently ignored, uses the default provider)", + "in": "path", + "name": "registryName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Skill namespace in reverse-DNS format (e.g. io.github.stacklok)", + "in": "path", + "name": "namespace", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Skill name", + "in": "path", + "name": "skillName", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/registry.Skill" + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Skill not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Registry authentication required or upstream registry unavailable" + } + }, + "summary": "Get a registry skill", + "tags": [ + "registry-skills" + ] + } } }, "openapi": "3.1.0" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 532cac0f7c..4008970275 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -2499,6 +2499,17 @@ }, "type": "object" }, + "pkg_api_v1.registryErrorResponse": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "type": "object" + }, "pkg_api_v1.registryInfo": { "description": "Basic information about a registry", "properties": { @@ -2668,6 +2679,41 @@ }, "type": "object" }, + "pkg_api_v1.skillsV01Metadata": { + "description": "Metadata contains pagination information", + "properties": { + "limit": { + "description": "Limit is the maximum number of skills per page", + "type": "integer" + }, + "page": { + "description": "Page is the current page number (1-based)", + "type": "integer" + }, + "total": { + "description": "Total is the total number of skills matching the query", + "type": "integer" + } + }, + "type": "object" + }, + "pkg_api_v1.skillsV01Response": { + "description": "Paginated list of skills from the registry", + "properties": { + "metadata": { + "$ref": "#/components/schemas/pkg_api_v1.skillsV01Metadata" + }, + "skills": { + "description": "Skills is the list of skills on the current page", + "items": { + "$ref": "#/components/schemas/registry.Skill" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "pkg_api_v1.toolOverride": { "description": "Tool override", "properties": { @@ -3266,6 +3312,152 @@ }, "type": "object" }, + "registry.Skill": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "Meta is an opaque payload with extended meta data details of the skill.", + "type": "object" + }, + "allowedTools": { + "description": "AllowedTools is the list of tools that the skill is compatible with.\nThis is experimental.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "compatibility": { + "description": "Compatibility is the environment requirements of the skill.", + "type": "string" + }, + "description": { + "description": "Description is the description of the skill.", + "type": "string" + }, + "icons": { + "description": "Icons is the list of icons for the skill.", + "items": { + "$ref": "#/components/schemas/registry.SkillIcon" + }, + "type": "array", + "uniqueItems": false + }, + "license": { + "description": "License is the SPDX license identifier of the skill.", + "type": "string" + }, + "metadata": { + "additionalProperties": {}, + "description": "Metadata is the official metadata of the skill as reported in the\nSKILL.md file.", + "type": "object" + }, + "name": { + "description": "Name is the name of the skill.\nThe format is that of identifiers, e.g. \"my-skill\".", + "type": "string" + }, + "namespace": { + "description": "Namespace is the namespace of the skill.\nThe format is reverse-DNS, e.g. \"io.github.user\".", + "type": "string" + }, + "packages": { + "description": "Packages is the list of packages for the skill.", + "items": { + "$ref": "#/components/schemas/registry.SkillPackage" + }, + "type": "array", + "uniqueItems": false + }, + "repository": { + "$ref": "#/components/schemas/registry.SkillRepository" + }, + "status": { + "description": "Status is the status of the skill.\nCan be one of \"active\", \"deprecated\", or \"archived\".", + "type": "string" + }, + "title": { + "description": "Title is the title of the skill.\nThis is for human consumption, not an identifier.", + "type": "string" + }, + "version": { + "description": "Version is the version of the skill.\nAny non-empty string is valid, but ideally it should be either a\nsemantic version or a commit hash.", + "type": "string" + } + }, + "type": "object" + }, + "registry.SkillIcon": { + "properties": { + "label": { + "description": "Label is the label of the icon.", + "type": "string" + }, + "size": { + "description": "Size is the size of the icon.", + "type": "string" + }, + "src": { + "description": "Src is the source of the icon.", + "type": "string" + }, + "type": { + "description": "Type is the type of the icon.", + "type": "string" + } + }, + "type": "object" + }, + "registry.SkillPackage": { + "properties": { + "commit": { + "description": "Commit is the commit of the package.", + "type": "string" + }, + "digest": { + "description": "Digest is the digest of the package.", + "type": "string" + }, + "identifier": { + "description": "Identifier is the OCI identifier of the package.", + "type": "string" + }, + "mediaType": { + "description": "MediaType is the media type of the package.", + "type": "string" + }, + "ref": { + "description": "Ref is the reference of the package.", + "type": "string" + }, + "registryType": { + "description": "RegistryType is the type of registry the package is from.\nCan be \"oci\" or \"git\".", + "type": "string" + }, + "subfolder": { + "description": "Subfolder is the subfolder of the package.", + "type": "string" + }, + "url": { + "description": "URL is the URL of the package.", + "type": "string" + } + }, + "type": "object" + }, + "registry.SkillRepository": { + "description": "Repository is the source repository of the skill.", + "properties": { + "type": { + "description": "Type is the type of the repository.", + "type": "string" + }, + "url": { + "description": "URL is the URL of the repository.", + "type": "string" + } + }, + "type": "object" + }, "registry.VerifiedAttestation": { "properties": { "predicate": {}, @@ -5961,6 +6153,162 @@ "system" ] } + }, + "/registry/{registryName}/v0.1/x/dev.toolhive/skills": { + "get": { + "description": "Get a paginated list of skills from the registry. Supports optional full-text search and pagination.", + "parameters": [ + { + "description": "Registry name (currently ignored, uses the default provider)", + "in": "path", + "name": "registryName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Search filter — matches against skill name, namespace, and description", + "in": "query", + "name": "q", + "schema": { + "type": "string" + } + }, + { + "description": "Page number, 1-based (default: 1)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "Items per page, max 200 (default: 50)", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.skillsV01Response" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Registry authentication required or upstream registry unavailable" + } + }, + "summary": "List available registry skills", + "tags": [ + "registry-skills" + ] + } + }, + "/registry/{registryName}/v0.1/x/dev.toolhive/skills/{namespace}/{skillName}": { + "get": { + "description": "Retrieve a single skill by its namespace and name from the registry.", + "parameters": [ + { + "description": "Registry name (currently ignored, uses the default provider)", + "in": "path", + "name": "registryName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Skill namespace in reverse-DNS format (e.g. io.github.stacklok)", + "in": "path", + "name": "namespace", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Skill name", + "in": "path", + "name": "skillName", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/registry.Skill" + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Skill not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Registry authentication required or upstream registry unavailable" + } + }, + "summary": "Get a registry skill", + "tags": [ + "registry-skills" + ] + } } }, "openapi": "3.1.0" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 8bcebfe7a0..ac859bc102 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -2220,6 +2220,13 @@ components: description: OCI reference to push type: string type: object + pkg_api_v1.registryErrorResponse: + properties: + code: + type: string + message: + type: string + type: object pkg_api_v1.registryInfo: description: Basic information about a registry properties: @@ -2354,6 +2361,31 @@ components: type: array uniqueItems: false type: object + pkg_api_v1.skillsV01Metadata: + description: Metadata contains pagination information + properties: + limit: + description: Limit is the maximum number of skills per page + type: integer + page: + description: Page is the current page number (1-based) + type: integer + total: + description: Total is the total number of skills matching the query + type: integer + type: object + pkg_api_v1.skillsV01Response: + description: Paginated list of skills from the registry + properties: + metadata: + $ref: '#/components/schemas/pkg_api_v1.skillsV01Metadata' + skills: + description: Skills is the list of skills on the current page + items: + $ref: '#/components/schemas/registry.Skill' + type: array + uniqueItems: false + type: object pkg_api_v1.toolOverride: description: Tool override properties: @@ -2878,6 +2910,131 @@ components: description: URL is the endpoint URL for the remote MCP server (e.g., https://api.example.com/mcp) type: string type: object + registry.Skill: + properties: + _meta: + additionalProperties: {} + description: Meta is an opaque payload with extended meta data details of + the skill. + type: object + allowedTools: + description: |- + AllowedTools is the list of tools that the skill is compatible with. + This is experimental. + items: + type: string + type: array + uniqueItems: false + compatibility: + description: Compatibility is the environment requirements of the skill. + type: string + description: + description: Description is the description of the skill. + type: string + icons: + description: Icons is the list of icons for the skill. + items: + $ref: '#/components/schemas/registry.SkillIcon' + type: array + uniqueItems: false + license: + description: License is the SPDX license identifier of the skill. + type: string + metadata: + additionalProperties: {} + description: |- + Metadata is the official metadata of the skill as reported in the + SKILL.md file. + type: object + name: + description: |- + Name is the name of the skill. + The format is that of identifiers, e.g. "my-skill". + type: string + namespace: + description: |- + Namespace is the namespace of the skill. + The format is reverse-DNS, e.g. "io.github.user". + type: string + packages: + description: Packages is the list of packages for the skill. + items: + $ref: '#/components/schemas/registry.SkillPackage' + type: array + uniqueItems: false + repository: + $ref: '#/components/schemas/registry.SkillRepository' + status: + description: |- + Status is the status of the skill. + Can be one of "active", "deprecated", or "archived". + type: string + title: + description: |- + Title is the title of the skill. + This is for human consumption, not an identifier. + type: string + version: + description: |- + Version is the version of the skill. + Any non-empty string is valid, but ideally it should be either a + semantic version or a commit hash. + type: string + type: object + registry.SkillIcon: + properties: + label: + description: Label is the label of the icon. + type: string + size: + description: Size is the size of the icon. + type: string + src: + description: Src is the source of the icon. + type: string + type: + description: Type is the type of the icon. + type: string + type: object + registry.SkillPackage: + properties: + commit: + description: Commit is the commit of the package. + type: string + digest: + description: Digest is the digest of the package. + type: string + identifier: + description: Identifier is the OCI identifier of the package. + type: string + mediaType: + description: MediaType is the media type of the package. + type: string + ref: + description: Ref is the reference of the package. + type: string + registryType: + description: |- + RegistryType is the type of registry the package is from. + Can be "oci" or "git". + type: string + subfolder: + description: Subfolder is the subfolder of the package. + type: string + url: + description: URL is the URL of the package. + type: string + type: object + registry.SkillRepository: + description: Repository is the source repository of the skill. + properties: + type: + description: Type is the type of the repository. + type: string + url: + description: URL is the URL of the repository. + type: string + type: object registry.VerifiedAttestation: properties: predicate: {} @@ -4539,3 +4696,101 @@ paths: summary: Health check tags: - system + /registry/{registryName}/v0.1/x/dev.toolhive/skills: + get: + description: Get a paginated list of skills from the registry. Supports optional + full-text search and pagination. + parameters: + - description: Registry name (currently ignored, uses the default provider) + in: path + name: registryName + required: true + schema: + type: string + - description: Search filter — matches against skill name, namespace, and description + in: query + name: q + schema: + type: string + - description: 'Page number, 1-based (default: 1)' + in: query + name: page + schema: + type: integer + - description: 'Items per page, max 200 (default: 50)' + in: query + name: limit + schema: + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.skillsV01Response' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.registryErrorResponse' + description: Internal server error + "503": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.registryErrorResponse' + description: Registry authentication required or upstream registry unavailable + summary: List available registry skills + tags: + - registry-skills + /registry/{registryName}/v0.1/x/dev.toolhive/skills/{namespace}/{skillName}: + get: + description: Retrieve a single skill by its namespace and name from the registry. + parameters: + - description: Registry name (currently ignored, uses the default provider) + in: path + name: registryName + required: true + schema: + type: string + - description: Skill namespace in reverse-DNS format (e.g. io.github.stacklok) + in: path + name: namespace + required: true + schema: + type: string + - description: Skill name + in: path + name: skillName + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/registry.Skill' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.registryErrorResponse' + description: Skill not found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.registryErrorResponse' + description: Internal server error + "503": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.registryErrorResponse' + description: Registry authentication required or upstream registry unavailable + summary: Get a registry skill + tags: + - registry-skills diff --git a/pkg/api/v1/registry_v01_skills.go b/pkg/api/v1/registry_v01_skills.go index 236d5313a0..53973ff599 100644 --- a/pkg/api/v1/registry_v01_skills.go +++ b/pkg/api/v1/registry_v01_skills.go @@ -77,6 +77,19 @@ func writeJSONError(w http.ResponseWriter, status int, code, message string) { } // listSkillsV01 handles GET /registry/{registryName}/v0.1/x/dev.toolhive/skills +// +// @Summary List available registry skills +// @Description Get a paginated list of skills from the registry. Supports optional full-text search and pagination. +// @Tags registry-skills +// @Produce json +// @Param registryName path string true "Registry name (currently ignored, uses the default provider)" +// @Param q query string false "Search filter — matches against skill name, namespace, and description" +// @Param page query integer false "Page number, 1-based (default: 1)" +// @Param limit query integer false "Items per page, max 200 (default: 50)" +// @Success 200 {object} skillsV01Response +// @Failure 500 {object} registryErrorResponse "Internal server error" +// @Failure 503 {object} registryErrorResponse "Registry authentication required or upstream registry unavailable" +// @Router /registry/{registryName}/v0.1/x/dev.toolhive/skills [get] func listSkillsV01(w http.ResponseWriter, r *http.Request) { provider, ok := getSkillsProvider(w) if !ok { @@ -124,6 +137,19 @@ func listSkillsV01(w http.ResponseWriter, r *http.Request) { } // getSkillV01 handles GET /registry/{registryName}/v0.1/x/dev.toolhive/skills/{namespace}/{skillName} +// +// @Summary Get a registry skill +// @Description Retrieve a single skill by its namespace and name from the registry. +// @Tags registry-skills +// @Produce json +// @Param registryName path string true "Registry name (currently ignored, uses the default provider)" +// @Param namespace path string true "Skill namespace in reverse-DNS format (e.g. io.github.stacklok)" +// @Param skillName path string true "Skill name" +// @Success 200 {object} types.Skill +// @Failure 404 {object} registryErrorResponse "Skill not found" +// @Failure 500 {object} registryErrorResponse "Internal server error" +// @Failure 503 {object} registryErrorResponse "Registry authentication required or upstream registry unavailable" +// @Router /registry/{registryName}/v0.1/x/dev.toolhive/skills/{namespace}/{skillName} [get] func getSkillV01(w http.ResponseWriter, r *http.Request) { namespace := chi.URLParam(r, "namespace") skillName := chi.URLParam(r, "skillName") @@ -191,13 +217,24 @@ func parseSkillsPagination(r *http.Request) (page, limit int) { return page, limit } +// skillsV01Response is the response body for the v0.1 skills list endpoint. +// +// @Description Paginated list of skills from the registry type skillsV01Response struct { - Skills []types.Skill `json:"skills"` + // Skills is the list of skills on the current page + Skills []types.Skill `json:"skills"` + // Metadata contains pagination information Metadata skillsV01Metadata `json:"metadata"` } +// skillsV01Metadata holds pagination metadata for the v0.1 skills list response. +// +// @Description Pagination metadata for a skills list response type skillsV01Metadata struct { + // Total is the total number of skills matching the query Total int `json:"total"` - Page int `json:"page"` + // Page is the current page number (1-based) + Page int `json:"page"` + // Limit is the maximum number of skills per page Limit int `json:"limit"` } From 2dedbae12a27b175a9e2ac7b685d9a0b6d8afad3 Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:26:34 +0200 Subject: [PATCH 013/117] Add Kiro as a skill-supporting client (#4803) --- pkg/client/config.go | 3 +++ pkg/client/skills_test.go | 26 ++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/pkg/client/config.go b/pkg/client/config.go index d1d861ec82..21c35642ad 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -653,6 +653,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "url", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".kiro", "skills"}, + SkillsProjectPath: []string{".kiro", "skills"}, }, { ClientType: Antigravity, diff --git a/pkg/client/skills_test.go b/pkg/client/skills_test.go index 206d40afe3..05dd971565 100644 --- a/pkg/client/skills_test.go +++ b/pkg/client/skills_test.go @@ -88,6 +88,12 @@ func testSkillClientIntegrations() []clientAppConfig { SkillsGlobalPath: []string{".agents", "skills"}, SkillsProjectPath: []string{".agents", "skills"}, }, + { + ClientType: Kiro, + SupportsSkills: true, + SkillsGlobalPath: []string{".kiro", "skills"}, + SkillsProjectPath: []string{".kiro", "skills"}, + }, { ClientType: Windsurf, // SupportsSkills defaults to false @@ -126,6 +132,7 @@ func TestSupportsSkills(t *testing.T) { {name: "Goose supports skills", client: Goose, expected: true}, {name: "GeminiCli supports skills", client: GeminiCli, expected: true}, {name: "AmpCli supports skills", client: AmpCli, expected: true}, + {name: "Kiro supports skills", client: Kiro, expected: true}, {name: "Windsurf does not support skills", client: Windsurf, expected: false}, {name: "unknown client returns false", client: ClientApp("nonexistent"), expected: false}, } @@ -143,8 +150,8 @@ func TestListSkillSupportingClients(t *testing.T) { cm := newTestSkillManager() clients := cm.ListSkillSupportingClients() - // Should include AmpCli, ClaudeCode, Codex, Cursor, Factory, GeminiCli, Goose, KimiCli, OpenCode, VSCode, VSCodeInsider, and our test-only no-paths-client - require.Len(t, clients, 12, "unexpected number of skill-supporting clients: %v", clients) + // Should include AmpCli, ClaudeCode, Codex, Cursor, Factory, GeminiCli, Goose, Kiro, KimiCli, OpenCode, VSCode, VSCodeInsider, and our test-only no-paths-client + require.Len(t, clients, 13, "unexpected number of skill-supporting clients: %v", clients) // Verify sorted order for i := 1; i < len(clients); i++ { @@ -361,6 +368,21 @@ func TestGetSkillPath(t *testing.T) { projectRoot: "/tmp/myproject", wantPath: filepath.Join("/tmp/myproject", ".agents", "skills", "my-skill"), }, + { + name: "ScopeUser Kiro", + client: Kiro, + skillName: "my-skill", + scope: skills.ScopeUser, + wantPath: filepath.Join(testHomeDir, ".kiro", "skills", "my-skill"), + }, + { + name: "ScopeProject Kiro with explicit root", + client: Kiro, + skillName: "my-skill", + scope: skills.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".kiro", "skills", "my-skill"), + }, { name: "client that does not support skills", client: Windsurf, From c9a2642060d5c4c2dbf9fed1334df5fba7df9166 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:50:23 +0100 Subject: [PATCH 014/117] Update module go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp to v1.43.0 [SECURITY] (#4683) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 10 +++++----- go.sum | 24 ++++++++++++------------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 6293de206b..df57d00110 100644 --- a/go.mod +++ b/go.mod @@ -54,7 +54,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/xeipuuv/gojsonschema v1.2.0 github.com/zalando/go-keyring v0.2.8 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 go.opentelemetry.io/otel/exporters/prometheus v0.63.0 go.opentelemetry.io/otel/sdk v1.43.0 @@ -280,7 +280,7 @@ require ( go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect go.opentelemetry.io/otel/exporters/zipkin v1.21.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect @@ -292,9 +292,9 @@ require ( golang.org/x/tools v0.44.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect - google.golang.org/grpc v1.79.3 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index dbcd9ccc06..e0b09d5a30 100644 --- a/go.sum +++ b/go.sum @@ -916,8 +916,8 @@ go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go= @@ -934,8 +934,8 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.step.sm/crypto v0.74.0 h1:/APBEv45yYR4qQFg47HA8w1nesIGcxh44pGyQNw6JRA= go.step.sm/crypto v0.74.0/go.mod h1:UoXqCAJjjRgzPte0Llaqen7O9P7XjPmgjgTHQGkKCDk= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -1112,18 +1112,18 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU= google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 28426ca20a4e73071869308c96f1e97302209c59 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:50:49 +0100 Subject: [PATCH 015/117] Bump go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp from 1.41.0 to 1.43.0 (#4684) Bump go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp Bumps [go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp](https://github.com/open-telemetry/opentelemetry-go) from 1.41.0 to 1.43.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.41.0...v1.43.0) --- updated-dependencies: - dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp dependency-version: 1.43.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From cd34d0e7924d66969982f26b8dc494d029bf127e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:51:36 +0100 Subject: [PATCH 016/117] Update dependency helm/helm to v3.20.2 (#4756) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/helm-charts-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/helm-charts-test.yml b/.github/workflows/helm-charts-test.yml index 75c7898355..51e3378eff 100644 --- a/.github/workflows/helm-charts-test.yml +++ b/.github/workflows/helm-charts-test.yml @@ -28,7 +28,7 @@ jobs: - name: Set up Helm uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 with: - version: v3.20.1 # helm + version: v3.20.2 # helm - name: Set up chart-testing uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0 From 36886940229c81f7b416516ab0316feba5c9cb10 Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:04:58 +0200 Subject: [PATCH 017/117] Add Cline, Roo Code, Windsurf, Mistral Vibe, Trae, and Antigravity as skill-supporting clients (#4804) --- pkg/client/config.go | 20 +++++- pkg/client/skills_test.go | 143 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 157 insertions(+), 6 deletions(-) diff --git a/pkg/client/config.go b/pkg/client/config.go index 21c35642ad..d9781913c1 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -218,6 +218,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "url", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".roo", "skills"}, + SkillsProjectPath: []string{".roo", "skills"}, }, { ClientType: Cline, @@ -244,6 +247,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "url", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".cline", "skills"}, + SkillsProjectPath: []string{".cline", "skills"}, }, { ClientType: VSCodeInsider, @@ -367,6 +373,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "serverUrl", types.TransportTypeStreamableHTTP: "serverUrl", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".codeium", "windsurf", "skills"}, + SkillsProjectPath: []string{".windsurf", "skills"}, }, { ClientType: WindsurfJetBrains, @@ -586,6 +595,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "url", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, }, { ClientType: Continue, @@ -670,6 +682,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "serverUrl", types.TransportTypeStreamableHTTP: "serverUrl", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, }, { ClientType: Zed, @@ -744,7 +759,10 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeStreamableHTTP: "url", }, // TOML configuration: uses array-of-tables format [[mcp_servers]] - TOMLStorageType: TOMLStorageTypeArray, + TOMLStorageType: TOMLStorageTypeArray, + SupportsSkills: true, + SkillsGlobalPath: []string{".vibe", "skills"}, + SkillsProjectPath: []string{".vibe", "skills"}, }, { ClientType: Codex, diff --git a/pkg/client/skills_test.go b/pkg/client/skills_test.go index 05dd971565..08f9c3569d 100644 --- a/pkg/client/skills_test.go +++ b/pkg/client/skills_test.go @@ -95,7 +95,43 @@ func testSkillClientIntegrations() []clientAppConfig { SkillsProjectPath: []string{".kiro", "skills"}, }, { - ClientType: Windsurf, + ClientType: RooCode, + SupportsSkills: true, + SkillsGlobalPath: []string{".roo", "skills"}, + SkillsProjectPath: []string{".roo", "skills"}, + }, + { + ClientType: Cline, + SupportsSkills: true, + SkillsGlobalPath: []string{".cline", "skills"}, + SkillsProjectPath: []string{".cline", "skills"}, + }, + { + ClientType: Windsurf, + SupportsSkills: true, + SkillsGlobalPath: []string{".codeium", "windsurf", "skills"}, + SkillsProjectPath: []string{".windsurf", "skills"}, + }, + { + ClientType: MistralVibe, + SupportsSkills: true, + SkillsGlobalPath: []string{".vibe", "skills"}, + SkillsProjectPath: []string{".vibe", "skills"}, + }, + { + ClientType: Trae, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, + }, + { + ClientType: Antigravity, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, + }, + { + ClientType: Zed, // SupportsSkills defaults to false }, { @@ -133,7 +169,13 @@ func TestSupportsSkills(t *testing.T) { {name: "GeminiCli supports skills", client: GeminiCli, expected: true}, {name: "AmpCli supports skills", client: AmpCli, expected: true}, {name: "Kiro supports skills", client: Kiro, expected: true}, - {name: "Windsurf does not support skills", client: Windsurf, expected: false}, + {name: "RooCode supports skills", client: RooCode, expected: true}, + {name: "Cline supports skills", client: Cline, expected: true}, + {name: "Windsurf supports skills", client: Windsurf, expected: true}, + {name: "MistralVibe supports skills", client: MistralVibe, expected: true}, + {name: "Trae supports skills", client: Trae, expected: true}, + {name: "Antigravity supports skills", client: Antigravity, expected: true}, + {name: "Zed does not support skills", client: Zed, expected: false}, {name: "unknown client returns false", client: ClientApp("nonexistent"), expected: false}, } @@ -150,8 +192,9 @@ func TestListSkillSupportingClients(t *testing.T) { cm := newTestSkillManager() clients := cm.ListSkillSupportingClients() - // Should include AmpCli, ClaudeCode, Codex, Cursor, Factory, GeminiCli, Goose, Kiro, KimiCli, OpenCode, VSCode, VSCodeInsider, and our test-only no-paths-client - require.Len(t, clients, 13, "unexpected number of skill-supporting clients: %v", clients) + // Should include AmpCli, Antigravity, ClaudeCode, Cline, Codex, Cursor, Factory, GeminiCli, Goose, Kiro, KimiCli, + // MistralVibe, OpenCode, RooCode, Trae, VSCode, VSCodeInsider, Windsurf, and our test-only no-paths-client + require.Len(t, clients, 19, "unexpected number of skill-supporting clients: %v", clients) // Verify sorted order for i := 1; i < len(clients); i++ { @@ -384,10 +427,100 @@ func TestGetSkillPath(t *testing.T) { wantPath: filepath.Join("/tmp/myproject", ".kiro", "skills", "my-skill"), }, { - name: "client that does not support skills", + name: "ScopeUser RooCode", + client: RooCode, + skillName: "my-skill", + scope: skills.ScopeUser, + wantPath: filepath.Join(testHomeDir, ".roo", "skills", "my-skill"), + }, + { + name: "ScopeProject RooCode with explicit root", + client: RooCode, + skillName: "my-skill", + scope: skills.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".roo", "skills", "my-skill"), + }, + { + name: "ScopeUser Cline", + client: Cline, + skillName: "my-skill", + scope: skills.ScopeUser, + wantPath: filepath.Join(testHomeDir, ".cline", "skills", "my-skill"), + }, + { + name: "ScopeProject Cline with explicit root", + client: Cline, + skillName: "my-skill", + scope: skills.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".cline", "skills", "my-skill"), + }, + { + name: "ScopeUser Windsurf", client: Windsurf, skillName: "my-skill", scope: skills.ScopeUser, + wantPath: filepath.Join(testHomeDir, ".codeium", "windsurf", "skills", "my-skill"), + }, + { + name: "ScopeProject Windsurf with explicit root", + client: Windsurf, + skillName: "my-skill", + scope: skills.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".windsurf", "skills", "my-skill"), + }, + { + name: "ScopeUser MistralVibe", + client: MistralVibe, + skillName: "my-skill", + scope: skills.ScopeUser, + wantPath: filepath.Join(testHomeDir, ".vibe", "skills", "my-skill"), + }, + { + name: "ScopeProject MistralVibe with explicit root", + client: MistralVibe, + skillName: "my-skill", + scope: skills.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".vibe", "skills", "my-skill"), + }, + { + name: "ScopeUser Trae", + client: Trae, + skillName: "my-skill", + scope: skills.ScopeUser, + wantPath: filepath.Join(testHomeDir, ".agents", "skills", "my-skill"), + }, + { + name: "ScopeProject Trae with explicit root", + client: Trae, + skillName: "my-skill", + scope: skills.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".agents", "skills", "my-skill"), + }, + { + name: "ScopeUser Antigravity", + client: Antigravity, + skillName: "my-skill", + scope: skills.ScopeUser, + wantPath: filepath.Join(testHomeDir, ".agents", "skills", "my-skill"), + }, + { + name: "ScopeProject Antigravity with explicit root", + client: Antigravity, + skillName: "my-skill", + scope: skills.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".agents", "skills", "my-skill"), + }, + { + name: "client that does not support skills", + client: Zed, + skillName: "my-skill", + scope: skills.ScopeUser, wantErr: ErrSkillsNotSupported, }, { From fa955cdddb728523d28bafa3815e919c40eb10e5 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:10:19 +0100 Subject: [PATCH 018/117] Add TelemetryConfigRef support to VirtualMCPServer CRD (#4801) * Add TelemetryConfigRef support to VirtualMCPServer CRD VirtualMCPServer can now reference shared MCPTelemetryConfig resources via spec.telemetryConfigRef, following the same pattern used by MCPServer and MCPRemoteProxy. This enables shared telemetry configuration, Kubernetes-native secret references for OTLP auth headers, CA bundle ConfigMap references, and per-server serviceName overrides. Implements changes for issue #4792: - Add TelemetryConfigRef field and CEL mutual exclusivity validation - Add TelemetryConfigHash to status for change detection - Add telemetry handler with batched statusManager pattern - Add MCPTelemetryConfig watch in SetupWithManager - Update converter to prefer TelemetryConfigRef over inline telemetry - Add CA bundle volumes and sensitive header env vars to deployment - Regenerate CRD manifests, RBAC, deepcopy, and mocks Co-Authored-By: Claude Opus 4.6 (1M context) * Deprecate inline config.telemetry in favor of telemetryConfigRef Add deprecation comments to config.Config.Telemetry field (for operator context) and emit a warning log when inline telemetry is used during conversion, guiding operators to migrate to spec.telemetryConfigRef. Co-Authored-By: Claude Opus 4.6 (1M context) * Add integration tests, docs, and example for TelemetryConfigRef Add envtest-based integration tests for VirtualMCPServer TelemetryConfigRef: hash tracking, config change detection, missing ref condition, and CEL mutual exclusivity validation. Update observability docs to show the preferred telemetryConfigRef pattern for VirtualMCPServer and mark inline config.telemetry as deprecated. Add example manifest demonstrating shared MCPTelemetryConfig with VirtualMCPServer. Co-Authored-By: Claude Opus 4.6 (1M context) * Address code review feedback for VirtualMCPServer TelemetryConfigRef Fixes from code review on PR #4801 for issue #4792: - Add VirtualMCPServer to MCPTelemetryConfig controller's findReferencingWorkloads, watch handler, and RBAC markers for deletion protection parity - Eliminate redundant MCPTelemetryConfig API calls (3x per reconcile) by fetching once in handleTelemetryConfig and threading the config through to converter and deployment builder - Change normalizeTelemetry to accept pre-fetched config instead of re-fetching - Fix deprecation log level from Info to V(1) debug to avoid log flooding - Remove stale TelemetryConfigRefValidated condition when TelemetryConfigRef is nil - Update architecture docs with VirtualMCPServer telemetryConfigRef edges Co-Authored-By: Claude Opus 4.6 (1M context) * Regenerate CRD API docs for TelemetryConfigRef Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../api/v1alpha1/virtualmcpserver_types.go | 35 +- .../api/v1alpha1/zz_generated.deepcopy.go | 5 + .../mcptelemetryconfig_controller.go | 64 +++- .../virtualmcpserver_controller.go | 56 ++- .../virtualmcpserver_controller_test.go | 24 +- .../virtualmcpserver_deployment.go | 25 +- .../virtualmcpserver_deployment_test.go | 10 +- ...cpserver_podtemplatespec_reconcile_test.go | 8 +- .../virtualmcpserver_telemetryconfig.go | 127 +++++++ .../virtualmcpserver_telemetryconfig_test.go | 254 +++++++++++++ .../virtualmcpserver_vmcpconfig.go | 5 +- .../virtualmcpserver_vmcpconfig_test.go | 32 +- cmd/thv-operator/pkg/controllerutil/config.go | 28 ++ .../pkg/controllerutil/config_test.go | 95 +++++ .../pkg/virtualmcpserverstatus/collector.go | 36 +- .../virtualmcpserverstatus/collector_test.go | 50 +++ .../mocks/mock_collector.go | 24 ++ .../pkg/virtualmcpserverstatus/types.go | 6 + cmd/thv-operator/pkg/vmcpconfig/converter.go | 101 +++-- .../pkg/vmcpconfig/converter_test.go | 76 +++- .../test-integration/virtualmcp/suite_test.go | 7 + ...server_telemetryconfig_integration_test.go | 349 ++++++++++++++++++ ...olhive.stacklok.dev_virtualmcpservers.yaml | 37 +- ...olhive.stacklok.dev_virtualmcpservers.yaml | 37 +- docs/arch/09-operator-architecture.md | 4 +- docs/arch/10-virtual-mcp-architecture.md | 1 + docs/observability.md | 15 +- docs/operator/crd-api.md | 7 +- .../virtualmcpserver-observability.md | 51 ++- .../virtual-mcps/vmcp_with_telemetry_ref.yaml | 106 ++++++ pkg/vmcp/config/config.go | 3 + 31 files changed, 1557 insertions(+), 121 deletions(-) create mode 100644 cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig.go create mode 100644 cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig_test.go create mode 100644 cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go create mode 100644 examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml diff --git a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go index 27db742973..28a56fb15b 100644 --- a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go +++ b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go @@ -14,6 +14,10 @@ import ( ) // VirtualMCPServerSpec defines the desired state of VirtualMCPServer +// +// +kubebuilder:validation:XValidation:rule="!(has(self.config.telemetry) && has(self.telemetryConfigRef))",message="config.telemetry and telemetryConfigRef are mutually exclusive; migrate to telemetryConfigRef" +// +//nolint:lll // CEL validation rules exceed line length limit type VirtualMCPServerSpec struct { // IncomingAuth configures authentication for clients connecting to the Virtual MCP server. // Must be explicitly set - use "anonymous" type when no authentication is required. @@ -63,10 +67,19 @@ type VirtualMCPServerSpec struct { // The only field currently required within config is `config.groupRef`. // GroupRef references an existing MCPGroup that defines backend workloads. // The referenced MCPGroup must exist in the same namespace. - // The telemetry and audit config from here are also supported, but not required. + // The audit config from here is also supported, but not required. + // Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference + // a shared MCPTelemetryConfig resource instead. // +optional Config config.Config `json:"config,omitempty"` + // TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. + // The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer. + // Cross-namespace references are not supported for security and isolation reasons. + // Mutually exclusive with the deprecated inline config.telemetry field. + // +optional + TelemetryConfigRef *MCPTelemetryConfigReference `json:"telemetryConfigRef,omitempty"` + // EmbeddingServerRef references an existing EmbeddingServer resource by name. // When the optimizer is enabled, this field is required to point to a ready EmbeddingServer // that provides embedding capabilities. @@ -228,6 +241,11 @@ type VirtualMCPServerStatus struct { // Only populated when IncomingAuth.OIDCConfigRef is set. // +optional OIDCConfigHash string `json:"oidcConfigHash,omitempty"` + + // TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection. + // Only populated when TelemetryConfigRef is set. + // +optional + TelemetryConfigHash string `json:"telemetryConfigHash,omitempty"` } // VirtualMCPServerPhase represents the lifecycle phase of a VirtualMCPServer @@ -270,6 +288,9 @@ const ( // ConditionTypeAuthServerConfigValidated indicates whether the AuthServerConfig has been validated ConditionTypeAuthServerConfigValidated = "AuthServerConfigValidated" + + // ConditionTypeVirtualMCPServerTelemetryConfigRefValidated indicates whether the TelemetryConfigRef is valid + ConditionTypeVirtualMCPServerTelemetryConfigRefValidated = "TelemetryConfigRefValidated" ) // Condition reasons for VirtualMCPServer @@ -333,6 +354,18 @@ const ( // ConditionReasonAuthServerConfigInvalid indicates the AuthServerConfig is invalid ConditionReasonAuthServerConfigInvalid = "AuthServerConfigInvalid" + + // ConditionReasonVirtualMCPServerTelemetryConfigRefValid indicates the referenced MCPTelemetryConfig is valid + ConditionReasonVirtualMCPServerTelemetryConfigRefValid = "TelemetryConfigRefValid" + + // ConditionReasonVirtualMCPServerTelemetryConfigRefNotFound indicates the referenced MCPTelemetryConfig was not found + ConditionReasonVirtualMCPServerTelemetryConfigRefNotFound = "TelemetryConfigRefNotFound" + + // ConditionReasonVirtualMCPServerTelemetryConfigRefInvalid indicates the referenced MCPTelemetryConfig is not valid + ConditionReasonVirtualMCPServerTelemetryConfigRefInvalid = "TelemetryConfigRefInvalid" + + // ConditionReasonVirtualMCPServerTelemetryConfigRefFetchError indicates a transient error occurred fetching the config + ConditionReasonVirtualMCPServerTelemetryConfigRefFetchError = "TelemetryConfigRefFetchError" ) // Backend authentication types diff --git a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go index 3776127a22..ceafe5b09a 100644 --- a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -3104,6 +3104,11 @@ func (in *VirtualMCPServerSpec) DeepCopyInto(out *VirtualMCPServerSpec) { (*in).DeepCopyInto(*out) } in.Config.DeepCopyInto(&out.Config) + if in.TelemetryConfigRef != nil { + in, out := &in.TelemetryConfigRef, &out.TelemetryConfigRef + *out = new(MCPTelemetryConfigReference) + **out = **in + } if in.EmbeddingServerRef != nil { in, out := &in.EmbeddingServerRef, &out.EmbeddingServerRef *out = new(EmbeddingServerRef) diff --git a/cmd/thv-operator/controllers/mcptelemetryconfig_controller.go b/cmd/thv-operator/controllers/mcptelemetryconfig_controller.go index 4b061e7a98..976c9c8bc8 100644 --- a/cmd/thv-operator/controllers/mcptelemetryconfig_controller.go +++ b/cmd/thv-operator/controllers/mcptelemetryconfig_controller.go @@ -45,6 +45,7 @@ type MCPTelemetryConfigReconciler struct { // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcptelemetryconfigs/status,verbs=get;update;patch // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcptelemetryconfigs/finalizers,verbs=update // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpservers,verbs=list;watch +// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=virtualmcpservers,verbs=list;watch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -197,6 +198,10 @@ func (r *MCPTelemetryConfigReconciler) SetupWithManager(mgr ctrl.Manager) error &mcpv1alpha1.MCPRemoteProxy{}, handler.EnqueueRequestsFromMapFunc(r.mapMCPRemoteProxyToTelemetryConfig), ). + Watches( + &mcpv1alpha1.VirtualMCPServer{}, + handler.EnqueueRequestsFromMapFunc(r.mapVirtualMCPServerToTelemetryConfig), + ). Complete(r) } @@ -246,6 +251,52 @@ func (r *MCPTelemetryConfigReconciler) mapMCPRemoteProxyToTelemetryConfig( return requests } +// mapVirtualMCPServerToTelemetryConfig enqueues MCPTelemetryConfig reconcile requests +// when a VirtualMCPServer changes. Handles both the currently-referenced config and +// any config that still lists this server in ReferencingWorkloads (ref-removal case). +func (r *MCPTelemetryConfigReconciler) mapVirtualMCPServerToTelemetryConfig( + ctx context.Context, obj client.Object, +) []reconcile.Request { + vmcp, ok := obj.(*mcpv1alpha1.VirtualMCPServer) + if !ok { + return nil + } + + seen := make(map[types.NamespacedName]struct{}) + var requests []reconcile.Request + + if vmcp.Spec.TelemetryConfigRef != nil { + nn := types.NamespacedName{ + Name: vmcp.Spec.TelemetryConfigRef.Name, + Namespace: vmcp.Namespace, + } + seen[nn] = struct{}{} + requests = append(requests, reconcile.Request{NamespacedName: nn}) + } + + // Also enqueue any MCPTelemetryConfig that still lists this VirtualMCPServer in + // ReferencingWorkloads — handles ref-removal and server-deletion cases. + telemetryConfigList := &mcpv1alpha1.MCPTelemetryConfigList{} + if err := r.List(ctx, telemetryConfigList, client.InNamespace(vmcp.Namespace)); err != nil { + log.FromContext(ctx).Error(err, "Failed to list MCPTelemetryConfigs for VirtualMCPServer watch") + return requests + } + for _, cfg := range telemetryConfigList.Items { + nn := types.NamespacedName{Name: cfg.Name, Namespace: cfg.Namespace} + if _, already := seen[nn]; already { + continue + } + for _, ref := range cfg.Status.ReferencingWorkloads { + if ref.Kind == mcpv1alpha1.WorkloadKindVirtualMCPServer && ref.Name == vmcp.Name { + requests = append(requests, reconcile.Request{NamespacedName: nn}) + break + } + } + } + + return requests +} + // calculateConfigHash calculates a hash of the MCPTelemetryConfig spec using Kubernetes utilities func (*MCPTelemetryConfigReconciler) calculateConfigHash(spec mcpv1alpha1.MCPTelemetryConfigSpec) string { return ctrlutil.CalculateConfigHash(spec) @@ -328,11 +379,22 @@ func (r *MCPTelemetryConfigReconciler) findReferencingWorkloads( return nil, err } - refs := make([]mcpv1alpha1.WorkloadReference, 0, len(serverRefs)+len(proxies)) + // Check VirtualMCPServers + vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + if err := r.List(ctx, vmcpList, client.InNamespace(telemetryConfig.Namespace)); err != nil { + return nil, fmt.Errorf("failed to list VirtualMCPServers: %w", err) + } + + refs := make([]mcpv1alpha1.WorkloadReference, 0, len(serverRefs)+len(proxies)+len(vmcpList.Items)) refs = append(refs, serverRefs...) for _, proxy := range proxies { refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPRemoteProxy, Name: proxy.Name}) } + for _, vmcp := range vmcpList.Items { + if vmcp.Spec.TelemetryConfigRef != nil && vmcp.Spec.TelemetryConfigRef.Name == telemetryConfig.Name { + refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindVirtualMCPServer, Name: vmcp.Name}) + } + } ctrlutil.SortWorkloadRefs(refs) return refs, nil } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index e43e22c674..bb461b77b8 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -126,6 +126,7 @@ type VirtualMCPServerReconciler struct { // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpoidcconfigs/status,verbs=get;update;patch // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=embeddingservers,verbs=get;list;watch // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=embeddingservers/status,verbs=get +// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcptelemetryconfigs,verbs=get;list;watch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -157,17 +158,20 @@ func (r *VirtualMCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Req return ctrl.Result{}, nil } - // Validate MCPOIDCConfigRef if referenced (before resource creation). - // handleOIDCConfig is a no-op when OIDCConfigRef is nil. - if err := r.handleOIDCConfig(ctx, vmcp, statusManager); err != nil { + // Validate shared config references (OIDC, Telemetry) before resource creation. + // Each handler is a no-op when its respective ref is nil. + // telemetryCfg is the fetched MCPTelemetryConfig (nil when not referenced), + // threaded through to downstream functions to avoid redundant API calls. + telemetryCfg, err := r.handleConfigRefs(ctx, vmcp, statusManager) + if err != nil { if applyErr := r.applyStatusUpdates(ctx, vmcp, statusManager); applyErr != nil { - ctxLogger.Error(applyErr, "Failed to apply status updates after MCPOIDCConfig validation error") + ctxLogger.Error(applyErr, "Failed to apply status updates after config ref validation error") } return ctrl.Result{}, err } // Ensure all resources - if result, err := r.ensureAllResources(ctx, vmcp, statusManager); err != nil { + if result, err := r.ensureAllResources(ctx, vmcp, telemetryCfg, statusManager); err != nil { // Apply status changes before returning error if applyErr := r.applyStatusUpdates(ctx, vmcp, statusManager); applyErr != nil { ctxLogger.Error(applyErr, "Failed to apply status updates after resource reconciliation error") @@ -658,11 +662,14 @@ func (r *VirtualMCPServerReconciler) validateAndUpdatePodTemplateStatus( } // ensureAllResources ensures all Kubernetes resources for the VirtualMCPServer. +// telemetryCfg is the already-fetched MCPTelemetryConfig (nil when not referenced), +// passed through from handleConfigRefs to avoid redundant API calls. // Returns a ctrl.Result with RequeueAfter when the controller should retry later // (e.g., waiting for EmbeddingServer readiness), and an error for failures. func (r *VirtualMCPServerReconciler) ensureAllResources( ctx context.Context, vmcp *mcpv1alpha1.VirtualMCPServer, + telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, statusManager virtualmcpserverstatus.StatusManager, ) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) @@ -726,7 +733,8 @@ func (r *VirtualMCPServerReconciler) ensureAllResources( // Ensure vmcp Config ConfigMap. // handleSpecValidationError converts SpecValidationError to nil (no requeue) // after applying status conditions, while passing through transient errors. - if specValidationErr := r.ensureVmcpConfigConfigMap(ctx, vmcp, workloadNames, statusManager); specValidationErr != nil { + specValidationErr := r.ensureVmcpConfigConfigMap(ctx, vmcp, workloadNames, telemetryCfg, statusManager) + if specValidationErr != nil { if err := r.handleSpecValidationError(ctx, vmcp, statusManager, specValidationErr); err != nil { ctxLogger.Error(err, "Failed to ensure vmcp Config ConfigMap") return ctrl.Result{}, err @@ -737,7 +745,7 @@ func (r *VirtualMCPServerReconciler) ensureAllResources( } // Ensure Deployment - if result, err := r.ensureDeployment(ctx, vmcp, workloadNames); err != nil { + if result, err := r.ensureDeployment(ctx, vmcp, telemetryCfg, workloadNames); err != nil { return ctrl.Result{}, err } else if result.RequeueAfter > 0 { return result, nil @@ -1028,6 +1036,7 @@ func (r *VirtualMCPServerReconciler) getVmcpConfigChecksum( func (r *VirtualMCPServerReconciler) ensureDeployment( ctx context.Context, vmcp *mcpv1alpha1.VirtualMCPServer, + telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) @@ -1048,7 +1057,7 @@ func (r *VirtualMCPServerReconciler) ensureDeployment( err = r.Get(ctx, types.NamespacedName{Name: vmcp.Name, Namespace: vmcp.Namespace}, deployment) if errors.IsNotFound(err) { - dep := r.deploymentForVirtualMCPServer(ctx, vmcp, vmcpConfigChecksum, typedWorkloads) + dep := r.deploymentForVirtualMCPServer(ctx, vmcp, vmcpConfigChecksum, telemetryCfg, typedWorkloads) if dep == nil { return ctrl.Result{}, fmt.Errorf("failed to create Deployment object") } @@ -1077,8 +1086,8 @@ func (r *VirtualMCPServerReconciler) ensureDeployment( // Deployment exists - check if it needs to be updated // deploymentNeedsUpdate performs a detailed comparison to avoid unnecessary updates - if r.deploymentNeedsUpdate(ctx, deployment, vmcp, vmcpConfigChecksum, typedWorkloads) { - newDeployment := r.deploymentForVirtualMCPServer(ctx, vmcp, vmcpConfigChecksum, typedWorkloads) + if r.deploymentNeedsUpdate(ctx, deployment, vmcp, vmcpConfigChecksum, telemetryCfg, typedWorkloads) { + newDeployment := r.deploymentForVirtualMCPServer(ctx, vmcp, vmcpConfigChecksum, telemetryCfg, typedWorkloads) if newDeployment == nil { return ctrl.Result{}, fmt.Errorf("failed to create updated Deployment object") } @@ -1215,6 +1224,7 @@ func (r *VirtualMCPServerReconciler) deploymentNeedsUpdate( deployment *appsv1.Deployment, vmcp *mcpv1alpha1.VirtualMCPServer, vmcpConfigChecksum string, + telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) bool { if deployment == nil || vmcp == nil { @@ -1225,7 +1235,7 @@ func (r *VirtualMCPServerReconciler) deploymentNeedsUpdate( return true } - if r.containerNeedsUpdate(ctx, deployment, vmcp, typedWorkloads) { + if r.containerNeedsUpdate(ctx, deployment, vmcp, telemetryCfg, typedWorkloads) { return true } @@ -1257,6 +1267,7 @@ func (r *VirtualMCPServerReconciler) containerNeedsUpdate( ctx context.Context, deployment *appsv1.Deployment, vmcp *mcpv1alpha1.VirtualMCPServer, + telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) bool { if deployment == nil || vmcp == nil || len(deployment.Spec.Template.Spec.Containers) == 0 { @@ -1283,7 +1294,7 @@ func (r *VirtualMCPServerReconciler) containerNeedsUpdate( } // Check if environment variables have changed - expectedEnv, err := r.buildEnvVarsForVmcp(ctx, vmcp, typedWorkloads) + expectedEnv, err := r.buildEnvVarsForVmcp(ctx, vmcp, telemetryCfg, typedWorkloads) if err != nil { return true // Trigger update to surface the error } @@ -2227,6 +2238,12 @@ func (r *VirtualMCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { &mcpv1alpha1.MCPOIDCConfig{}, handler.EnqueueRequestsFromMapFunc(r.mapOIDCConfigToVirtualMCPServer), ). + // Watch referenced MCPTelemetryConfigs so that validity/hash changes + // trigger VirtualMCPServer reconciliation. + Watches( + &mcpv1alpha1.MCPTelemetryConfig{}, + handler.EnqueueRequestsFromMapFunc(r.mapTelemetryConfigToVirtualMCPServer), + ). Complete(r) } @@ -2876,6 +2893,21 @@ func generateHMACSecret() (string, error) { return base64.StdEncoding.EncodeToString(secret), nil } +// handleConfigRefs validates shared config references (OIDC, Telemetry) before resource creation. +// Each handler is a no-op when its respective ref is nil. +// Returns the fetched MCPTelemetryConfig (may be nil) so callers can thread it through +// to downstream functions without redundant API calls. +func (r *VirtualMCPServerReconciler) handleConfigRefs( + ctx context.Context, + vmcp *mcpv1alpha1.VirtualMCPServer, + statusManager virtualmcpserverstatus.StatusManager, +) (*mcpv1alpha1.MCPTelemetryConfig, error) { + if err := r.handleOIDCConfig(ctx, vmcp, statusManager); err != nil { + return nil, err + } + return r.handleTelemetryConfig(ctx, vmcp, statusManager) +} + // handleOIDCConfig validates and tracks the hash of the referenced MCPOIDCConfig. // It sets the OIDCConfigRefValidated condition and triggers reconciliation when // the OIDC configuration changes. diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go index 7b0e9e6fa7..0317076175 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go @@ -686,7 +686,7 @@ func TestVirtualMCPServerEnsureDeployment(t *testing.T) { PlatformDetector: ctrlutil.NewSharedPlatformDetector(), } - result, err := r.ensureDeployment(context.Background(), vmcp, []workloads.TypedWorkload{}) + result, err := r.ensureDeployment(context.Background(), vmcp, nil, []workloads.TypedWorkload{}) require.NoError(t, err) assert.Equal(t, ctrl.Result{}, result) @@ -1303,7 +1303,7 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { } statusManager := virtualmcpserverstatus.NewStatusManager(tt.vmcp) - _, err := r.ensureAllResources(context.Background(), tt.vmcp, statusManager) + _, err := r.ensureAllResources(context.Background(), tt.vmcp, nil, statusManager) if tt.expectError { assert.Error(t, err) @@ -1597,7 +1597,7 @@ func TestVirtualMCPServerEnsureAllResources_Errors(t *testing.T) { collector := virtualmcpserverstatus.NewStatusManager(vmcp) - _, err := reconciler.ensureAllResources(context.Background(), vmcp, collector) + _, err := reconciler.ensureAllResources(context.Background(), vmcp, nil, collector) if tt.expectError { assert.Error(t, err) @@ -1868,7 +1868,7 @@ func TestVirtualMCPServerContainerNeedsUpdate(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - needsUpdate := reconciler.containerNeedsUpdate(context.Background(), tt.deployment, tt.vmcp, []workloads.TypedWorkload{}) + needsUpdate := reconciler.containerNeedsUpdate(context.Background(), tt.deployment, tt.vmcp, nil, []workloads.TypedWorkload{}) assert.Equal(t, tt.expectedUpdate, needsUpdate) }) } @@ -2259,7 +2259,7 @@ func TestVirtualMCPServerDeploymentNeedsUpdate(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - needsUpdate := reconciler.deploymentNeedsUpdate(context.Background(), tt.deployment, vmcp, vmcpConfigChecksum, []workloads.TypedWorkload{}) + needsUpdate := reconciler.deploymentNeedsUpdate(context.Background(), tt.deployment, vmcp, vmcpConfigChecksum, nil, []workloads.TypedWorkload{}) assert.Equal(t, tt.expectedUpdate, needsUpdate) }) } @@ -2579,7 +2579,7 @@ func TestVirtualMCPServerEnsureDeployment_ConfigMapNotFound(t *testing.T) { Scheme: scheme, } - result, err := reconciler.ensureDeployment(context.Background(), vmcp, []workloads.TypedWorkload{}) + result, err := reconciler.ensureDeployment(context.Background(), vmcp, nil, []workloads.TypedWorkload{}) // Should requeue after 5 seconds when ConfigMap not found assert.NoError(t, err) @@ -2628,7 +2628,7 @@ func TestVirtualMCPServerEnsureDeployment_CreateDeployment(t *testing.T) { Scheme: scheme, } - result, err := reconciler.ensureDeployment(context.Background(), vmcp, []workloads.TypedWorkload{}) + result, err := reconciler.ensureDeployment(context.Background(), vmcp, nil, []workloads.TypedWorkload{}) assert.NoError(t, err) assert.Equal(t, ctrl.Result{}, result) @@ -2711,7 +2711,7 @@ func TestVirtualMCPServerEnsureDeployment_UpdateDeployment(t *testing.T) { Scheme: scheme, } - result, err := reconciler.ensureDeployment(context.Background(), vmcp, []workloads.TypedWorkload{}) + result, err := reconciler.ensureDeployment(context.Background(), vmcp, nil, []workloads.TypedWorkload{}) assert.NoError(t, err) assert.Equal(t, ctrl.Result{}, result) @@ -2807,7 +2807,7 @@ func TestVirtualMCPServerEnsureDeployment_NoUpdateNeeded(t *testing.T) { reconciler.Client = k8sClient - result, err := reconciler.ensureDeployment(context.Background(), vmcp, []workloads.TypedWorkload{}) + result, err := reconciler.ensureDeployment(context.Background(), vmcp, nil, []workloads.TypedWorkload{}) assert.NoError(t, err) assert.Equal(t, ctrl.Result{}, result) @@ -3216,7 +3216,7 @@ func TestVirtualMCPServerEnsureDeployment_ReplicaSync_SpecDriven(t *testing.T) { PlatformDetector: ctrlutil.NewSharedPlatformDetector(), } - result, err := r.ensureDeployment(context.Background(), vmcp, []workloads.TypedWorkload{}) + result, err := r.ensureDeployment(context.Background(), vmcp, nil, []workloads.TypedWorkload{}) require.NoError(t, err) assert.Equal(t, ctrl.Result{}, result) @@ -3295,7 +3295,7 @@ func TestVirtualMCPServerEnsureDeployment_ReplicaSync_NilPassthrough(t *testing. PlatformDetector: ctrlutil.NewSharedPlatformDetector(), } - result, err := r.ensureDeployment(context.Background(), vmcp, []workloads.TypedWorkload{}) + result, err := r.ensureDeployment(context.Background(), vmcp, nil, []workloads.TypedWorkload{}) require.NoError(t, err) assert.Equal(t, ctrl.Result{}, result) @@ -3312,7 +3312,7 @@ func TestVirtualMCPServerEnsureDeployment_ReplicaSync_NilPassthrough(t *testing. // mustBuildEnvVarsForVmcp is a test helper that calls buildEnvVarsForVmcp and panics on error. // All test VirtualMCPServers use anonymous auth (no OIDCConfigRef), so the error path is unreachable. func mustBuildEnvVarsForVmcp(r *VirtualMCPServerReconciler, vmcp *mcpv1alpha1.VirtualMCPServer) []corev1.EnvVar { - env, err := r.buildEnvVarsForVmcp(context.Background(), vmcp, []workloads.TypedWorkload{}) + env, err := r.buildEnvVarsForVmcp(context.Background(), vmcp, nil, []workloads.TypedWorkload{}) if err != nil { panic("mustBuildEnvVarsForVmcp: " + err.Error()) } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go index 13b2656be0..02fd044275 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go @@ -120,11 +120,14 @@ var vmcpDiscoveredRBACRules = []rbacv1.PolicyRule{ }, } -// deploymentForVirtualMCPServer returns a VirtualMCPServer Deployment object +// deploymentForVirtualMCPServer returns a VirtualMCPServer Deployment object. +// telemetryCfg is the already-fetched MCPTelemetryConfig (nil when not referenced), +// used for CA bundle volumes and OpenTelemetry env vars without redundant API calls. func (r *VirtualMCPServerReconciler) deploymentForVirtualMCPServer( ctx context.Context, vmcp *mcpv1alpha1.VirtualMCPServer, vmcpConfigChecksum string, + telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) *appsv1.Deployment { ls := labelsForVirtualMCPServer(vmcp.Name) @@ -136,7 +139,7 @@ func (r *VirtualMCPServerReconciler) deploymentForVirtualMCPServer( log.FromContext(ctx).Error(err, "Failed to build volumes for VirtualMCPServer") return nil } - env, err := r.buildEnvVarsForVmcp(ctx, vmcp, typedWorkloads) + env, err := r.buildEnvVarsForVmcp(ctx, vmcp, telemetryCfg, typedWorkloads) if err != nil { log.FromContext(ctx).Error(err, "Failed to build env vars for VirtualMCPServer") return nil @@ -151,6 +154,13 @@ func (r *VirtualMCPServerReconciler) deploymentForVirtualMCPServer( volumes = append(volumes, caVolumes...) volumeMounts = append(volumeMounts, caMounts...) + // Add telemetry CA bundle volumes from the pre-fetched MCPTelemetryConfig + if telemetryCfg != nil { + telVolumes, telMounts := ctrlutil.AddTelemetryCABundleVolumes(telemetryCfg) + volumes = append(volumes, telVolumes...) + volumeMounts = append(volumeMounts, telMounts...) + } + // Add embedded auth server volumes and env vars if configured (inline config) if vmcp.Spec.AuthServerConfig != nil { authServerVolumes, authServerMounts := ctrlutil.GenerateAuthServerVolumes(vmcp.Spec.AuthServerConfig) @@ -311,10 +321,12 @@ func (r *VirtualMCPServerReconciler) buildVolumesForVmcp( return volumeMounts, volumes, nil } -// buildEnvVarsForVmcp builds environment variables for the vmcp container +// buildEnvVarsForVmcp builds environment variables for the vmcp container. +// telemetryCfg is the already-fetched MCPTelemetryConfig (nil when not referenced). func (r *VirtualMCPServerReconciler) buildEnvVarsForVmcp( ctx context.Context, vmcp *mcpv1alpha1.VirtualMCPServer, + telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) ([]corev1.EnvVar, error) { env := []corev1.EnvVar{} @@ -346,6 +358,13 @@ func (r *VirtualMCPServerReconciler) buildEnvVarsForVmcp( // Mount Redis password secret when session storage provider is Redis. env = append(env, r.buildRedisPasswordEnvVar(vmcp)...) + // Mount OpenTelemetry env vars (resource attributes, sensitive headers) from the pre-fetched MCPTelemetryConfig + if telemetryCfg != nil && vmcp.Spec.TelemetryConfigRef != nil { + otelEnv := ctrlutil.GenerateOpenTelemetryEnvVarsFromRef( + telemetryCfg, vmcp.Spec.TelemetryConfigRef, vmcp.Name, vmcp.Namespace) + env = append(env, otelEnv...) + } + return ctrlutil.EnsureRequiredEnvVars(ctx, env), nil } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go index 7b0450c9f1..0ea0f05d7c 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go @@ -58,7 +58,7 @@ func TestDeploymentForVirtualMCPServer(t *testing.T) { PlatformDetector: ctrlutil.NewSharedPlatformDetector(), } - deployment := r.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", []workloads.TypedWorkload{}) + deployment := r.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) require.NotNil(t, deployment) assert.Equal(t, vmcp.Name, deployment.Name) @@ -121,7 +121,7 @@ func TestDeploymentForVirtualMCPServer_WithRedisPassword(t *testing.T) { PlatformDetector: ctrlutil.NewSharedPlatformDetector(), } - deployment := r.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", []workloads.TypedWorkload{}) + deployment := r.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) require.NotNil(t, deployment) require.Len(t, deployment.Spec.Template.Spec.Containers, 1) @@ -239,7 +239,7 @@ func TestBuildEnvVarsForVmcp(t *testing.T) { } r := &VirtualMCPServerReconciler{} - env, err := r.buildEnvVarsForVmcp(context.Background(), vmcp, []workloads.TypedWorkload{}) + env, err := r.buildEnvVarsForVmcp(context.Background(), vmcp, nil, []workloads.TypedWorkload{}) require.NoError(t, err) // Should have VMCP_NAME and VMCP_NAMESPACE @@ -536,7 +536,7 @@ func TestDeploymentNeedsUpdate(t *testing.T) { } // Test nil inputs - assert.True(t, r.deploymentNeedsUpdate(context.Background(), nil, nil, "", []workloads.TypedWorkload{})) + assert.True(t, r.deploymentNeedsUpdate(context.Background(), nil, nil, "", nil, []workloads.TypedWorkload{})) vmcp := &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ @@ -546,7 +546,7 @@ func TestDeploymentNeedsUpdate(t *testing.T) { } // Test with nil deployment - assert.True(t, r.deploymentNeedsUpdate(context.Background(), nil, vmcp, "checksum", []workloads.TypedWorkload{})) + assert.True(t, r.deploymentNeedsUpdate(context.Background(), nil, vmcp, "checksum", nil, []workloads.TypedWorkload{})) } // TestServiceNeedsUpdate tests service update detection diff --git a/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go b/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go index 3c2a88ad27..502bacacc7 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go @@ -87,8 +87,8 @@ func TestVirtualMCPServerPodTemplateSpecDeterministic(t *testing.T) { } // Generate deployment twice with same input - dep1 := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", []workloads.TypedWorkload{}) - dep2 := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", []workloads.TypedWorkload{}) + dep1 := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) + dep2 := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) // Both should be non-nil assert.NotNil(t, dep1, "First deployment should not be nil") @@ -162,7 +162,7 @@ func TestVirtualMCPServerPodTemplateSpecPreservesContainer(t *testing.T) { Scheme: scheme, } - dep := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", []workloads.TypedWorkload{}) + dep := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) // Verify deployment was created assert.NotNil(t, dep, "Deployment should not be nil") @@ -340,7 +340,7 @@ func TestVirtualMCPServerPodTemplateSpecResourceOverride(t *testing.T) { Scheme: scheme, } - dep := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", []workloads.TypedWorkload{}) + dep := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) require.NotNil(t, dep, "Deployment should not be nil") require.Len(t, dep.Spec.Template.Spec.Containers, 1, "Should have exactly one container") diff --git a/cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig.go b/cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig.go new file mode 100644 index 0000000000..afb3405f71 --- /dev/null +++ b/cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig.go @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/virtualmcpserverstatus" +) + +// handleTelemetryConfig validates and tracks the hash of the referenced MCPTelemetryConfig. +// It sets the TelemetryConfigRefValidated condition and triggers reconciliation when +// the telemetry configuration changes. +// Returns the fetched MCPTelemetryConfig so callers can pass it to downstream functions +// (converter, deployment builder) without redundant API calls. +// Uses the batched statusManager pattern instead of direct r.Status().Update(). +func (r *VirtualMCPServerReconciler) handleTelemetryConfig( + ctx context.Context, + vmcp *mcpv1alpha1.VirtualMCPServer, + statusManager virtualmcpserverstatus.StatusManager, +) (*mcpv1alpha1.MCPTelemetryConfig, error) { + ctxLogger := log.FromContext(ctx) + + if vmcp.Spec.TelemetryConfigRef == nil { + // No MCPTelemetryConfig referenced, clear any stored hash and remove stale condition. + if vmcp.Status.TelemetryConfigHash != "" { + statusManager.SetTelemetryConfigHash("") + } + statusManager.RemoveConditionsWithPrefix( + mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, []string{}) + return nil, nil + } + + // Get the referenced MCPTelemetryConfig + telemetryConfig, err := ctrlutil.GetTelemetryConfigForVirtualMCPServer(ctx, r.Client, vmcp) + if err != nil { + // Transient API error (not a NotFound) + statusManager.SetTelemetryConfigRefValidatedCondition( + mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefFetchError, + err.Error(), + metav1.ConditionFalse, + ) + return nil, err + } + + if telemetryConfig == nil { + // Resource genuinely does not exist + statusManager.SetTelemetryConfigRefValidatedCondition( + mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefNotFound, + fmt.Sprintf("MCPTelemetryConfig %s not found", vmcp.Spec.TelemetryConfigRef.Name), + metav1.ConditionFalse, + ) + return nil, fmt.Errorf("MCPTelemetryConfig %s not found", vmcp.Spec.TelemetryConfigRef.Name) + } + + // Validate that the MCPTelemetryConfig is valid (has Valid=True condition) + if err := telemetryConfig.Validate(); err != nil { + statusManager.SetTelemetryConfigRefValidatedCondition( + mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefInvalid, + fmt.Sprintf("MCPTelemetryConfig %s is invalid: %v", vmcp.Spec.TelemetryConfigRef.Name, err), + metav1.ConditionFalse, + ) + return nil, fmt.Errorf("MCPTelemetryConfig %s is invalid: %w", vmcp.Spec.TelemetryConfigRef.Name, err) + } + + // Set valid condition + statusManager.SetTelemetryConfigRefValidatedCondition( + mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid, + fmt.Sprintf("MCPTelemetryConfig %s is valid", vmcp.Spec.TelemetryConfigRef.Name), + metav1.ConditionTrue, + ) + + // Check if the MCPTelemetryConfig hash has changed + if vmcp.Status.TelemetryConfigHash != telemetryConfig.Status.ConfigHash { + ctxLogger.Info("MCPTelemetryConfig has changed, updating VirtualMCPServer", + "vmcp", vmcp.Name, + "telemetryConfig", telemetryConfig.Name, + "oldHash", vmcp.Status.TelemetryConfigHash, + "newHash", telemetryConfig.Status.ConfigHash) + + statusManager.SetTelemetryConfigHash(telemetryConfig.Status.ConfigHash) + } + + return telemetryConfig, nil +} + +// mapTelemetryConfigToVirtualMCPServer maps MCPTelemetryConfig changes to VirtualMCPServer reconciliation requests. +// Used by SetupWithManager to watch MCPTelemetryConfig resources. +func (r *VirtualMCPServerReconciler) mapTelemetryConfigToVirtualMCPServer( + ctx context.Context, obj client.Object, +) []reconcile.Request { + telemetryConfig, ok := obj.(*mcpv1alpha1.MCPTelemetryConfig) + if !ok { + return nil + } + + vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + if err := r.List(ctx, vmcpList, client.InNamespace(telemetryConfig.Namespace)); err != nil { + log.FromContext(ctx).Error(err, "Failed to list VirtualMCPServers for MCPTelemetryConfig watch") + return nil + } + + var requests []reconcile.Request + for _, vmcp := range vmcpList.Items { + if vmcp.Spec.TelemetryConfigRef != nil && + vmcp.Spec.TelemetryConfigRef.Name == telemetryConfig.Name { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: vmcp.Name, + Namespace: vmcp.Namespace, + }, + }) + } + } + + return requests +} diff --git a/cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig_test.go b/cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig_test.go new file mode 100644 index 0000000000..6e2f82035e --- /dev/null +++ b/cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig_test.go @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/virtualmcpserverstatus" +) + +func TestHandleTelemetryConfig_VirtualMCPServer(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + + tests := []struct { + name string + vmcp *mcpv1alpha1.VirtualMCPServer + telemetryConfig *mcpv1alpha1.MCPTelemetryConfig + expectError bool + expectTelCfgNil bool + expectedHash string + expectedCondType string + expectedCondStatus metav1.ConditionStatus + expectedCondReason string + expectHashCleared bool + expectCondRemoved bool + }{ + { + name: "nil ref clears hash and removes condition", + vmcp: &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{TelemetryConfigRef: nil}, + Status: mcpv1alpha1.VirtualMCPServerStatus{ + TelemetryConfigHash: "old-hash", + Conditions: []metav1.Condition{ + { + Type: mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + Status: metav1.ConditionTrue, + Reason: mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid, + }, + }, + }, + }, + expectError: false, + expectTelCfgNil: true, + expectHashCleared: true, + expectCondRemoved: true, + }, + { + name: "valid ref sets condition true and updates hash", + vmcp: &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "my-telemetry"}, + }, + }, + telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "my-telemetry", Namespace: "default"}, + Spec: newTelemetrySpec("https://otel-collector:4317", true, false), + Status: mcpv1alpha1.MCPTelemetryConfigStatus{ + ConfigHash: "abc123", + }, + }, + expectError: false, + expectedHash: "abc123", + expectedCondType: mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + expectedCondStatus: metav1.ConditionTrue, + expectedCondReason: mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid, + }, + { + name: "not found sets condition false", + vmcp: &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "missing"}, + }, + }, + expectError: true, + expectedCondType: mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + expectedCondStatus: metav1.ConditionFalse, + expectedCondReason: mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefNotFound, + }, + { + name: "invalid config sets condition false", + vmcp: &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "invalid-telemetry"}, + }, + }, + // Spec with endpoint but no tracing/metrics enabled -> Validate() fails + telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "invalid-telemetry", Namespace: "default"}, + Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + Enabled: true, + Endpoint: "https://otel-collector:4317", + Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: false}, + Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{Enabled: false}, + }, + }, + }, + expectError: true, + expectedCondType: mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + expectedCondStatus: metav1.ConditionFalse, + expectedCondReason: mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefInvalid, + }, + { + name: "hash change triggers update", + vmcp: &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "my-telemetry"}, + }, + Status: mcpv1alpha1.VirtualMCPServerStatus{ + TelemetryConfigHash: "old-hash", + }, + }, + telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "my-telemetry", Namespace: "default"}, + Spec: newTelemetrySpec("https://otel-collector:4317", true, false), + Status: mcpv1alpha1.MCPTelemetryConfigStatus{ + ConfigHash: "new-hash", + }, + }, + expectError: false, + expectedHash: "new-hash", + expectedCondType: mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + expectedCondStatus: metav1.ConditionTrue, + expectedCondReason: mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + builder := fake.NewClientBuilder().WithScheme(scheme) + if tt.telemetryConfig != nil { + builder = builder.WithObjects(tt.telemetryConfig) + } + fakeClient := builder.Build() + + reconciler := &VirtualMCPServerReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + statusManager := virtualmcpserverstatus.NewStatusManager(tt.vmcp) + telCfg, err := reconciler.handleTelemetryConfig(ctx, tt.vmcp, statusManager) + + if tt.expectError { + require.Error(t, err) + assert.Nil(t, telCfg, "telemetry config should be nil on error") + } else { + require.NoError(t, err) + } + + if tt.expectTelCfgNil { + assert.Nil(t, telCfg, "telemetry config should be nil") + } + + // Apply collected status changes to check assertions + status := &tt.vmcp.Status + statusManager.UpdateStatus(ctx, status) + + if tt.expectHashCleared { + assert.Empty(t, status.TelemetryConfigHash, "hash should be cleared") + } + + if tt.expectCondRemoved { + for _, c := range status.Conditions { + assert.NotEqual(t, + mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + c.Type, "stale TelemetryConfigRefValidated condition should be removed") + } + } + + if tt.expectedCondType != "" { + var found bool + for _, c := range status.Conditions { + if c.Type == tt.expectedCondType { + found = true + assert.Equal(t, tt.expectedCondStatus, c.Status) + assert.Equal(t, tt.expectedCondReason, c.Reason) + break + } + } + assert.True(t, found, "expected condition %s not found", tt.expectedCondType) + } + + if tt.expectedHash != "" { + assert.Equal(t, tt.expectedHash, status.TelemetryConfigHash) + } + }) + } +} + +func TestMapTelemetryConfigToVirtualMCPServer(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + + vmcp1 := &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "vmcp1", Namespace: "default"}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "shared-telemetry"}, + }, + } + vmcp2 := &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "vmcp2", Namespace: "default"}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "other-telemetry"}, + }, + } + vmcp3 := &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "vmcp3", Namespace: "default"}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{}, // no ref + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(vmcp1, vmcp2, vmcp3). + Build() + + reconciler := &VirtualMCPServerReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + ctx := t.Context() + + telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "shared-telemetry", Namespace: "default"}, + } + + requests := reconciler.mapTelemetryConfigToVirtualMCPServer(ctx, telemetryConfig) + + require.Len(t, requests, 1) + assert.Equal(t, types.NamespacedName{Name: "vmcp1", Namespace: "default"}, requests[0].NamespacedName) +} diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go index 15e5cf69bf..5fe3572e2b 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go @@ -28,11 +28,14 @@ import ( // ensureVmcpConfigConfigMap ensures the vmcp Config ConfigMap exists and is up to date. // workloadInfos is the list of workloads in the group, passed in to ensure consistency // across multiple calls that need the same workload list. +// telemetryCfg is the already-fetched MCPTelemetryConfig (nil when not referenced), +// passed through from handleConfigRefs to avoid redundant API calls. // statusManager is used to set auth config conditions for any conversion failures. func (r *VirtualMCPServerReconciler) ensureVmcpConfigConfigMap( ctx context.Context, vmcp *mcpv1alpha1.VirtualMCPServer, typedWorkloads []workloads.TypedWorkload, + telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, statusManager virtualmcpserverstatus.StatusManager, ) error { // Create OIDC resolver and converter for CRD-to-config transformation @@ -41,7 +44,7 @@ func (r *VirtualMCPServerReconciler) ensureVmcpConfigConfigMap( if err != nil { return fmt.Errorf("failed to create vmcp converter: %w", err) } - config, authServerRC, err := converter.Convert(ctx, vmcp) + config, authServerRC, err := converter.Convert(ctx, vmcp, telemetryCfg) if err != nil { return fmt.Errorf("failed to create vmcp Config from VirtualMCPServer: %w", err) } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go index 4ab18a7812..a27acd11d4 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go @@ -85,7 +85,7 @@ func TestCreateVmcpConfigFromVirtualMCPServer(t *testing.T) { t.Parallel() converter := newTestConverter(t, newNoOpMockResolver(t)) - config, _, err := converter.Convert(context.Background(), tt.vmcp) + config, _, err := converter.Convert(context.Background(), tt.vmcp, nil) require.NoError(t, err) assert.NotNil(t, config) @@ -156,7 +156,7 @@ func TestConvertOutgoingAuth(t *testing.T) { } converter := newTestConverter(t, newNoOpMockResolver(t)) - config, _, err := converter.Convert(context.Background(), vmcpServer) + config, _, err := converter.Convert(context.Background(), vmcpServer, nil) require.NoError(t, err) require.NotNil(t, config.OutgoingAuth) @@ -247,7 +247,7 @@ func TestConvertBackendAuthConfig(t *testing.T) { converter = newTestConverter(t, newNoOpMockResolver(t)) } - config, _, err := converter.Convert(context.Background(), vmcpServer) + config, _, err := converter.Convert(context.Background(), vmcpServer, nil) require.NoError(t, err) require.NotNil(t, config.OutgoingAuth) @@ -341,7 +341,7 @@ func TestConvertAggregation(t *testing.T) { } converter := newTestConverter(t, newNoOpMockResolver(t)) - config, _, err := converter.Convert(context.Background(), vmcpServer) + config, _, err := converter.Convert(context.Background(), vmcpServer, nil) require.NoError(t, err) require.NotNil(t, config.Aggregation) @@ -434,7 +434,7 @@ func TestConvertCompositeTools(t *testing.T) { } converter := newTestConverter(t, newNoOpMockResolver(t)) - config, _, err := converter.Convert(context.Background(), vmcpServer) + config, _, err := converter.Convert(context.Background(), vmcpServer, nil) require.NoError(t, err) tools := config.CompositeTools @@ -495,7 +495,7 @@ func TestEnsureVmcpConfigConfigMap(t *testing.T) { // Create a status collector (we don't validate status in this test) statusCollector := virtualmcpserverstatus.NewStatusManager(testVmcp) - err = r.ensureVmcpConfigConfigMap(ctx, testVmcp, workloadNames, statusCollector) + err = r.ensureVmcpConfigConfigMap(ctx, testVmcp, workloadNames, nil, statusCollector) require.NoError(t, err) // Verify ConfigMap was created @@ -1074,7 +1074,7 @@ func TestYAMLMarshalingDeterminism(t *testing.T) { results := make([]string, iterations) for i := 0; i < iterations; i++ { - cfg, _, err := converter.Convert(context.Background(), testVmcp) + cfg, _, err := converter.Convert(context.Background(), testVmcp, nil) require.NoError(t, err) // Marshal the Config to YAML. @@ -1194,7 +1194,7 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_EndToEnd(t *testing.T) { // Test the ensureVmcpConfigConfigMap function statusCollector := virtualmcpserverstatus.NewStatusManager(vmcpServer) - err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, statusCollector) + err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, nil, statusCollector) require.NoError(t, err, "should successfully create ConfigMap with referenced composite tool") // Verify ConfigMap was created @@ -1321,7 +1321,7 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_MergeInlineAndReferenced(t // Test the ensureVmcpConfigConfigMap function statusCollector := virtualmcpserverstatus.NewStatusManager(vmcpServer) - err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, statusCollector) + err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, nil, statusCollector) require.NoError(t, err, "should successfully merge inline and referenced tools") // Verify ConfigMap was created @@ -1405,7 +1405,7 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_NotFound(t *testing.T) { // Test should fail with not found error statusCollector := virtualmcpserverstatus.NewStatusManager(vmcpServer) - err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, statusCollector) + err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, nil, statusCollector) require.Error(t, err, "should fail when referenced tool doesn't exist") assert.Contains(t, err.Error(), "not found", "error should mention not found") } @@ -1464,7 +1464,7 @@ func TestConfigMapContent_DynamicMode(t *testing.T) { // Create ConfigMap statusCollector := virtualmcpserverstatus.NewStatusManager(vmcpServer) - err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, statusCollector) + err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, nil, statusCollector) require.NoError(t, err) // Verify ConfigMap was created @@ -1571,7 +1571,7 @@ func TestConfigMapContent_StaticMode_InlineOverrides(t *testing.T) { // Create ConfigMap statusCollector := virtualmcpserverstatus.NewStatusManager(vmcpServer) - err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, statusCollector) + err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, nil, statusCollector) require.NoError(t, err) // Verify ConfigMap was created @@ -1687,7 +1687,7 @@ func TestConfigMapContent_StaticModeWithDiscovery(t *testing.T) { // Create ConfigMap statusCollector := virtualmcpserverstatus.NewStatusManager(vmcpServer) - err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, statusCollector) + err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, nil, statusCollector) require.NoError(t, err) // Verify ConfigMap was created @@ -1931,7 +1931,7 @@ func TestOptimizerEmbeddingServiceURL(t *testing.T) { require.NoError(t, err) statusManager := virtualmcpserverstatus.NewStatusManager(tt.vmcp) - err = reconciler.ensureVmcpConfigConfigMap(ctx, tt.vmcp, workloadNames, statusManager) + err = reconciler.ensureVmcpConfigConfigMap(ctx, tt.vmcp, workloadNames, nil, statusManager) require.NoError(t, err) // Read back the ConfigMap and parse the config @@ -2055,7 +2055,7 @@ func TestConfigMapContent_SessionStorage(t *testing.T) { require.NoError(t, err) statusManager := virtualmcpserverstatus.NewStatusManager(vmcpServer) - err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, statusManager) + err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, nil, statusManager) require.NoError(t, err) configMap := &corev1.ConfigMap{} @@ -2181,7 +2181,7 @@ func TestEnsureVmcpConfigConfigMap_AuthServerIntegrationValidationError(t *testi ).Times(1) mockStatus.EXPECT().SetObservedGeneration(testVmcp.Generation).Times(1) - err = r.ensureVmcpConfigConfigMap(ctx, testVmcp, workloadNames, mockStatus) + err = r.ensureVmcpConfigConfigMap(ctx, testVmcp, workloadNames, nil, mockStatus) // Verify the error is a SpecValidationError with the expected message. var specErr *SpecValidationError diff --git a/cmd/thv-operator/pkg/controllerutil/config.go b/cmd/thv-operator/pkg/controllerutil/config.go index a665de020a..12ab9014f0 100644 --- a/cmd/thv-operator/pkg/controllerutil/config.go +++ b/cmd/thv-operator/pkg/controllerutil/config.go @@ -220,6 +220,34 @@ func GetTelemetryConfigForMCPRemoteProxy( return telemetryConfig, nil } +// GetTelemetryConfigForVirtualMCPServer fetches the MCPTelemetryConfig referenced by the VirtualMCPServer. +// Returns (nil, nil) when TelemetryConfigRef is nil or the resource is not found. +// Returns (nil, err) only for transient API errors so callers can distinguish +// "config missing" from "API unavailable". +func GetTelemetryConfigForVirtualMCPServer( + ctx context.Context, + c client.Client, + vmcp *mcpv1alpha1.VirtualMCPServer, +) (*mcpv1alpha1.MCPTelemetryConfig, error) { + if vmcp.Spec.TelemetryConfigRef == nil { + return nil, nil + } + + telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{} + err := c.Get(ctx, types.NamespacedName{ + Name: vmcp.Spec.TelemetryConfigRef.Name, + Namespace: vmcp.Namespace, + }, telemetryConfig) + if errors.IsNotFound(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get MCPTelemetryConfig %s: %w", vmcp.Spec.TelemetryConfigRef.Name, err) + } + + return telemetryConfig, nil +} + // GetExternalAuthConfigByName is a generic helper for fetching MCPExternalAuthConfig by name func GetExternalAuthConfigByName( ctx context.Context, diff --git a/cmd/thv-operator/pkg/controllerutil/config_test.go b/cmd/thv-operator/pkg/controllerutil/config_test.go index eeeb33d68d..3b13e23dc6 100644 --- a/cmd/thv-operator/pkg/controllerutil/config_test.go +++ b/cmd/thv-operator/pkg/controllerutil/config_test.go @@ -554,3 +554,98 @@ func TestGetTelemetryConfigForMCPRemoteProxy(t *testing.T) { }) } } + +func TestGetTelemetryConfigForVirtualMCPServer(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + + tests := []struct { + name string + vmcp *mcpv1alpha1.VirtualMCPServer + telemetryConfig *mcpv1alpha1.MCPTelemetryConfig + expectNil bool + expectError bool + expectedName string + }{ + { + name: "nil ref returns nil without error", + vmcp: &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{TelemetryConfigRef: nil}, + }, + expectNil: true, + expectError: false, + }, + { + name: "fetches referenced config", + vmcp: &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "my-telemetry"}, + }, + }, + telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "my-telemetry", Namespace: "default"}, + }, + expectNil: false, + expectError: false, + expectedName: "my-telemetry", + }, + { + name: "not found returns nil without error", + vmcp: &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "missing"}, + }, + }, + expectNil: true, + expectError: false, + }, + { + name: "cross-namespace returns nil (not found)", + vmcp: &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "namespace-b"}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "shared-config"}, + }, + }, + telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "shared-config", Namespace: "namespace-a"}, + }, + expectNil: true, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + builder := fake.NewClientBuilder().WithScheme(scheme) + if tt.telemetryConfig != nil { + builder = builder.WithObjects(tt.telemetryConfig) + } + fakeClient := builder.Build() + + result, err := GetTelemetryConfigForVirtualMCPServer(ctx, fakeClient, tt.vmcp) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, result) + return + } + + assert.NoError(t, err) + if tt.expectNil { + assert.Nil(t, result) + } else { + require.NotNil(t, result) + assert.Equal(t, tt.expectedName, result.Name) + } + }) + } +} diff --git a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go index e6bbd6a8bb..5cf066a6b6 100644 --- a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go +++ b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go @@ -19,15 +19,16 @@ import ( // and applies them in a single batch update at the end. // It implements the StatusManager interface. type StatusCollector struct { - vmcp *mcpv1alpha1.VirtualMCPServer - hasChanges bool - phase *mcpv1alpha1.VirtualMCPServerPhase - message *string - url *string - observedGeneration *int64 - oidcConfigHash *string - conditions map[string]metav1.Condition - discoveredBackends []mcpv1alpha1.DiscoveredBackend + vmcp *mcpv1alpha1.VirtualMCPServer + hasChanges bool + phase *mcpv1alpha1.VirtualMCPServerPhase + message *string + url *string + observedGeneration *int64 + oidcConfigHash *string + telemetryConfigHash *string + conditions map[string]metav1.Condition + discoveredBackends []mcpv1alpha1.DiscoveredBackend } // NewStatusManager creates a new StatusManager for the given VirtualMCPServer resource. @@ -79,6 +80,17 @@ func (s *StatusCollector) SetOIDCConfigHash(hash string) { s.hasChanges = true } +// SetTelemetryConfigHash sets the telemetry config hash to be updated. +func (s *StatusCollector) SetTelemetryConfigHash(hash string) { + s.telemetryConfigHash = &hash + s.hasChanges = true +} + +// SetTelemetryConfigRefValidatedCondition sets the TelemetryConfigRefValidated condition. +func (s *StatusCollector) SetTelemetryConfigRefValidatedCondition(reason, message string, status metav1.ConditionStatus) { + s.SetCondition(mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, reason, message, status) +} + // SetGroupRefValidatedCondition sets the GroupRef validation condition. func (s *StatusCollector) SetGroupRefValidatedCondition(reason, message string, status metav1.ConditionStatus) { s.SetCondition(mcpv1alpha1.ConditionTypeVirtualMCPServerGroupRefValidated, reason, message, status) @@ -181,6 +193,11 @@ func (s *StatusCollector) UpdateStatus(ctx context.Context, vmcpStatus *mcpv1alp vmcpStatus.OIDCConfigHash = *s.oidcConfigHash } + // Apply telemetry config hash change + if s.telemetryConfigHash != nil { + vmcpStatus.TelemetryConfigHash = *s.telemetryConfigHash + } + // Apply condition changes for _, condition := range s.conditions { if condition.Status == "" { @@ -208,6 +225,7 @@ func (s *StatusCollector) UpdateStatus(ctx context.Context, vmcpStatus *mcpv1alp "phase", s.phase, "message", s.message, "oidcConfigHash", s.oidcConfigHash, + "telemetryConfigHash", s.telemetryConfigHash, "conditionsCount", len(s.conditions), "discoveredBackendsCount", len(s.discoveredBackends)) return true diff --git a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector_test.go b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector_test.go index 3707a25b8c..f1ab8fac6f 100644 --- a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector_test.go +++ b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector_test.go @@ -304,3 +304,53 @@ func TestStatusCollector_RemoveConditionsWithPrefix(t *testing.T) { assert.True(t, foundBackend1, "backend-1 condition should remain") assert.True(t, foundReady, "Ready condition should remain") } + +func TestStatusCollector_SetTelemetryConfigHash(t *testing.T) { + t.Parallel() + + vmcp := &mcpv1alpha1.VirtualMCPServer{} + collector := NewStatusManager(vmcp) + + collector.SetTelemetryConfigHash("tel-hash-456") + + status := &mcpv1alpha1.VirtualMCPServerStatus{} + hasUpdates := collector.UpdateStatus(context.Background(), status) + + assert.True(t, hasUpdates) + assert.Equal(t, "tel-hash-456", status.TelemetryConfigHash) +} + +func TestStatusCollector_SetTelemetryConfigHash_Clear(t *testing.T) { + t.Parallel() + + vmcp := &mcpv1alpha1.VirtualMCPServer{} + collector := NewStatusManager(vmcp) + + collector.SetTelemetryConfigHash("") + + status := &mcpv1alpha1.VirtualMCPServerStatus{TelemetryConfigHash: "old-hash"} + hasUpdates := collector.UpdateStatus(context.Background(), status) + + assert.True(t, hasUpdates) + assert.Empty(t, status.TelemetryConfigHash) +} + +func TestStatusCollector_SetTelemetryConfigRefValidatedCondition(t *testing.T) { + t.Parallel() + + vmcp := &mcpv1alpha1.VirtualMCPServer{} + collector := NewStatusManager(vmcp) + + collector.SetTelemetryConfigRefValidatedCondition( + "TelemetryConfigRefValid", "MCPTelemetryConfig is valid", metav1.ConditionTrue) + + status := &mcpv1alpha1.VirtualMCPServerStatus{} + hasUpdates := collector.UpdateStatus(context.Background(), status) + + assert.True(t, hasUpdates) + assert.Len(t, status.Conditions, 1) + assert.Equal(t, mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, status.Conditions[0].Type) + assert.Equal(t, metav1.ConditionTrue, status.Conditions[0].Status) + assert.Equal(t, "TelemetryConfigRefValid", status.Conditions[0].Reason) + assert.Equal(t, "MCPTelemetryConfig is valid", status.Conditions[0].Message) +} diff --git a/cmd/thv-operator/pkg/virtualmcpserverstatus/mocks/mock_collector.go b/cmd/thv-operator/pkg/virtualmcpserverstatus/mocks/mock_collector.go index 889486a63f..3f944a13d8 100644 --- a/cmd/thv-operator/pkg/virtualmcpserverstatus/mocks/mock_collector.go +++ b/cmd/thv-operator/pkg/virtualmcpserverstatus/mocks/mock_collector.go @@ -210,6 +210,30 @@ func (mr *MockStatusManagerMockRecorder) SetReadyCondition(reason, message, stat return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetReadyCondition", reflect.TypeOf((*MockStatusManager)(nil).SetReadyCondition), reason, message, status) } +// SetTelemetryConfigHash mocks base method. +func (m *MockStatusManager) SetTelemetryConfigHash(hash string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetTelemetryConfigHash", hash) +} + +// SetTelemetryConfigHash indicates an expected call of SetTelemetryConfigHash. +func (mr *MockStatusManagerMockRecorder) SetTelemetryConfigHash(hash any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTelemetryConfigHash", reflect.TypeOf((*MockStatusManager)(nil).SetTelemetryConfigHash), hash) +} + +// SetTelemetryConfigRefValidatedCondition mocks base method. +func (m *MockStatusManager) SetTelemetryConfigRefValidatedCondition(reason, message string, status v1.ConditionStatus) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetTelemetryConfigRefValidatedCondition", reason, message, status) +} + +// SetTelemetryConfigRefValidatedCondition indicates an expected call of SetTelemetryConfigRefValidatedCondition. +func (mr *MockStatusManagerMockRecorder) SetTelemetryConfigRefValidatedCondition(reason, message, status any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTelemetryConfigRefValidatedCondition", reflect.TypeOf((*MockStatusManager)(nil).SetTelemetryConfigRefValidatedCondition), reason, message, status) +} + // SetURL mocks base method. func (m *MockStatusManager) SetURL(url string) { m.ctrl.T.Helper() diff --git a/cmd/thv-operator/pkg/virtualmcpserverstatus/types.go b/cmd/thv-operator/pkg/virtualmcpserverstatus/types.go index 0820405dfb..df519115c2 100644 --- a/cmd/thv-operator/pkg/virtualmcpserverstatus/types.go +++ b/cmd/thv-operator/pkg/virtualmcpserverstatus/types.go @@ -65,6 +65,12 @@ type StatusManager interface { // SetAuthServerConfigValidatedCondition sets the AuthServerConfigValidated condition SetAuthServerConfigValidatedCondition(reason, message string, status metav1.ConditionStatus) + // SetTelemetryConfigHash sets the telemetry config hash for change detection + SetTelemetryConfigHash(hash string) + + // SetTelemetryConfigRefValidatedCondition sets the TelemetryConfigRefValidated condition + SetTelemetryConfigRefValidatedCondition(reason, message string, status metav1.ConditionStatus) + // SetDiscoveredBackends sets the discovered backends list SetDiscoveredBackends(backends []mcpv1alpha1.DiscoveredBackend) diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter.go b/cmd/thv-operator/pkg/vmcpconfig/converter.go index 6783e53186..812f78abd3 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go @@ -19,6 +19,7 @@ import ( "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/spectoconfig" "github.com/stacklok/toolhive/pkg/authserver" + "github.com/stacklok/toolhive/pkg/telemetry" "github.com/stacklok/toolhive/pkg/vmcp/auth/converters" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" @@ -68,11 +69,16 @@ func NewConverter(oidcResolver oidc.Resolver, k8sClient client.Client) (*Convert // passed through without explicit mapping. Only fields that require special handling // (auth, aggregation, composite tools, telemetry) are explicitly converted below. // +// telemetryCfg is the already-fetched MCPTelemetryConfig (nil when not referenced). +// It is passed in by the controller to avoid redundant API calls; normalizeTelemetry +// uses it directly instead of re-fetching. +// // The returned Config is the serializable vMCP config. The RunConfig is non-nil only // when AuthServerConfig is set on the VirtualMCPServer spec. func (c *Converter) Convert( ctx context.Context, vmcp *mcpv1alpha1.VirtualMCPServer, + telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, ) (*vmcpconfig.Config, *authserver.RunConfig, error) { // Start with a deep copy of the embedded config for automatic field passthrough. // This ensures new fields added to config.Config are automatically included @@ -92,35 +98,18 @@ func (c *Converter) Convert( } // Convert OutgoingAuth - always set with defaults if not specified - if vmcp.Spec.OutgoingAuth != nil { - outgoingAuth, err := c.convertOutgoingAuth(ctx, vmcp) - if err != nil { - return nil, nil, fmt.Errorf("failed to convert outgoing auth: %w", err) - } - config.OutgoingAuth = outgoingAuth - } else { - // Provide default outgoing auth config - config.OutgoingAuth = &vmcpconfig.OutgoingAuthConfig{ - Source: "discovered", // Default to discovered mode - } + outgoingAuth, err := c.convertOutgoingAuthWithDefaults(ctx, vmcp) + if err != nil { + return nil, nil, fmt.Errorf("failed to convert outgoing auth: %w", err) } + config.OutgoingAuth = outgoingAuth // Convert Aggregation - always set with defaults if not specified - if vmcp.Spec.Config.Aggregation != nil { - agg, err := c.convertAggregation(ctx, vmcp) - if err != nil { - return nil, nil, fmt.Errorf("failed to convert aggregation config: %w", err) - } - config.Aggregation = agg - } else { - // Provide default aggregation config with prefix conflict resolution - config.Aggregation = &vmcpconfig.AggregationConfig{ - ConflictResolution: conflictResolutionPrefix, // Default to prefix strategy - ConflictResolutionConfig: &vmcpconfig.ConflictResolutionConfig{ - PrefixFormat: "{workload}_", // Default prefix format - }, - } + agg, err := c.convertAggregationWithDefaults(ctx, vmcp) + if err != nil { + return nil, nil, fmt.Errorf("failed to convert aggregation config: %w", err) } + config.Aggregation = agg // Convert CompositeTools (inline and referenced) compositeTools, err := c.convertAllCompositeTools(ctx, vmcp) @@ -134,11 +123,10 @@ func (c *Converter) Convert( // Use Operational from spec.config directly config.Operational = vmcp.Spec.Config.Operational - // Normalize telemetry config using the shared spectoconfig normalization logic. - // This applies runtime defaults and normalization (endpoint prefix stripping, service name defaults). - // Note: Most defaults (e.g., SamplingRate="0.05", TracingEnabled=false, MetricsEnabled=false) - // are handled by kubebuilder annotations in pkg/telemetry/config.go and applied by the API server. - config.Telemetry = spectoconfig.NormalizeTelemetryConfig(vmcp.Spec.Config.Telemetry, vmcp.Name) + // Normalize telemetry config: prefer TelemetryConfigRef (shared MCPTelemetryConfig resource), + // fall back to inline config.telemetry. These are mutually exclusive (enforced by CEL validation). + normalizedTelemetry := c.normalizeTelemetry(ctx, vmcp, telemetryCfg) + config.Telemetry = normalizedTelemetry if vmcp.Spec.Config.Audit != nil && vmcp.Spec.Config.Audit.Enabled { config.Audit = vmcp.Spec.Config.Audit @@ -339,6 +327,30 @@ func mapResolvedOIDCToVmcpConfigFromRef( return config } +// normalizeTelemetry resolves and normalizes the telemetry config from either +// a pre-fetched MCPTelemetryConfig or the inline config.telemetry field. +// telemetryCfg is the already-validated MCPTelemetryConfig passed in by the controller +// (nil when TelemetryConfigRef is not set or the resource was not found). +func (*Converter) normalizeTelemetry( + ctx context.Context, + vmcp *mcpv1alpha1.VirtualMCPServer, + telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, +) *telemetry.Config { + if vmcp.Spec.TelemetryConfigRef != nil && telemetryCfg != nil { + return spectoconfig.NormalizeMCPTelemetryConfig( + &telemetryCfg.Spec, vmcp.Spec.TelemetryConfigRef.ServiceName, vmcp.Name) + } + // Deprecated inline path: config.telemetry is deprecated in favor of spec.telemetryConfigRef. + // Log at debug level to avoid log flooding on every reconcile. + if vmcp.Spec.Config.Telemetry != nil { + log.FromContext(ctx).V(1).Info( + "config.telemetry is deprecated; migrate to spec.telemetryConfigRef", + "vmcp", vmcp.Name, + ) + } + return spectoconfig.NormalizeTelemetryConfig(vmcp.Spec.Config.Telemetry, vmcp.Name) +} + // convertSessionStorage populates SessionStorage from the VirtualMCPServer spec. // spec.sessionStorage is the authoritative source; always overwrite whatever // the DeepCopy brought in from spec.config.sessionStorage. @@ -411,6 +423,35 @@ func deriveScopesSupported(config *vmcpconfig.Config) []string { return config.IncomingAuth.OIDC.Scopes } +// convertOutgoingAuthWithDefaults converts OutgoingAuthConfig or returns defaults. +func (c *Converter) convertOutgoingAuthWithDefaults( + ctx context.Context, + vmcp *mcpv1alpha1.VirtualMCPServer, +) (*vmcpconfig.OutgoingAuthConfig, error) { + if vmcp.Spec.OutgoingAuth != nil { + return c.convertOutgoingAuth(ctx, vmcp) + } + return &vmcpconfig.OutgoingAuthConfig{ + Source: "discovered", // Default to discovered mode + }, nil +} + +// convertAggregationWithDefaults converts AggregationConfig or returns defaults. +func (c *Converter) convertAggregationWithDefaults( + ctx context.Context, + vmcp *mcpv1alpha1.VirtualMCPServer, +) (*vmcpconfig.AggregationConfig, error) { + if vmcp.Spec.Config.Aggregation != nil { + return c.convertAggregation(ctx, vmcp) + } + return &vmcpconfig.AggregationConfig{ + ConflictResolution: conflictResolutionPrefix, + ConflictResolutionConfig: &vmcpconfig.ConflictResolutionConfig{ + PrefixFormat: "{workload}_", + }, + }, nil +} + // convertOutgoingAuth converts OutgoingAuthConfig from CRD to vmcp config func (c *Converter) convertOutgoingAuth( ctx context.Context, diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go index a849fb1e1a..83d4d7e6d9 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go @@ -193,7 +193,7 @@ func TestConverter_OIDCResolution(t *testing.T) { converter := newTestConverter(t, mockResolver) ctx := log.IntoContext(context.Background(), logr.Discard()) - config, _, err := converter.Convert(ctx, newTestVMCPServer(tt.oidcConfig)) + config, _, err := converter.Convert(ctx, newTestVMCPServer(tt.oidcConfig), nil) tt.validate(t, config, err) }) @@ -240,7 +240,7 @@ func TestConverter_CompositeToolsPassThrough(t *testing.T) { converter := newTestConverter(t, newNoOpMockResolver(t)) ctx := log.IntoContext(context.Background(), logr.Discard()) - config, _, err := converter.Convert(ctx, vmcpServer) + config, _, err := converter.Convert(ctx, vmcpServer, nil) require.NoError(t, err) require.NotNil(t, config) @@ -392,7 +392,7 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { converter := newTestConverter(t, mockResolver) ctx := log.IntoContext(context.Background(), logr.Discard()) - config, _, err := converter.Convert(ctx, vmcpServer) + config, _, err := converter.Convert(ctx, vmcpServer, nil) require.NoError(t, err, tt.description) require.NotNil(t, config, tt.description) @@ -801,7 +801,7 @@ func TestConverter_CompositeToolRefs(t *testing.T) { require.NoError(t, err) ctx := log.IntoContext(context.Background(), logr.Discard()) - config, _, err := converter.Convert(ctx, tt.vmcp) + config, _, err := converter.Convert(ctx, tt.vmcp, nil) if tt.expectError { require.Error(t, err) @@ -912,7 +912,7 @@ func TestConverter_CompositeToolDefinitionFieldsPreserved(t *testing.T) { require.NoError(t, err) ctx := log.IntoContext(context.Background(), logr.Discard()) - cfg, _, err := converter.Convert(ctx, vmcpServer) + cfg, _, err := converter.Convert(ctx, vmcpServer, nil) require.NoError(t, err) require.NotNil(t, cfg) require.Len(t, cfg.CompositeTools, 1) @@ -1413,7 +1413,7 @@ func TestConvert_MCPToolConfigFailClosed(t *testing.T) { converter := newTestConverter(t, newNoOpMockResolver(t)) converter.k8sClient = k8sClient - config, _, err := converter.Convert(ctx, tt.vmcp) + config, _, err := converter.Convert(ctx, tt.vmcp, nil) if tt.expectError { require.Error(t, err) @@ -1472,7 +1472,7 @@ func TestConverter_TelemetryConfigPreserved(t *testing.T) { converter := newTestConverter(t, newNoOpMockResolver(t)) ctx := log.IntoContext(context.Background(), logr.Discard()) - config, _, err := converter.Convert(ctx, vmcp) + config, _, err := converter.Convert(ctx, vmcp, nil) require.NoError(t, err) require.NotNil(t, config) @@ -1575,7 +1575,7 @@ func TestConverter_TelemetryDefaults(t *testing.T) { converter := newTestConverter(t, newNoOpMockResolver(t)) ctx := log.IntoContext(context.Background(), logr.Discard()) - config, _, err := converter.Convert(ctx, vmcp) + config, _, err := converter.Convert(ctx, vmcp, nil) require.NoError(t, err) require.NotNil(t, config) @@ -1607,7 +1607,7 @@ func TestConverter_TelemetryNil(t *testing.T) { converter := newTestConverter(t, newNoOpMockResolver(t)) ctx := log.IntoContext(context.Background(), logr.Discard()) - config, _, err := converter.Convert(ctx, vmcp) + config, _, err := converter.Convert(ctx, vmcp, nil) require.NoError(t, err) require.NotNil(t, config) assert.Nil(t, config.Telemetry, "Telemetry should be nil when not configured") @@ -1672,7 +1672,7 @@ func TestConverter_TelemetryEndpointPrefixStripping(t *testing.T) { converter := newTestConverter(t, newNoOpMockResolver(t)) ctx := log.IntoContext(context.Background(), logr.Discard()) - config, _, err := converter.Convert(ctx, vmcp) + config, _, err := converter.Convert(ctx, vmcp, nil) require.NoError(t, err) require.NotNil(t, config) require.NotNil(t, config.Telemetry) @@ -1751,7 +1751,7 @@ func TestConverter_SessionStorage(t *testing.T) { converter := newTestConverter(t, newNoOpMockResolver(t)) ctx := log.IntoContext(context.Background(), logr.Discard()) - config, _, err := converter.Convert(ctx, vmcpServer) + config, _, err := converter.Convert(ctx, vmcpServer, nil) require.NoError(t, err) require.NotNil(t, config) @@ -1926,7 +1926,7 @@ func TestConvert_AuthServerConfigIntegration(t *testing.T) { } ctx := log.IntoContext(context.Background(), logr.Discard()) - config, runConfig, err := converter.Convert(ctx, vmcp) + config, runConfig, err := converter.Convert(ctx, vmcp, nil) require.NoError(t, err) require.NotNil(t, config) @@ -1947,3 +1947,55 @@ func TestConvert_AuthServerConfigIntegration(t *testing.T) { assert.Equal(t, controllerutil.UpstreamClientSecretEnvVar+"_CORP_IDP", runConfig.Upstreams[0].OIDCConfig.ClientSecretEnvVar) } + +// TestConverter_TelemetryConfigRef tests that Convert uses MCPTelemetryConfig when TelemetryConfigRef is set. +// The telemetry config is now passed directly by the controller (no longer fetched by the converter). +func TestConverter_TelemetryConfigRef(t *testing.T) { + t.Parallel() + + telemetryCfg := &mcpv1alpha1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "shared-telemetry", Namespace: "default"}, + Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + Enabled: true, + Endpoint: "https://otel-collector:4317", + Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{ + Enabled: true, + SamplingRate: "0.5", + }, + Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{ + Enabled: true, + }, + }, + }, + } + + k8sClient := newTestK8sClient(t) + converter, err := NewConverter(newNoOpMockResolver(t), k8sClient) + require.NoError(t, err) + + vmcp := &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, + Config: vmcpconfig.Config{Group: "test-group"}, + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + Name: "shared-telemetry", + ServiceName: "custom-svc", + }, + }, + } + + ctx := log.IntoContext(context.Background(), logr.Discard()) + config, _, err := converter.Convert(ctx, vmcp, telemetryCfg) + require.NoError(t, err) + require.NotNil(t, config) + require.NotNil(t, config.Telemetry) + + assert.Equal(t, "custom-svc", config.Telemetry.ServiceName, + "ServiceName should come from TelemetryConfigRef.ServiceName override") + assert.Equal(t, "otel-collector:4317", config.Telemetry.Endpoint, + "Endpoint should be normalized (https:// prefix stripped)") + assert.True(t, config.Telemetry.TracingEnabled, "Tracing should be enabled from MCPTelemetryConfig") + assert.True(t, config.Telemetry.MetricsEnabled, "Metrics should be enabled from MCPTelemetryConfig") +} diff --git a/cmd/thv-operator/test-integration/virtualmcp/suite_test.go b/cmd/thv-operator/test-integration/virtualmcp/suite_test.go index 7a045d2d3a..bccc2edd7e 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/suite_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/suite_test.go @@ -146,6 +146,13 @@ var _ = BeforeSuite(func() { }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) + // Register the MCPTelemetryConfig controller (required for telemetryConfigRef tests) + err = (&controllers.MCPTelemetryConfigReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + // Register the VirtualMCPServer controller err = (&controllers.VirtualMCPServerReconciler{ Client: k8sManager.GetClient(), diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go new file mode 100644 index 0000000000..b72cde76ea --- /dev/null +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go @@ -0,0 +1,349 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package controllers contains integration tests for the VirtualMCPServer controller +package controllers + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + telemetryconfig "github.com/stacklok/toolhive/pkg/telemetry" + vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" +) + +var _ = Describe("VirtualMCPServer TelemetryConfig Integration", + Label("k8s", "telemetry"), func() { + const ( + timeout = time.Second * 30 + interval = time.Millisecond * 250 + namespace = "default" + ) + + Context("VirtualMCPServer with TelemetryConfigRef should track config hash in status", Ordered, func() { + var ( + mcpGroup *mcpv1alpha1.MCPGroup + telemetryConfig *mcpv1alpha1.MCPTelemetryConfig + virtualMCPServer *mcpv1alpha1.VirtualMCPServer + ) + + BeforeAll(func() { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: namespace}, + } + err := k8sClient.Create(ctx, ns) + if err != nil && !apierrors.IsAlreadyExists(err) { + Expect(err).NotTo(HaveOccurred()) + } + + mcpGroup = &mcpv1alpha1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-group-telemetry-hash", + Namespace: namespace, + }, + Spec: mcpv1alpha1.MCPGroupSpec{ + Description: "Test group for telemetry config hash test", + }, + } + Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) + + telemetryConfig = &mcpv1alpha1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-telemetry-vmcp-hash", + Namespace: namespace, + }, + } + telemetryConfig.Spec.OpenTelemetry = &mcpv1alpha1.MCPTelemetryOTelConfig{ + Enabled: true, + Endpoint: "https://otel-collector:4317", + Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{Enabled: true}, + } + Expect(k8sClient.Create(ctx, telemetryConfig)).Should(Succeed()) + + // Wait for the MCPTelemetryConfig controller to set ConfigHash + Eventually(func() bool { + fetched := &mcpv1alpha1.MCPTelemetryConfig{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: telemetryConfig.Name, + Namespace: namespace, + }, fetched) + return err == nil && fetched.Status.ConfigHash != "" + }, timeout, interval).Should(BeTrue()) + + virtualMCPServer = &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vmcp-telemetry-hash", + Namespace: namespace, + }, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Config: vmcpconfig.Config{Group: "test-group-telemetry-hash"}, + IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Type: "anonymous", + }, + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + Name: "test-telemetry-vmcp-hash", + }, + }, + } + Expect(k8sClient.Create(ctx, virtualMCPServer)).Should(Succeed()) + }) + + AfterAll(func() { + Expect(k8sClient.Delete(ctx, virtualMCPServer)).Should(Succeed()) + Expect(k8sClient.Delete(ctx, mcpGroup)).Should(Succeed()) + // MCPTelemetryConfig may be blocked by finalizer until references are removed; + // the VirtualMCPServer deletion above clears the reference. + Eventually(func() bool { + err := k8sClient.Delete(ctx, telemetryConfig) + return err == nil || apierrors.IsNotFound(err) + }, timeout, interval).Should(BeTrue()) + }) + + It("should set status.telemetryConfigHash to a non-empty value", func() { + Eventually(func() string { + fetched := &mcpv1alpha1.VirtualMCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: virtualMCPServer.Name, + Namespace: namespace, + }, fetched) + if err != nil { + return "" + } + return fetched.Status.TelemetryConfigHash + }, timeout, interval).ShouldNot(BeEmpty()) + }) + + It("should set TelemetryConfigRefValidated condition to True", func() { + Eventually(func() bool { + fetched := &mcpv1alpha1.VirtualMCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: virtualMCPServer.Name, + Namespace: namespace, + }, fetched) + if err != nil { + return false + } + for _, cond := range fetched.Status.Conditions { + if cond.Type == mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated { + return cond.Status == metav1.ConditionTrue && + cond.Reason == mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid + } + } + return false + }, timeout, interval).Should(BeTrue()) + }) + }) + + Context("VirtualMCPServer should update when MCPTelemetryConfig spec changes", Ordered, func() { + var ( + mcpGroup *mcpv1alpha1.MCPGroup + telemetryConfig *mcpv1alpha1.MCPTelemetryConfig + virtualMCPServer *mcpv1alpha1.VirtualMCPServer + initialHash string + ) + + BeforeAll(func() { + mcpGroup = &mcpv1alpha1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-group-telemetry-update", + Namespace: namespace, + }, + Spec: mcpv1alpha1.MCPGroupSpec{ + Description: "Test group for telemetry config update test", + }, + } + Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) + + telemetryConfig = &mcpv1alpha1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-telemetry-vmcp-update", + Namespace: namespace, + }, + } + telemetryConfig.Spec.OpenTelemetry = &mcpv1alpha1.MCPTelemetryOTelConfig{ + Enabled: true, + Endpoint: "https://otel-collector:4317", + Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + } + Expect(k8sClient.Create(ctx, telemetryConfig)).Should(Succeed()) + + // Wait for the MCPTelemetryConfig controller to set ConfigHash + Eventually(func() bool { + fetched := &mcpv1alpha1.MCPTelemetryConfig{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: telemetryConfig.Name, + Namespace: namespace, + }, fetched) + return err == nil && fetched.Status.ConfigHash != "" + }, timeout, interval).Should(BeTrue()) + + virtualMCPServer = &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vmcp-telemetry-update", + Namespace: namespace, + }, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Config: vmcpconfig.Config{Group: "test-group-telemetry-update"}, + IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Type: "anonymous", + }, + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + Name: "test-telemetry-vmcp-update", + }, + }, + } + Expect(k8sClient.Create(ctx, virtualMCPServer)).Should(Succeed()) + + // Wait for the initial hash to be propagated to the VirtualMCPServer + Eventually(func() bool { + fetched := &mcpv1alpha1.VirtualMCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: virtualMCPServer.Name, + Namespace: namespace, + }, fetched) + if err != nil || fetched.Status.TelemetryConfigHash == "" { + return false + } + initialHash = fetched.Status.TelemetryConfigHash + return true + }, timeout, interval).Should(BeTrue()) + }) + + AfterAll(func() { + Expect(k8sClient.Delete(ctx, virtualMCPServer)).Should(Succeed()) + Expect(k8sClient.Delete(ctx, mcpGroup)).Should(Succeed()) + Eventually(func() bool { + err := k8sClient.Delete(ctx, telemetryConfig) + return err == nil || apierrors.IsNotFound(err) + }, timeout, interval).Should(BeTrue()) + }) + + It("should update telemetryConfigHash when MCPTelemetryConfig spec changes", func() { + // Update the MCPTelemetryConfig endpoint to trigger a hash change + Eventually(func() error { + fetched := &mcpv1alpha1.MCPTelemetryConfig{} + if err := k8sClient.Get(ctx, types.NamespacedName{ + Name: telemetryConfig.Name, + Namespace: namespace, + }, fetched); err != nil { + return err + } + fetched.Spec.OpenTelemetry.Endpoint = "https://new-collector:4317" + return k8sClient.Update(ctx, fetched) + }, timeout, interval).Should(Succeed()) + + // Verify the VirtualMCPServer's telemetryConfigHash changes + Eventually(func() bool { + fetched := &mcpv1alpha1.VirtualMCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: virtualMCPServer.Name, + Namespace: namespace, + }, fetched) + if err != nil { + return false + } + return fetched.Status.TelemetryConfigHash != "" && + fetched.Status.TelemetryConfigHash != initialHash + }, timeout, interval).Should(BeTrue()) + }) + }) + + Context("VirtualMCPServer referencing non-existent MCPTelemetryConfig", Ordered, func() { + var ( + mcpGroup *mcpv1alpha1.MCPGroup + virtualMCPServer *mcpv1alpha1.VirtualMCPServer + ) + + BeforeAll(func() { + mcpGroup = &mcpv1alpha1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-group-telemetry-notfound", + Namespace: namespace, + }, + Spec: mcpv1alpha1.MCPGroupSpec{ + Description: "Test group for telemetry config not found test", + }, + } + Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) + + virtualMCPServer = &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vmcp-telemetry-notfound", + Namespace: namespace, + }, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Config: vmcpconfig.Config{Group: "test-group-telemetry-notfound"}, + IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Type: "anonymous", + }, + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + Name: "nonexistent-telemetry-config", + }, + }, + } + Expect(k8sClient.Create(ctx, virtualMCPServer)).Should(Succeed()) + }) + + AfterAll(func() { + Expect(k8sClient.Delete(ctx, virtualMCPServer)).Should(Succeed()) + Expect(k8sClient.Delete(ctx, mcpGroup)).Should(Succeed()) + }) + + It("should set TelemetryConfigRefValidated condition to False with reason TelemetryConfigRefNotFound", func() { + Eventually(func() bool { + fetched := &mcpv1alpha1.VirtualMCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: virtualMCPServer.Name, + Namespace: namespace, + }, fetched) + if err != nil { + return false + } + for _, cond := range fetched.Status.Conditions { + if cond.Type == mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated { + return cond.Status == metav1.ConditionFalse && + cond.Reason == mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefNotFound + } + } + return false + }, timeout, interval).Should(BeTrue()) + }) + }) + + Context("CEL validation rejects both config.telemetry and telemetryConfigRef", func() { + It("should reject creation when both config.telemetry and telemetryConfigRef are set", func() { + vmcp := &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vmcp-telemetry-mutual-exclusion", + Namespace: namespace, + }, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Config: vmcpconfig.Config{ + Group: "some-group", + Telemetry: &telemetryconfig.Config{ + Endpoint: "https://otel-collector:4317", + TracingEnabled: true, + }, + }, + IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Type: "anonymous", + }, + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + Name: "some-telemetry-config", + }, + }, + } + err := k8sClient.Create(ctx, vmcp) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("telemetryConfigRef")) + }) + }) + }) diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 213aa451e9..11aeb30400 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -642,7 +642,9 @@ spec: The only field currently required within config is `config.groupRef`. GroupRef references an existing MCPGroup that defines backend workloads. The referenced MCPGroup must exist in the same namespace. - The telemetry and audit config from here are also supported, but not required. + The audit config from here is also supported, but not required. + Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference + a shared MCPTelemetryConfig resource instead. properties: aggregation: description: |- @@ -1682,6 +1684,9 @@ spec: description: |- Telemetry configures OpenTelemetry-based observability for the Virtual MCP server including distributed tracing, OTLP metrics export, and Prometheus metrics endpoint. + Deprecated (Kubernetes operator only): When deploying via the operator, use + VirtualMCPServer.spec.telemetryConfigRef to reference a shared MCPTelemetryConfig + resource instead. This field remains valid for standalone (non-operator) deployments. properties: caCertPath: description: |- @@ -2290,9 +2295,34 @@ spec: x-kubernetes-validations: - message: address is required rule: 'self.provider == ''redis'' ? has(self.address) : true' + telemetryConfigRef: + description: |- + TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. + The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer. + Cross-namespace references are not supported for security and isolation reasons. + Mutually exclusive with the deprecated inline config.telemetry field. + properties: + name: + description: Name is the name of the MCPTelemetryConfig resource + minLength: 1 + type: string + serviceName: + description: |- + ServiceName overrides the telemetry service name for this specific server. + This MUST be unique per server for proper observability (e.g., distinguishing + traces and metrics from different servers sharing the same collector). + If empty, defaults to the server name with "thv-" prefix at runtime. + type: string + required: + - name + type: object required: - incomingAuth type: object + x-kubernetes-validations: + - message: config.telemetry and telemetryConfigRef are mutually exclusive; + migrate to telemetryConfigRef + rule: '!(has(self.config.telemetry) && has(self.telemetryConfigRef))' status: description: VirtualMCPServerStatus defines the observed state of VirtualMCPServer properties: @@ -2448,6 +2478,11 @@ spec: - Degraded - Failed type: string + telemetryConfigHash: + description: |- + TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection. + Only populated when TelemetryConfigRef is set. + type: string url: description: URL is the URL where the Virtual MCP server can be accessed type: string diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index a3a775f477..9c451d7765 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -645,7 +645,9 @@ spec: The only field currently required within config is `config.groupRef`. GroupRef references an existing MCPGroup that defines backend workloads. The referenced MCPGroup must exist in the same namespace. - The telemetry and audit config from here are also supported, but not required. + The audit config from here is also supported, but not required. + Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference + a shared MCPTelemetryConfig resource instead. properties: aggregation: description: |- @@ -1685,6 +1687,9 @@ spec: description: |- Telemetry configures OpenTelemetry-based observability for the Virtual MCP server including distributed tracing, OTLP metrics export, and Prometheus metrics endpoint. + Deprecated (Kubernetes operator only): When deploying via the operator, use + VirtualMCPServer.spec.telemetryConfigRef to reference a shared MCPTelemetryConfig + resource instead. This field remains valid for standalone (non-operator) deployments. properties: caCertPath: description: |- @@ -2293,9 +2298,34 @@ spec: x-kubernetes-validations: - message: address is required rule: 'self.provider == ''redis'' ? has(self.address) : true' + telemetryConfigRef: + description: |- + TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. + The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer. + Cross-namespace references are not supported for security and isolation reasons. + Mutually exclusive with the deprecated inline config.telemetry field. + properties: + name: + description: Name is the name of the MCPTelemetryConfig resource + minLength: 1 + type: string + serviceName: + description: |- + ServiceName overrides the telemetry service name for this specific server. + This MUST be unique per server for proper observability (e.g., distinguishing + traces and metrics from different servers sharing the same collector). + If empty, defaults to the server name with "thv-" prefix at runtime. + type: string + required: + - name + type: object required: - incomingAuth type: object + x-kubernetes-validations: + - message: config.telemetry and telemetryConfigRef are mutually exclusive; + migrate to telemetryConfigRef + rule: '!(has(self.config.telemetry) && has(self.telemetryConfigRef))' status: description: VirtualMCPServerStatus defines the observed state of VirtualMCPServer properties: @@ -2451,6 +2481,11 @@ spec: - Degraded - Failed type: string + telemetryConfigHash: + description: |- + TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection. + Only populated when TelemetryConfigRef is set. + type: string url: description: URL is the URL where the Virtual MCP server can be accessed type: string diff --git a/docs/arch/09-operator-architecture.md b/docs/arch/09-operator-architecture.md index d3f17173ca..93444fea4c 100644 --- a/docs/arch/09-operator-architecture.md +++ b/docs/arch/09-operator-architecture.md @@ -129,6 +129,7 @@ graph TB VMCP -->|groupRef| Group VMCP -->|compositeToolRefs| CTD VMCP -.->|oidcConfigRef| OIDCCfg + VMCP -.->|telemetryConfigRef| TelCfg Server -->|groupRef| Group Server -.->|externalAuthConfigRef| ExtAuth @@ -140,6 +141,7 @@ graph TB Proxy -.->|externalAuthConfigRef| ExtAuth Proxy -.->|toolConfigRef| ToolCfg Proxy -.->|oidcConfigRef| OIDCCfg + Proxy -.->|telemetryConfigRef| TelCfg Entry -->|groupRef| Group Entry -.->|externalAuthConfigRef| ExtAuth @@ -243,7 +245,7 @@ MCPTelemetryConfig centralises telemetry infrastructure settings (collector endp **Status fields** include a `Ready` condition, `configHash` for change detection, and `referencingWorkloads` tracking. -**Referenced by**: MCPServer (via `telemetryConfigRef`) +**Referenced by**: MCPServer, VirtualMCPServer, MCPRemoteProxy (via `telemetryConfigRef`) **Controller**: `cmd/thv-operator/controllers/mcptelemetryconfig_controller.go` diff --git a/docs/arch/10-virtual-mcp-architecture.md b/docs/arch/10-virtual-mcp-architecture.md index 4f8d7e2469..2cb899721d 100644 --- a/docs/arch/10-virtual-mcp-architecture.md +++ b/docs/arch/10-virtual-mcp-architecture.md @@ -12,6 +12,7 @@ vMCP solves the problem of **MCP server sprawl**. As organizations deploy more s - **Composite workflows** - Create new tools that orchestrate multiple backends - **Centralized security** - Single authentication and authorization point - **Token management** - Exchange and cache tokens for backend access +- **Shared telemetry** - Reference an MCPTelemetryConfig via `telemetryConfigRef` for fleet-wide OpenTelemetry settings ## Architecture diff --git a/docs/observability.md b/docs/observability.md index 9acc2c460e..9e27f5d36e 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -127,10 +127,10 @@ CLI flags take precedence over configuration file values when explicitly set. **MCPTelemetryConfig (preferred)**: Define telemetry settings in a shared `MCPTelemetryConfig` resource and reference it via `spec.telemetryConfigRef` -in MCPServer. This eliminates duplication when managing multiple servers. -Each server provides a unique `serviceName` override. Sensitive headers -(API keys, bearer tokens) are stored in Kubernetes Secrets via -`sensitiveHeaders[].secretKeyRef`. +in MCPServer, MCPRemoteProxy, or VirtualMCPServer. This eliminates duplication +when managing multiple servers. Each server provides a unique `serviceName` +override. Sensitive headers (API keys, bearer tokens) are stored in Kubernetes +Secrets via `sensitiveHeaders[].secretKeyRef`. ```yaml apiVersion: toolhive.stacklok.dev/v1alpha1 @@ -162,10 +162,11 @@ spec: See [`examples/operator/mcp-servers/mcpserver_fetch_otel.yaml`](./examples/operator/mcp-servers/mcpserver_fetch_otel.yaml) for a complete example. -**Inline (deprecated)**: The inline `spec.openTelemetry` (MCPServer) and -`spec.config.telemetry` (VirtualMCPServer) fields still work but are +**Inline (deprecated)**: The inline `spec.telemetry` (MCPServer, MCPRemoteProxy) +and `spec.config.telemetry` (VirtualMCPServer) fields still work but are deprecated and will be removed in v1beta1. They are mutually exclusive with -`telemetryConfigRef` (CEL enforced). +`telemetryConfigRef` (CEL enforced). All three resource types now support +`spec.telemetryConfigRef`. For VirtualMCPServer telemetry, see the [vMCP observability docs](./operator/virtualmcpserver-observability.md). diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index dd79a4f147..0a7e9ee2fa 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -272,7 +272,7 @@ _Appears in:_ | `compositeToolRefs` _[vmcp.config.CompositeToolRef](#vmcpconfigcompositetoolref) array_ | CompositeToolRefs references VirtualMCPCompositeToolDefinition resources
for complex, reusable workflows. Only applicable when running in Kubernetes.
Referenced resources must be in the same namespace as the VirtualMCPServer. | | Optional: \{\}
| | `operational` _[vmcp.config.OperationalConfig](#vmcpconfigoperationalconfig)_ | Operational configures operational settings. | | | | `metadata` _object (keys:string, values:string)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `telemetry` _[pkg.telemetry.Config](#pkgtelemetryconfig)_ | Telemetry configures OpenTelemetry-based observability for the Virtual MCP server
including distributed tracing, OTLP metrics export, and Prometheus metrics endpoint. | | Optional: \{\}
| +| `telemetry` _[pkg.telemetry.Config](#pkgtelemetryconfig)_ | Telemetry configures OpenTelemetry-based observability for the Virtual MCP server
including distributed tracing, OTLP metrics export, and Prometheus metrics endpoint.
Deprecated (Kubernetes operator only): When deploying via the operator, use
VirtualMCPServer.spec.telemetryConfigRef to reference a shared MCPTelemetryConfig
resource instead. This field remains valid for standalone (non-operator) deployments. | | Optional: \{\}
| | `audit` _[pkg.audit.Config](#pkgauditconfig)_ | Audit configures audit logging for the Virtual MCP server.
When present, audit logs include MCP protocol operations.
See audit.Config for available configuration options. | | Optional: \{\}
| | `optimizer` _[vmcp.config.OptimizerConfig](#vmcpconfigoptimizerconfig)_ | Optimizer configures the MCP optimizer for context optimization on large toolsets.
When enabled, vMCP exposes only find_tool and call_tool operations to clients
instead of all backend tools directly. This reduces token usage by allowing
LLMs to discover relevant tools on demand rather than receiving all tool definitions. | | Optional: \{\}
| | `sessionStorage` _[vmcp.config.SessionStorageConfig](#vmcpconfigsessionstorageconfig)_ | SessionStorage configures session storage for stateful horizontal scaling.
When provider is "redis", the operator injects Redis connection parameters
(address, db, keyPrefix) here. The Redis password is provided separately via
the THV_SESSION_REDIS_PASSWORD environment variable. | | Optional: \{\}
| @@ -2240,6 +2240,7 @@ same namespace as the MCPServer. _Appears in:_ - [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) - [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1alpha1.VirtualMCPServerSpec](#apiv1alpha1virtualmcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -3420,7 +3421,8 @@ _Appears in:_ | `sessionAffinity` _string_ | SessionAffinity controls whether the Service routes repeated client connections to the same pod.
MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default.
Set to "None" for stateless servers or when using an external load balancer with its own affinity. | ClientIP | Enum: [ClientIP None]
Optional: \{\}
| | `serviceAccount` _string_ | ServiceAccount is the name of an already existing service account to use by the Virtual MCP server.
If not specified, a ServiceAccount will be created automatically and used by the Virtual MCP server. | | Optional: \{\}
| | `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the Virtual MCP server
This allows for customizing the pod configuration beyond what is provided by the other fields.
Note that to modify the specific container the Virtual MCP server runs in, you must specify
the 'vmcp' container name in the PodTemplateSpec.
This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object
Optional: \{\}
| -| `config` _[vmcp.config.Config](#vmcpconfigconfig)_ | Config is the Virtual MCP server configuration
The only field currently required within config is `config.groupRef`.
GroupRef references an existing MCPGroup that defines backend workloads.
The referenced MCPGroup must exist in the same namespace.
The telemetry and audit config from here are also supported, but not required. | | Type: object
Optional: \{\}
| +| `config` _[vmcp.config.Config](#vmcpconfigconfig)_ | Config is the Virtual MCP server configuration
The only field currently required within config is `config.groupRef`.
GroupRef references an existing MCPGroup that defines backend workloads.
The referenced MCPGroup must exist in the same namespace.
The audit config from here is also supported, but not required.
Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference
a shared MCPTelemetryConfig resource instead. | | Type: object
Optional: \{\}
| +| `telemetryConfigRef` _[api.v1alpha1.MCPTelemetryConfigReference](#apiv1alpha1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer.
Cross-namespace references are not supported for security and isolation reasons.
Mutually exclusive with the deprecated inline config.telemetry field. | | Optional: \{\}
| | `embeddingServerRef` _[api.v1alpha1.EmbeddingServerRef](#apiv1alpha1embeddingserverref)_ | EmbeddingServerRef references an existing EmbeddingServer resource by name.
When the optimizer is enabled, this field is required to point to a ready EmbeddingServer
that provides embedding capabilities.
The referenced EmbeddingServer must exist in the same namespace and be ready. | | Optional: \{\}
| | `authServerConfig` _[api.v1alpha1.EmbeddedAuthServerConfig](#apiv1alpha1embeddedauthserverconfig)_ | AuthServerConfig configures an embedded OAuth authorization server.
When set, the vMCP server acts as an OIDC issuer, drives users through
upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the
IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfig
so that tokens it issues are accepted by the vMCP's incoming auth middleware.
When nil, IncomingAuth uses an external IDP and behavior is unchanged. | | Optional: \{\}
| | `replicas` _integer_ | Replicas is the desired number of vMCP pod replicas.
VirtualMCPServer creates a single Deployment for the vMCP aggregator process,
so there is only one replicas field (unlike MCPServer which has separate
Replicas and BackendReplicas for its two Deployments).
When nil, the operator does not set Deployment.Spec.Replicas, leaving replica
management to an HPA or other external controller. | | Minimum: 0
Optional: \{\}
| @@ -3448,6 +3450,7 @@ _Appears in:_ | `discoveredBackends` _[api.v1alpha1.DiscoveredBackend](#apiv1alpha1discoveredbackend) array_ | DiscoveredBackends lists discovered backend configurations from the MCPGroup | | Optional: \{\}
| | `backendCount` _integer_ | BackendCount is the number of healthy/ready backends
(excludes unavailable, degraded, and unknown backends) | | Optional: \{\}
| | `oidcConfigHash` _string_ | OIDCConfigHash is the hash of the referenced MCPOIDCConfig spec for change detection.
Only populated when IncomingAuth.OIDCConfigRef is set. | | Optional: \{\}
| +| `telemetryConfigHash` _string_ | TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection.
Only populated when TelemetryConfigRef is set. | | Optional: \{\}
| #### api.v1alpha1.Volume diff --git a/docs/operator/virtualmcpserver-observability.md b/docs/operator/virtualmcpserver-observability.md index cb53245ec7..78621ac752 100644 --- a/docs/operator/virtualmcpserver-observability.md +++ b/docs/operator/virtualmcpserver-observability.md @@ -154,11 +154,56 @@ backends that support it. ## Configuration -Configure telemetry in the `VirtualMCPServer` resource using the `spec.config.telemetry` -field. The telemetry configuration uses the `telemetry.Config` type which provides -flat configuration for OpenTelemetry and Prometheus settings. +**MCPTelemetryConfig (preferred)**: Define telemetry settings in a shared +`MCPTelemetryConfig` resource and reference it via `spec.telemetryConfigRef` +in VirtualMCPServer. This eliminates duplication when managing multiple servers +and keeps telemetry configuration consistent across MCPServer, MCPRemoteProxy, +and VirtualMCPServer resources. ```yaml +# Shared telemetry configuration +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPTelemetryConfig +metadata: + name: shared-otel +spec: + openTelemetry: + enabled: true + endpoint: otel-collector:4318 + insecure: true + tracing: + enabled: true + samplingRate: "0.1" + metrics: + enabled: true + prometheus: + enabled: true +--- +# VirtualMCPServer referencing shared telemetry config +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: VirtualMCPServer +metadata: + name: my-vmcp +spec: + telemetryConfigRef: + name: shared-otel + serviceName: my-vmcp + config: + groupRef: my-group + incomingAuth: + type: anonymous +``` + +See [`examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml`](../../examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml) +for a complete example with an MCPGroup and backend MCPServer. + +**Inline (deprecated)**: The inline `spec.config.telemetry` field still works +but is deprecated and will be removed in v1beta1. It is mutually exclusive with +`telemetryConfigRef` (CEL enforced). Migrate to `telemetryConfigRef` to use the +shared MCPTelemetryConfig pattern. + +```yaml +# Deprecated — use telemetryConfigRef instead apiVersion: toolhive.stacklok.dev/v1alpha1 kind: VirtualMCPServer metadata: diff --git a/examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml b/examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml new file mode 100644 index 0000000000..c029ae9df5 --- /dev/null +++ b/examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml @@ -0,0 +1,106 @@ +# Example: VirtualMCPServer with telemetryConfigRef +# +# This example demonstrates using a shared MCPTelemetryConfig resource with a +# VirtualMCPServer via spec.telemetryConfigRef. This is the preferred pattern +# for configuring telemetry — the inline spec.config.telemetry field is +# deprecated and will be removed in v1beta1. +# +# The MCPTelemetryConfig enables OTLP tracing, OTLP metrics, and Prometheus +# metrics. The VirtualMCPServer references it and provides a unique serviceName +# override for its telemetry data. +# +# Prerequisites: +# - ToolHive operator installed in the cluster +# - An OpenTelemetry Collector reachable at the configured endpoint +# +# This example creates: +# 1. An MCPTelemetryConfig with OTLP + Prometheus settings +# 2. An MCPGroup to organize backend servers +# 3. An MCPServer backend in the group +# 4. A VirtualMCPServer referencing the shared telemetry config +# +# Usage: +# kubectl apply -f vmcp_with_telemetry_ref.yaml + +--- +# Step 1: Shared telemetry configuration +# Define once, reference from multiple MCPServers and VirtualMCPServers. +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPTelemetryConfig +metadata: + name: shared-otel + namespace: toolhive-system +spec: + openTelemetry: + enabled: true + endpoint: otel-collector-opentelemetry-collector.monitoring.svc.cluster.local:4318 + insecure: true + tracing: + enabled: true + samplingRate: "0.1" + metrics: + enabled: true + prometheus: + enabled: true + +--- +# Step 2: MCPGroup for backend discovery +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPGroup +metadata: + name: telemetry-demo + namespace: toolhive-system +spec: + description: Backend services for the telemetryConfigRef example + +--- +# Step 3: MCPServer backend that references the group +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: yardstick-telemetry + namespace: toolhive-system +spec: + groupRef: telemetry-demo + image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 + transport: stdio + proxyPort: 8080 + resources: + limits: + cpu: "100m" + memory: "128Mi" + requests: + cpu: "50m" + memory: "64Mi" + +--- +# Step 4: VirtualMCPServer with telemetryConfigRef +# The telemetryConfigRef replaces the deprecated inline spec.config.telemetry field. +# serviceName provides a unique OTel service name for this vMCP's telemetry. +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: VirtualMCPServer +metadata: + name: telemetry-demo-vmcp + namespace: toolhive-system +spec: + # Shared telemetry configuration reference + telemetryConfigRef: + name: shared-otel + serviceName: telemetry-demo-vmcp + + # vMCP configuration + config: + groupRef: telemetry-demo + aggregation: + conflictResolution: prefix + conflictResolutionConfig: + prefixFormat: "{workload}_" + + # Incoming authentication — anonymous for simplicity + # Replace with OIDC in production + incomingAuth: + type: anonymous + + # Backend authentication — discovered from backend MCPServers + outgoingAuth: + source: discovered diff --git a/pkg/vmcp/config/config.go b/pkg/vmcp/config/config.go index 5cc4a4fefa..e09aa96f84 100644 --- a/pkg/vmcp/config/config.go +++ b/pkg/vmcp/config/config.go @@ -147,6 +147,9 @@ type Config struct { // Telemetry configures OpenTelemetry-based observability for the Virtual MCP server // including distributed tracing, OTLP metrics export, and Prometheus metrics endpoint. + // Deprecated (Kubernetes operator only): When deploying via the operator, use + // VirtualMCPServer.spec.telemetryConfigRef to reference a shared MCPTelemetryConfig + // resource instead. This field remains valid for standalone (non-operator) deployments. // +optional Telemetry *telemetry.Config `json:"telemetry,omitempty" yaml:"telemetry,omitempty"` From f9119cb03574263eb3357f0db1ca3fed1a2afa5c Mon Sep 17 00:00:00 2001 From: Radoslav Dimitrov Date: Tue, 14 Apr 2026 09:39:38 -0400 Subject: [PATCH 019/117] Update registry API image to v1.1.1 (#4806) The operator Helm chart was pinned to thv-registry-api v1.0.1. Bump to the latest release v1.1.1. Co-authored-by: Claude Opus 4.6 (1M context) --- deploy/charts/operator/README.md | 4 ++-- deploy/charts/operator/values-openshift.yaml | 2 +- deploy/charts/operator/values.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index 44a63ed9db..01676ea559 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -99,6 +99,6 @@ The command removes all the Kubernetes components associated with the chart and | operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.19.0"` | Image to use for Virtual MCP Server (vMCP) deployments | | operator.volumeMounts | list | `[]` | Additional volume mounts on the operator container | | operator.volumes | list | `[]` | Additional volumes to mount on the operator pod | -| registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.0.1"}` | All values for the registry API deployment and associated resources | -| registryAPI.image | string | `"ghcr.io/stacklok/thv-registry-api:v1.0.1"` | Container image for the registry API | +| registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.1.1"}` | All values for the registry API deployment and associated resources | +| registryAPI.image | string | `"ghcr.io/stacklok/thv-registry-api:v1.1.1"` | Container image for the registry API | diff --git a/deploy/charts/operator/values-openshift.yaml b/deploy/charts/operator/values-openshift.yaml index 628e79de26..02ca30c067 100644 --- a/deploy/charts/operator/values-openshift.yaml +++ b/deploy/charts/operator/values-openshift.yaml @@ -206,4 +206,4 @@ operator: # -- All values for the registry API deployment and associated resources registryAPI: # -- Container image for the registry API - image: "ghcr.io/stacklok/thv-registry-api:v1.0.1" + image: "ghcr.io/stacklok/thv-registry-api:v1.1.1" diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index a75cd2cc1c..39cefcd7cc 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -206,4 +206,4 @@ operator: # -- All values for the registry API deployment and associated resources registryAPI: # -- Container image for the registry API - image: "ghcr.io/stacklok/thv-registry-api:v1.0.1" + image: "ghcr.io/stacklok/thv-registry-api:v1.1.1" From 25fe4754e1e6446843ec25bf7491f6675750c30a Mon Sep 17 00:00:00 2001 From: Michelangelo Mori <328978+blkt@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:05:37 +0200 Subject: [PATCH 020/117] Grant default `aud` when `resource` is absent (#4805) * Grant default `aud` when `resource` is absent The `TokenHandler` only granted an audience claim when the client included the RFC 8707 `resource` parameter. Clients that omit this optional parameter received a token with an empty `aud`, which caused `VirtualMCPServer` to reject every token its own auth server issued: the incoming OIDC validator requires `audience` to be set and rejects tokens that do not carry a matching claim. When no `resource` parameter is present and `AllowedAudiences` contains exactly one entry, the handler now defaults to granting that audience. The intended audience is unambiguous in that case, and `AllowedAudiences` is always non-empty (enforced at config validation time). The defaulting is restricted to `authorization_code` grants; for `refresh_token` grants fosite carries the originally-granted audience forward through the stored session automatically, so re-granting would conflict with its matching strategy. Fixes #4794 --- pkg/authserver/server/handlers/token.go | 13 ++- pkg/authserver/server/handlers/token_test.go | 96 +++++++++++++++----- 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/pkg/authserver/server/handlers/token.go b/pkg/authserver/server/handlers/token.go index 6fc5865cff..05478cb6dd 100644 --- a/pkg/authserver/server/handlers/token.go +++ b/pkg/authserver/server/handlers/token.go @@ -49,7 +49,7 @@ func (h *Handler) TokenHandler(w http.ResponseWriter, req *http.Request) { server.ErrInvalidTarget.WithHint("Multiple resource parameters are not supported")) return } - if len(resources) == 1 { + if len(resources) == 1 && resources[0] != "" { resource := resources[0] // Validate URI format per RFC 8707 if err := server.ValidateAudienceURI(resource); err != nil { @@ -75,6 +75,17 @@ func (h *Handler) TokenHandler(w http.ResponseWriter, req *http.Request) { "resource", resource, ) accessRequest.GrantAudience(resource) + } else if accessRequest.GetGrantTypes().ExactOne("authorization_code") && len(h.config.AllowedAudiences) == 1 { + // No resource parameter provided (or provided as empty) during an authorization_code + // exchange; default to the sole allowed audience. The len == 1 guard makes the + // intended audience unambiguous and the index access safe. We restrict this defaulting + // to authorization_code grants: for refresh_token grants, fosite already carries the + // originally-granted audience forward through the session, so re-granting here would + // conflict with fosite's audience matching strategy. + slog.Debug("no resource parameter, defaulting to sole allowed audience", + "audience", h.config.AllowedAudiences[0], + ) + accessRequest.GrantAudience(h.config.AllowedAudiences[0]) } // Generate the access response (tokens) diff --git a/pkg/authserver/server/handlers/token_test.go b/pkg/authserver/server/handlers/token_test.go index 84cee335ca..df4e9b7c9c 100644 --- a/pkg/authserver/server/handlers/token_test.go +++ b/pkg/authserver/server/handlers/token_test.go @@ -4,6 +4,7 @@ package handlers import ( + "encoding/json" "net/http" "net/http/httptest" "net/url" @@ -11,6 +12,8 @@ import ( "testing" "time" + "github.com/go-jose/go-jose/v4" + josejwt "github.com/go-jose/go-jose/v4/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -173,35 +176,78 @@ func TestTokenHandler_Success(t *testing.T) { assert.Contains(t, body, "expires_in") } -func TestTokenHandler_ResourceParameter(t *testing.T) { +func TestTokenHandler_AudienceClaim(t *testing.T) { t.Parallel() - handler, storState, _ := handlerTestSetup(t) - - // Simulate authorize flow - authorizeCode := simulateAuthorizeFlow(t, handler, storState) - // Exchange code with RFC 8707 resource parameter - form := url.Values{ - "grant_type": {"authorization_code"}, - "client_id": {testAuthClientID}, - "redirect_uri": {testAuthRedirectURI}, - "code": {authorizeCode}, - "code_verifier": {testPKCEVerifier}, - "resource": {"https://api.example.com"}, + // ptr is a helper to take the address of a string literal. + ptr := func(s string) *string { return &s } + + tests := []struct { + name string + resource *string // nil = omit parameter; non-nil = include (possibly empty) + wantAud string + }{ + { + name: "explicit resource grants matching audience", + resource: ptr("https://api.example.com"), + wantAud: "https://api.example.com", + }, + { + name: "absent resource defaults to sole AllowedAudience", + resource: nil, + wantAud: "https://api.example.com", + }, + { + name: "explicit empty resource defaults to sole AllowedAudience", + resource: ptr(""), + wantAud: "https://api.example.com", + }, } - req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - handler.TokenHandler(rec, req) - - require.Equal(t, http.StatusOK, rec.Code, "expected 200 OK, got %d: %s", rec.Code, rec.Body.String()) - - // The resource parameter should be granted as audience in the JWT - // We can't easily verify the JWT contents here without decoding, - // but we verify the request succeeded - body := rec.Body.String() - assert.Contains(t, body, "access_token") + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + handler, storState, _ := handlerTestSetup(t) + authorizeCode := simulateAuthorizeFlow(t, handler, storState) + + form := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {testAuthClientID}, + "redirect_uri": {testAuthRedirectURI}, + "code": {authorizeCode}, + "code_verifier": {testPKCEVerifier}, + } + if tc.resource != nil { + form.Set("resource", *tc.resource) + } + + req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + handler.TokenHandler(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "got %d: %s", rec.Code, rec.Body.String()) + + var tokenResp map[string]any + require.NoError(t, json.NewDecoder(rec.Body).Decode(&tokenResp)) + + accessToken, ok := tokenResp["access_token"].(string) + require.True(t, ok, "access_token should be a string") + require.NotEmpty(t, accessToken) + + parsedToken, err := josejwt.ParseSigned(accessToken, []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err) + + var claims map[string]any + require.NoError(t, parsedToken.UnsafeClaimsWithoutVerification(&claims)) + + aud, ok := claims["aud"].([]any) + require.True(t, ok, "aud claim should be an array, got: %T %v", claims["aud"], claims["aud"]) + require.Len(t, aud, 1) + assert.Equal(t, tc.wantAud, aud[0]) + }) + } } func TestTokenHandler_RouteRegistered(t *testing.T) { From 1fa05eeef6dc128dbd72da18b6fdd9901426d2ce Mon Sep 17 00:00:00 2001 From: Michelangelo Mori <328978+blkt@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:54:35 +0200 Subject: [PATCH 021/117] Add `Audience` to authserver test client fixture (#4814) Fosite validates that a granted audience appears in the client's own `GetAudience()` list when handling refresh tokens. The main `setupTestServer` helper registered the test client without an `Audience` field, so the check failed with HTTP 400 after the token handler began defaulting to the sole `AllowedAudience` on the initial `authorization_code` exchange. DCR registration always sets `Audience` to `AllowedAudiences`; align the test fixture with that production behaviour. --- pkg/authserver/integration_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/authserver/integration_test.go b/pkg/authserver/integration_test.go index e7fea55522..973398ea9b 100644 --- a/pkg/authserver/integration_test.go +++ b/pkg/authserver/integration_test.go @@ -139,6 +139,7 @@ func setupTestServer(t *testing.T, opts ...testServerOption) *testServer { ResponseTypes: []string{"code"}, GrantTypes: []string{"authorization_code", "refresh_token"}, Scopes: options.scopes, + Audience: []string{testAudience}, Public: true, }) require.NoError(t, err) From 0fb1b422812e2f14d4627af4a0089c78abcfb1b7 Mon Sep 17 00:00:00 2001 From: Michelangelo Mori <328978+blkt@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:55:35 +0200 Subject: [PATCH 022/117] Isolate `TestParseGitReference` from ambient env (#4813) The subtests failed when `TOOLHIVE_DEV=true` was set in the shell environment. `isDevMode()` reads that variable, causing the function to use `http://` instead of `https://` and to skip SSRF rejection -- the opposite of what the non-dev test cases assert. Fix: remove `t.Parallel()` from `TestParseGitReference` and add `t.Setenv("TOOLHIVE_DEV", "")` so the test always runs in a clean environment, matching the pattern used by `TestParseGitReferenceDevMode`. --- pkg/skills/gitresolver/reference_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/skills/gitresolver/reference_test.go b/pkg/skills/gitresolver/reference_test.go index caf209ea9f..880d1de6bf 100644 --- a/pkg/skills/gitresolver/reference_test.go +++ b/pkg/skills/gitresolver/reference_test.go @@ -35,8 +35,12 @@ func TestIsGitReference(t *testing.T) { } } +//nolint:paralleltest // t.Setenv is incompatible with t.Parallel func TestParseGitReference(t *testing.T) { - t.Parallel() + // Ensure dev mode is off regardless of the ambient environment, so that + // SSRF checks and https:// scheme selection are exercised as they would be + // in production. + t.Setenv("TOOLHIVE_DEV", "") tests := []struct { name string @@ -159,7 +163,6 @@ func TestParseGitReference(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Parallel() result, err := ParseGitReference(tt.input) if tt.expectError != "" { From be40131daab27cf57cf948e3017c7d5d6079a5c5 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:59:52 +0100 Subject: [PATCH 023/117] Add ConfigMap content assertions to vMCP telemetry integration tests (#4807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ConfigMap content assertions to telemetry integration tests Verify the vmcp-config ConfigMap contains the correct telemetry settings from the referenced MCPTelemetryConfig: endpoint (with https:// prefix stripped), tracing/metrics enabled flags, and that the ConfigMap updates when the MCPTelemetryConfig endpoint changes. This ensures the full data path is tested end-to-end: CRD spec → converter → ConfigMap → telemetry config, not just status conditions. Co-authored-by: Claude Opus 4.6 (1M context) --- ...server_telemetryconfig_integration_test.go | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go index b72cde76ea..1622395f67 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go @@ -5,6 +5,7 @@ package controllers import ( + "fmt" "time" . "github.com/onsi/ginkgo/v2" @@ -13,6 +14,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/yaml" mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" telemetryconfig "github.com/stacklok/toolhive/pkg/telemetry" @@ -140,6 +142,33 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", return false }, timeout, interval).Should(BeTrue()) }) + + It("should produce a ConfigMap with telemetry config from the MCPTelemetryConfig", func() { + configMapName := fmt.Sprintf("%s-vmcp-config", virtualMCPServer.Name) + Eventually(func() bool { + cm := &corev1.ConfigMap{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: configMapName, + Namespace: namespace, + }, cm) + if err != nil { + return false + } + configYAML, ok := cm.Data["config.yaml"] + if !ok || configYAML == "" { + return false + } + // Parse the config and verify telemetry fields match the MCPTelemetryConfig + var config vmcpconfig.Config + if err := yaml.Unmarshal([]byte(configYAML), &config); err != nil { + return false + } + return config.Telemetry != nil && + config.Telemetry.Endpoint == "otel-collector:4317" && // NormalizeTelemetryConfig strips https:// + config.Telemetry.TracingEnabled && + config.Telemetry.MetricsEnabled + }, timeout, interval).Should(BeTrue()) + }) }) Context("VirtualMCPServer should update when MCPTelemetryConfig spec changes", Ordered, func() { @@ -253,6 +282,26 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", return fetched.Status.TelemetryConfigHash != "" && fetched.Status.TelemetryConfigHash != initialHash }, timeout, interval).Should(BeTrue()) + + // Verify the ConfigMap reflects the new endpoint + configMapName := fmt.Sprintf("%s-vmcp-config", virtualMCPServer.Name) + Eventually(func() bool { + cm := &corev1.ConfigMap{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: configMapName, + Namespace: namespace, + }, cm) + if err != nil { + return false + } + var config vmcpconfig.Config + if err := yaml.Unmarshal([]byte(cm.Data["config.yaml"]), &config); err != nil { + return false + } + // NormalizeTelemetryConfig strips https:// prefix + return config.Telemetry != nil && + config.Telemetry.Endpoint == "new-collector:4317" + }, timeout, interval).Should(BeTrue()) }) }) From 0c5213e3bc35d108dffeb636ec70ff74aac217f8 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:49:04 +0100 Subject: [PATCH 024/117] Change GroupRef from bare string to typed MCPGroupRef struct (#4809) * Change GroupRef from bare string to typed MCPGroupRef struct GroupRef was a bare string while every other cross-CRD reference (ExternalAuthConfigRef, ToolConfigRef, AuthServerRef, EmbeddingServerRef) uses a typed struct. This inconsistency prevented extending GroupRef with additional fields (like namespace) without a breaking change. Define MCPGroupRef struct with Name field and nil-safe GetName() helper. Replace GroupRef string with *MCPGroupRef on MCPServerSpec, MCPRemoteProxySpec, MCPServerEntrySpec, and add a new top-level GroupRef field on VirtualMCPServerSpec that takes precedence over the deprecated config.groupRef string path. Internal types (vmcpserver.Config, StatusResponse, BackendReconciler, config.Config) remain as strings since they are not CRD API types. This is a breaking wire-format change (groupRef: "name" becomes groupRef: {name: "name"}) that must happen before v1beta1. Closes #4634 Co-Authored-By: Claude Opus 4.6 (1M context) * Address review feedback on MCPGroupRef refactoring - Add nil guard to MCPServerEntry validateGroupRef using GetName() instead of direct .Name access to prevent potential panic - Add unit tests for MCPGroupRef.GetName() and VirtualMCPServer.ResolveGroupName() covering precedence logic - Normalize nil-check pattern across all field index extractors to use GetName() == "" (handles both nil and empty name) - Mark config.Config.Group as optional and deprecated since spec.groupRef is now the preferred path - Improve Validate() error message to mention both spec.groupRef and config.groupRef paths Co-Authored-By: Claude Opus 4.6 (1M context) * Update missed example and docs for MCPGroupRef wire format - Fix vmcp_with_telemetry_ref.yaml (missed in initial pass) - Update virtualmcpserver-api.md to document spec.groupRef as primary field and update all YAML examples to struct format - Update virtualmcpcompositetooldefinition-guide.md and virtualmcpserver-observability.md examples - Regenerate crd-api.md to show MCPGroupRef typed struct Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../api/v1alpha1/mcpremoteproxy_types.go | 6 +- .../api/v1alpha1/mcpserver_types.go | 23 +- .../api/v1alpha1/mcpserverentry_types.go | 7 +- .../api/v1alpha1/virtualmcpserver_types.go | 25 ++- .../v1alpha1/virtualmcpserver_types_test.go | 68 ++++++ .../api/v1alpha1/zz_generated.deepcopy.go | 35 +++ .../controllers/mcpgroup_controller.go | 27 ++- .../controllers/mcpgroup_controller_test.go | 206 +++++++++--------- .../controllers/mcpremoteproxy_controller.go | 11 +- .../controllers/mcpserver_controller.go | 11 +- .../controllers/mcpserver_groupref_test.go | 14 +- .../controllers/mcpserverentry_controller.go | 7 +- .../mcpserverentry_controller_test.go | 22 +- .../virtualmcpserver_controller.go | 30 +-- .../virtualmcpserver_deployment_test.go | 10 +- .../virtualmcpserver_vmcpconfig.go | 4 +- .../virtualmcpserver_vmcpconfig_test.go | 14 +- .../virtualmcpserver_watch_test.go | 42 ++-- cmd/thv-operator/main.go | 15 +- .../mcpgroup_controller_integration_test.go | 18 +- .../test-integration/mcp-group/suite_test.go | 15 +- .../mcp-oidc-config/suite_test.go | 15 +- .../mcp-remote-proxy/remoteproxy_helpers.go | 2 +- .../mcp-remote-proxy/suite_test.go | 15 +- .../mcpserver_controller_integration_test.go | 4 +- .../test-integration/mcp-server/suite_test.go | 15 +- .../test-integration/virtualmcp/suite_test.go | 15 +- ...irtualmcpserver_externalauth_watch_test.go | 2 +- ...oolhive.stacklok.dev_mcpremoteproxies.yaml | 14 +- ...oolhive.stacklok.dev_mcpserverentries.yaml | 15 +- .../toolhive.stacklok.dev_mcpservers.yaml | 14 +- ...olhive.stacklok.dev_virtualmcpservers.yaml | 23 +- ...oolhive.stacklok.dev_mcpremoteproxies.yaml | 14 +- ...oolhive.stacklok.dev_mcpserverentries.yaml | 15 +- .../toolhive.stacklok.dev_mcpservers.yaml | 14 +- ...olhive.stacklok.dev_virtualmcpservers.yaml | 23 +- docs/arch/09-operator-architecture.md | 6 +- docs/operator/crd-api.md | 31 ++- ...virtualmcpcompositetooldefinition-guide.md | 4 +- docs/operator/virtualmcpserver-api.md | 40 ++-- .../virtualmcpserver-kubernetes-guide.md | 35 +-- .../virtualmcpserver-observability.md | 7 +- .../mcpserverentry_basic.yaml | 3 +- .../mcpserverentry_mixed_group.yaml | 9 +- .../mcpserverentry_with_ca_bundle.yaml | 3 +- .../mcpserverentry_with_header_forward.yaml | 3 +- .../mcpserverentry_with_token_exchange.yaml | 3 +- .../virtual-mcps/composite_tool_complex.yaml | 12 +- .../virtual-mcps/composite_tool_simple.yaml | 9 +- .../composite_tool_with_elicitations.yaml | 6 +- .../vmcp_conflict_resolution.yaml | 18 +- .../vmcp_inline_incoming_auth.yaml | 12 +- .../vmcp_optimizer_all_options.yaml | 10 +- .../vmcp_optimizer_quickstart.yaml | 37 ++-- .../virtual-mcps/vmcp_production_full.yaml | 12 +- .../virtual-mcps/vmcp_simple_discovered.yaml | 6 +- .../vmcp_with_oidcconfig_ref.yaml | 3 +- .../virtual-mcps/vmcp_with_telemetry_ref.yaml | 7 +- pkg/vmcp/config/config.go | 5 +- pkg/vmcp/k8s/backend_reconciler.go | 20 +- .../backend_reconciler_integration_test.go | 20 +- pkg/vmcp/k8s/backend_reconciler_test.go | 22 +- pkg/vmcp/workloads/k8s.go | 6 +- pkg/vmcp/workloads/k8s_test.go | 60 ++--- .../vmcp/audit-chainsaw-test.yaml | 10 +- .../vmcp/basic/chainsaw-test.yaml | 3 +- .../test-scenarios/vmcp/vmcp-controller.yaml | 3 +- .../test-scenarios/vmcp/vmcp-with-oidc.yaml | 3 +- .../virtualmcpserver/chainsaw-test.yaml | 8 +- test/e2e/thv-operator/virtualmcp/helpers.go | 6 +- .../virtualmcp_auth_discovery_test.go | 4 +- .../virtualmcp_circuit_breaker_test.go | 4 +- .../virtualmcp_discovered_mode_test.go | 6 +- .../virtualmcp_external_auth_test.go | 12 +- .../virtualmcp_session_management_test.go | 4 +- .../virtualmcp/virtualmcp_telemetry_test.go | 2 +- .../virtualmcp_yardstick_base_test.go | 4 +- .../virtualmcpserver_scaling_test.go | 4 +- 78 files changed, 804 insertions(+), 488 deletions(-) diff --git a/cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go b/cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go index 7a51da1072..4434b17f68 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go @@ -143,10 +143,10 @@ type MCPRemoteProxySpec struct { // +optional ResourceOverrides *ResourceOverrides `json:"resourceOverrides,omitempty"` - // GroupRef is the name of the MCPGroup this proxy belongs to - // Must reference an existing MCPGroup in the same namespace + // GroupRef references the MCPGroup this proxy belongs to. + // The referenced MCPGroup must be in the same namespace. // +optional - GroupRef string `json:"groupRef,omitempty"` + GroupRef *MCPGroupRef `json:"groupRef,omitempty"` // SessionAffinity controls whether the Service routes repeated client connections to the same pod. // MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default. diff --git a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go b/cmd/thv-operator/api/v1alpha1/mcpserver_types.go index 893ce20b92..62733750d4 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpserver_types.go @@ -328,10 +328,10 @@ type MCPServerSpec struct { // +optional EndpointPrefix string `json:"endpointPrefix,omitempty"` - // GroupRef is the name of the MCPGroup this server belongs to - // Must reference an existing MCPGroup in the same namespace + // GroupRef references the MCPGroup this server belongs to. + // The referenced MCPGroup must be in the same namespace. // +optional - GroupRef string `json:"groupRef,omitempty"` + GroupRef *MCPGroupRef `json:"groupRef,omitempty"` // SessionAffinity controls whether the Service routes repeated client connections to the same pod. // MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default. @@ -911,6 +911,23 @@ type ToolConfigRef struct { Name string `json:"name"` } +// MCPGroupRef defines a reference to an MCPGroup resource. +// The referenced MCPGroup must be in the same namespace. +type MCPGroupRef struct { + // Name is the name of the MCPGroup resource in the same namespace + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// GetName returns the name, or empty string if the receiver is nil. +func (r *MCPGroupRef) GetName() string { + if r == nil { + return "" + } + return r.Name +} + // InlineAuthzConfig contains direct authorization configuration type InlineAuthzConfig struct { // Policies is a list of Cedar policy strings diff --git a/cmd/thv-operator/api/v1alpha1/mcpserverentry_types.go b/cmd/thv-operator/api/v1alpha1/mcpserverentry_types.go index ff665dbaed..60c089ccb8 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpserverentry_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpserverentry_types.go @@ -24,11 +24,10 @@ type MCPServerEntrySpec struct { // +kubebuilder:validation:Enum=sse;streamable-http Transport string `json:"transport"` - // GroupRef is the name of the MCPGroup this entry belongs to. + // GroupRef references the MCPGroup this entry belongs to. // Required — every MCPServerEntry must be part of a group for vMCP discovery. // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - GroupRef string `json:"groupRef"` + GroupRef *MCPGroupRef `json:"groupRef"` // ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange // when connecting to the remote MCP server. The referenced MCPExternalAuthConfig must @@ -155,7 +154,7 @@ const ( //+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase" //+kubebuilder:printcolumn:name="Transport",type="string",JSONPath=".spec.transport" //+kubebuilder:printcolumn:name="Remote URL",type="string",JSONPath=".spec.remoteUrl" -//+kubebuilder:printcolumn:name="Group",type="string",JSONPath=".spec.groupRef" +//+kubebuilder:printcolumn:name="Group",type="string",JSONPath=".spec.groupRef.name" //+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" // MCPServerEntry is the Schema for the mcpserverentries API. diff --git a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go index 28a56fb15b..9f1d7ba1cd 100644 --- a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go +++ b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go @@ -63,11 +63,15 @@ type VirtualMCPServerSpec struct { // +kubebuilder:validation:Type=object PodTemplateSpec *runtime.RawExtension `json:"podTemplateSpec,omitempty"` - // Config is the Virtual MCP server configuration - // The only field currently required within config is `config.groupRef`. - // GroupRef references an existing MCPGroup that defines backend workloads. + // GroupRef references the MCPGroup that defines backend workloads. // The referenced MCPGroup must exist in the same namespace. + // This field takes precedence over config.groupRef and should be preferred. + // +optional + GroupRef *MCPGroupRef `json:"groupRef,omitempty"` + + // Config is the Virtual MCP server configuration. // The audit config from here is also supported, but not required. + // Note: config.groupRef is deprecated in favor of spec.groupRef. // Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference // a shared MCPTelemetryConfig resource instead. // +optional @@ -447,13 +451,22 @@ func (*VirtualMCPServer) GetProxyPort() int32 { return 4483 } +// ResolveGroupName returns the effective group name, preferring spec.groupRef +// over the deprecated config.groupRef string. +func (r *VirtualMCPServer) ResolveGroupName() string { + if name := r.Spec.GroupRef.GetName(); name != "" { + return name + } + return r.Spec.Config.Group +} + // Validate performs validation for VirtualMCPServer // This method is called by the controller during reconciliation func (r *VirtualMCPServer) Validate() error { - // Validate Group is set (required field) + // Validate Group is set — prefer spec.groupRef, fall back to config.groupRef // Note: CEL cannot validate embedded types from other packages - if r.Spec.Config.Group == "" { - return fmt.Errorf("spec.config.groupRef is required") + if r.Spec.GroupRef.GetName() == "" && r.Spec.Config.Group == "" { + return fmt.Errorf("either spec.groupRef.name or config.groupRef must be set") } // Note: IncomingAuth validation is handled by kubebuilder markers and CEL rules diff --git a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go index b6d28bb7be..c39ccc6d64 100644 --- a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go +++ b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go @@ -535,3 +535,71 @@ func TestVirtualMCPServerSpecScalingFieldsJSONRoundtrip(t *testing.T) { }) } } + +func TestMCPGroupRef_GetName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref *MCPGroupRef + want string + }{ + {name: "nil receiver", ref: nil, want: ""}, + {name: "empty name", ref: &MCPGroupRef{Name: ""}, want: ""}, + {name: "non-empty name", ref: &MCPGroupRef{Name: "my-group"}, want: "my-group"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, tt.ref.GetName()) + }) + } +} + +func TestVirtualMCPServer_ResolveGroupName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + groupRef *MCPGroupRef + cfgGroup string + want string + }{ + { + name: "spec.groupRef takes precedence over config.groupRef", + groupRef: &MCPGroupRef{Name: "from-spec"}, + cfgGroup: "from-config", + want: "from-spec", + }, + { + name: "falls back to config.groupRef when spec.groupRef is nil", + groupRef: nil, + cfgGroup: "from-config", + want: "from-config", + }, + { + name: "only spec.groupRef set", + groupRef: &MCPGroupRef{Name: "from-spec"}, + cfgGroup: "", + want: "from-spec", + }, + { + name: "neither set returns empty", + groupRef: nil, + cfgGroup: "", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + vmcp := &VirtualMCPServer{ + Spec: VirtualMCPServerSpec{ + GroupRef: tt.groupRef, + Config: config.Config{Group: tt.cfgGroup}, + }, + } + assert.Equal(t, tt.want, vmcp.ResolveGroupName()) + }) + } +} diff --git a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go index ceafe5b09a..24019195af 100644 --- a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -898,6 +898,21 @@ func (in *MCPGroupList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPGroupRef) DeepCopyInto(out *MCPGroupRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPGroupRef. +func (in *MCPGroupRef) DeepCopy() *MCPGroupRef { + if in == nil { + return nil + } + out := new(MCPGroupRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPGroupSpec) DeepCopyInto(out *MCPGroupSpec) { *out = *in @@ -1324,6 +1339,11 @@ func (in *MCPRemoteProxySpec) DeepCopyInto(out *MCPRemoteProxySpec) { *out = new(ResourceOverrides) (*in).DeepCopyInto(*out) } + if in.GroupRef != nil { + in, out := &in.GroupRef, &out.GroupRef + *out = new(MCPGroupRef) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRemoteProxySpec. @@ -1447,6 +1467,11 @@ func (in *MCPServerEntryList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPServerEntrySpec) DeepCopyInto(out *MCPServerEntrySpec) { *out = *in + if in.GroupRef != nil { + in, out := &in.GroupRef, &out.GroupRef + *out = new(MCPGroupRef) + **out = **in + } if in.ExternalAuthConfigRef != nil { in, out := &in.ExternalAuthConfigRef, &out.ExternalAuthConfigRef *out = new(ExternalAuthConfigRef) @@ -1617,6 +1642,11 @@ func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { *out = new(TelemetryConfig) (*in).DeepCopyInto(*out) } + if in.GroupRef != nil { + in, out := &in.GroupRef, &out.GroupRef + *out = new(MCPGroupRef) + **out = **in + } if in.Replicas != nil { in, out := &in.Replicas, &out.Replicas *out = new(int32) @@ -3103,6 +3133,11 @@ func (in *VirtualMCPServerSpec) DeepCopyInto(out *VirtualMCPServerSpec) { *out = new(runtime.RawExtension) (*in).DeepCopyInto(*out) } + if in.GroupRef != nil { + in, out := &in.GroupRef, &out.GroupRef + *out = new(MCPGroupRef) + **out = **in + } in.Config.DeepCopyInto(&out.Config) if in.TelemetryConfigRef != nil { in, out := &in.TelemetryConfigRef, &out.TelemetryConfigRef diff --git a/cmd/thv-operator/controllers/mcpgroup_controller.go b/cmd/thv-operator/controllers/mcpgroup_controller.go index f350d3c0ff..41efb7c108 100644 --- a/cmd/thv-operator/controllers/mcpgroup_controller.go +++ b/cmd/thv-operator/controllers/mcpgroup_controller.go @@ -431,7 +431,8 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPServer(ctx context.Context, obj c ctxLogger.Error(nil, "Object is not an MCPServer", "object", obj.GetName()) return []ctrl.Request{} } - if mcpServer.Spec.GroupRef == "" { + groupName := mcpServer.Spec.GroupRef.GetName() + if groupName == "" { // No MCPGroup reference, nothing to do return []ctrl.Request{} } @@ -444,10 +445,10 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPServer(ctx context.Context, obj c "mcpserver", obj.GetName(), "groupRef", - mcpServer.Spec.GroupRef) + groupName) group := &mcpv1alpha1.MCPGroup{} - if err := r.Get(ctx, types.NamespacedName{Namespace: obj.GetNamespace(), Name: mcpServer.Spec.GroupRef}, group); err != nil { - ctxLogger.Error(err, "Failed to get MCPGroup for MCPServer", "namespace", obj.GetNamespace(), "name", mcpServer.Spec.GroupRef) + if err := r.Get(ctx, types.NamespacedName{Namespace: obj.GetNamespace(), Name: groupName}, group); err != nil { + ctxLogger.Error(err, "Failed to get MCPGroup for MCPServer", "namespace", obj.GetNamespace(), "name", groupName) return []ctrl.Request{} } return []ctrl.Request{ @@ -469,7 +470,8 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPRemoteProxy(ctx context.Context, ctxLogger.Error(nil, "Object is not an MCPRemoteProxy", "object", obj.GetName()) return []ctrl.Request{} } - if mcpRemoteProxy.Spec.GroupRef == "" { + groupName := mcpRemoteProxy.Spec.GroupRef.GetName() + if groupName == "" { // No MCPGroup reference, nothing to do return []ctrl.Request{} } @@ -482,12 +484,12 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPRemoteProxy(ctx context.Context, "mcpremoteproxy", obj.GetName(), "groupRef", - mcpRemoteProxy.Spec.GroupRef) + groupName) group := &mcpv1alpha1.MCPGroup{} - groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: mcpRemoteProxy.Spec.GroupRef} + groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: groupName} if err := r.Get(ctx, groupKey, group); err != nil { ctxLogger.Error(err, "Failed to get MCPGroup for MCPRemoteProxy", - "namespace", obj.GetNamespace(), "name", mcpRemoteProxy.Spec.GroupRef) + "namespace", obj.GetNamespace(), "name", groupName) return []ctrl.Request{} } return []ctrl.Request{ @@ -508,7 +510,8 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPServerEntry(ctx context.Context, ctxLogger.Error(nil, "Object is not an MCPServerEntry", "object", obj.GetName()) return []ctrl.Request{} } - if mcpServerEntry.Spec.GroupRef == "" { + groupName := mcpServerEntry.Spec.GroupRef.GetName() + if groupName == "" { return []ctrl.Request{} } @@ -516,12 +519,12 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPServerEntry(ctx context.Context, "Finding MCPGroup for MCPServerEntry", "namespace", obj.GetNamespace(), "mcpserverentry", obj.GetName(), - "groupRef", mcpServerEntry.Spec.GroupRef) + "groupRef", groupName) group := &mcpv1alpha1.MCPGroup{} - groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: mcpServerEntry.Spec.GroupRef} + groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: groupName} if err := r.Get(ctx, groupKey, group); err != nil { ctxLogger.Error(err, "Failed to get MCPGroup for MCPServerEntry", - "namespace", obj.GetNamespace(), "name", mcpServerEntry.Spec.GroupRef) + "namespace", obj.GetNamespace(), "name", groupName) return []ctrl.Request{} } return []ctrl.Request{ diff --git a/cmd/thv-operator/controllers/mcpgroup_controller_test.go b/cmd/thv-operator/controllers/mcpgroup_controller_test.go index e0618f99d6..82b8b80471 100644 --- a/cmd/thv-operator/controllers/mcpgroup_controller_test.go +++ b/cmd/thv-operator/controllers/mcpgroup_controller_test.go @@ -53,7 +53,7 @@ func TestMCPGroupReconciler_Reconcile_BasicLogic(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: testGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, Status: mcpv1alpha1.MCPServerStatus{ Phase: mcpv1alpha1.MCPServerPhaseReady, @@ -66,7 +66,7 @@ func TestMCPGroupReconciler_Reconcile_BasicLogic(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: testGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, Status: mcpv1alpha1.MCPServerStatus{ Phase: mcpv1alpha1.MCPServerPhaseReady, @@ -93,7 +93,7 @@ func TestMCPGroupReconciler_Reconcile_BasicLogic(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: testGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, Status: mcpv1alpha1.MCPServerStatus{ Phase: mcpv1alpha1.MCPServerPhaseReady, @@ -106,7 +106,7 @@ func TestMCPGroupReconciler_Reconcile_BasicLogic(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: testGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, Status: mcpv1alpha1.MCPServerStatus{ Phase: mcpv1alpha1.MCPServerPhaseFailed, @@ -133,7 +133,7 @@ func TestMCPGroupReconciler_Reconcile_BasicLogic(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: testGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, Status: mcpv1alpha1.MCPServerStatus{ Phase: mcpv1alpha1.MCPServerPhaseReady, @@ -146,7 +146,7 @@ func TestMCPGroupReconciler_Reconcile_BasicLogic(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: testGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, Status: mcpv1alpha1.MCPServerStatus{ Phase: mcpv1alpha1.MCPServerPhasePending, @@ -193,24 +193,24 @@ func TestMCPGroupReconciler_Reconcile_BasicLogic(t *testing.T) { WithStatusSubresource(&mcpv1alpha1.MCPGroup{}). WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + if mcpServer.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{mcpServer.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -267,15 +267,15 @@ func TestMCPGroupReconciler_ServerFiltering(t *testing.T) { mcpServers: []*mcpv1alpha1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{Name: "server1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: testGroupName}, + Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server2", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: "other-group"}, + Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server3", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: testGroupName}, + Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, }, }, expectedServerNames: []string{"server1", "server3"}, @@ -288,7 +288,7 @@ func TestMCPGroupReconciler_ServerFiltering(t *testing.T) { mcpServers: []*mcpv1alpha1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{Name: "server1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: testGroupName}, + Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server2", Namespace: "default"}, @@ -305,11 +305,11 @@ func TestMCPGroupReconciler_ServerFiltering(t *testing.T) { mcpServers: []*mcpv1alpha1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{Name: "server1", Namespace: "namespace-a"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: testGroupName}, + Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server2", Namespace: "namespace-b"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: testGroupName}, + Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, }, }, expectedServerNames: []string{"server1"}, @@ -344,24 +344,24 @@ func TestMCPGroupReconciler_ServerFiltering(t *testing.T) { WithStatusSubresource(&mcpv1alpha1.MCPGroup{}). WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + if mcpServer.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{mcpServer.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -416,7 +416,7 @@ func TestMCPGroupReconciler_findMCPGroupForMCPServer(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: testGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, }, mcpGroups: []*mcpv1alpha1.MCPGroup{ @@ -461,7 +461,7 @@ func TestMCPGroupReconciler_findMCPGroupForMCPServer(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: "non-existent-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "non-existent-group"}, }, }, mcpGroups: []*mcpv1alpha1.MCPGroup{ @@ -483,7 +483,7 @@ func TestMCPGroupReconciler_findMCPGroupForMCPServer(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: "group-b", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-b"}, }, }, mcpGroups: []*mcpv1alpha1.MCPGroup{ @@ -531,24 +531,24 @@ func TestMCPGroupReconciler_findMCPGroupForMCPServer(t *testing.T) { WithObjects(objs...). WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + if mcpServer.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{mcpServer.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -580,24 +580,24 @@ func TestMCPGroupReconciler_GroupNotFound(t *testing.T) { WithScheme(scheme). WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + if mcpServer.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{mcpServer.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -646,7 +646,7 @@ func TestMCPGroupReconciler_Conditions(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: testGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, }, }, @@ -689,24 +689,24 @@ func TestMCPGroupReconciler_Conditions(t *testing.T) { WithStatusSubresource(&mcpv1alpha1.MCPGroup{}). WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + if mcpServer.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{mcpServer.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -777,24 +777,24 @@ func TestMCPGroupReconciler_Finalizer(t *testing.T) { WithStatusSubresource(&mcpv1alpha1.MCPGroup{}, &mcpv1alpha1.MCPServer{}). WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + if mcpServer.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{mcpServer.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -846,7 +846,7 @@ func TestMCPGroupReconciler_Deletion(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: testGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, }, { @@ -856,7 +856,7 @@ func TestMCPGroupReconciler_Deletion(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: testGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, }, }, @@ -873,7 +873,7 @@ func TestMCPGroupReconciler_Deletion(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: "other-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, }, }, }, @@ -912,24 +912,24 @@ func TestMCPGroupReconciler_Deletion(t *testing.T) { WithStatusSubresource(&mcpv1alpha1.MCPGroup{}, &mcpv1alpha1.MCPServer{}). WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + if mcpServer.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{mcpServer.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -960,7 +960,7 @@ func TestMCPGroupReconciler_Deletion(t *testing.T) { // If servers should be updated, verify their conditions if tt.shouldUpdateServers { for _, server := range tt.mcpServers { - if server.Spec.GroupRef == testGroupName { + if server.Spec.GroupRef.GetName() == testGroupName { var updatedServer mcpv1alpha1.MCPServer err = fakeClient.Get(ctx, types.NamespacedName{ Name: server.Name, @@ -1007,15 +1007,15 @@ func TestMCPGroupReconciler_findReferencingMCPServers(t *testing.T) { mcpServers: []*mcpv1alpha1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{Name: "server1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: testGroupName}, + Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server2", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: "other-group"}, + Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server3", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: testGroupName}, + Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, }, }, expectedCount: 2, @@ -1028,7 +1028,7 @@ func TestMCPGroupReconciler_findReferencingMCPServers(t *testing.T) { mcpServers: []*mcpv1alpha1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{Name: "server1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: "other-group"}, + Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}}, }, }, expectedCount: 0, @@ -1041,11 +1041,11 @@ func TestMCPGroupReconciler_findReferencingMCPServers(t *testing.T) { mcpServers: []*mcpv1alpha1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{Name: "server1", Namespace: "namespace-a"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: testGroupName}, + Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server2", Namespace: "namespace-b"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: testGroupName}, + Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, }, }, expectedCount: 1, @@ -1079,24 +1079,24 @@ func TestMCPGroupReconciler_findReferencingMCPServers(t *testing.T) { WithObjects(objs...). WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + if mcpServer.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{mcpServer.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -1138,15 +1138,15 @@ func TestMCPGroupReconciler_findReferencingMCPRemoteProxies(t *testing.T) { mcpRemoteProxies: []*mcpv1alpha1.MCPRemoteProxy{ { ObjectMeta: metav1.ObjectMeta{Name: "proxy1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: testGroupName}, + Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "proxy2", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: "other-group"}, + Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "proxy3", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: testGroupName}, + Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, }, }, expectedCount: 2, @@ -1159,7 +1159,7 @@ func TestMCPGroupReconciler_findReferencingMCPRemoteProxies(t *testing.T) { mcpRemoteProxies: []*mcpv1alpha1.MCPRemoteProxy{ { ObjectMeta: metav1.ObjectMeta{Name: "proxy1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: "other-group"}, + Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}}, }, }, expectedCount: 0, @@ -1172,11 +1172,11 @@ func TestMCPGroupReconciler_findReferencingMCPRemoteProxies(t *testing.T) { mcpRemoteProxies: []*mcpv1alpha1.MCPRemoteProxy{ { ObjectMeta: metav1.ObjectMeta{Name: "proxy1", Namespace: "namespace-a"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: testGroupName}, + Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "proxy2", Namespace: "namespace-b"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: testGroupName}, + Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, }, }, expectedCount: 1, @@ -1218,24 +1218,24 @@ func TestMCPGroupReconciler_findReferencingMCPRemoteProxies(t *testing.T) { WithObjects(objs...). WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + if mcpServer.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{mcpServer.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -1277,7 +1277,7 @@ func TestMCPGroupReconciler_findMCPGroupForMCPRemoteProxy(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: testGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, }, mcpGroups: []*mcpv1alpha1.MCPGroup{ @@ -1320,7 +1320,7 @@ func TestMCPGroupReconciler_findMCPGroupForMCPRemoteProxy(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: "non-existent-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "non-existent-group"}, }, }, mcpGroups: []*mcpv1alpha1.MCPGroup{ @@ -1341,7 +1341,7 @@ func TestMCPGroupReconciler_findMCPGroupForMCPRemoteProxy(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: "group-b", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-b"}, }, }, mcpGroups: []*mcpv1alpha1.MCPGroup{ @@ -1389,24 +1389,24 @@ func TestMCPGroupReconciler_findMCPGroupForMCPRemoteProxy(t *testing.T) { WithObjects(objs...). WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + if mcpServer.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{mcpServer.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -1445,7 +1445,7 @@ func TestMCPGroupReconciler_updateReferencingRemoteProxiesOnDeletion(t *testing. Namespace: "default", }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: testGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, }, { @@ -1454,7 +1454,7 @@ func TestMCPGroupReconciler_updateReferencingRemoteProxiesOnDeletion(t *testing. Namespace: "default", }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: testGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, }, }, @@ -1488,24 +1488,24 @@ func TestMCPGroupReconciler_updateReferencingRemoteProxiesOnDeletion(t *testing. WithStatusSubresource(&mcpv1alpha1.MCPRemoteProxy{}). WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + if mcpServer.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{mcpServer.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go index 3538797223..94d6bacf3a 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go @@ -1088,7 +1088,7 @@ func (r *MCPRemoteProxyReconciler) updateOIDCConfigReferencingWorkloads( // This function only sets conditions on the proxy object - the caller is responsible // for persisting the status update to avoid multiple conflicting status updates. func (r *MCPRemoteProxyReconciler) validateGroupRef(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) { - if proxy.Spec.GroupRef == "" { + if proxy.Spec.GroupRef == nil { // No group reference - remove any existing GroupRefValidated condition // to avoid showing stale info from a previous reconciliation meta.RemoveStatusCondition(&proxy.Status.Conditions, mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated) @@ -1096,16 +1096,17 @@ func (r *MCPRemoteProxyReconciler) validateGroupRef(ctx context.Context, proxy * } ctxLogger := log.FromContext(ctx) + groupName := proxy.Spec.GroupRef.Name // Find the referenced MCPGroup group := &mcpv1alpha1.MCPGroup{} - if err := r.Get(ctx, types.NamespacedName{Namespace: proxy.Namespace, Name: proxy.Spec.GroupRef}, group); err != nil { + if err := r.Get(ctx, types.NamespacedName{Namespace: proxy.Namespace, Name: groupName}, group); err != nil { ctxLogger.Error(err, "Failed to validate GroupRef") meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated, Status: metav1.ConditionFalse, Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyGroupRefNotFound, - Message: fmt.Sprintf("MCPGroup '%s' not found in namespace '%s'", proxy.Spec.GroupRef, proxy.Namespace), + Message: fmt.Sprintf("MCPGroup '%s' not found in namespace '%s'", groupName, proxy.Namespace), ObservedGeneration: proxy.Generation, }) } else if group.Status.Phase != mcpv1alpha1.MCPGroupPhaseReady { @@ -1113,7 +1114,7 @@ func (r *MCPRemoteProxyReconciler) validateGroupRef(ctx context.Context, proxy * Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated, Status: metav1.ConditionFalse, Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyGroupRefNotReady, - Message: fmt.Sprintf("MCPGroup '%s' is not ready (current phase: %s)", proxy.Spec.GroupRef, group.Status.Phase), + Message: fmt.Sprintf("MCPGroup '%s' is not ready (current phase: %s)", groupName, group.Status.Phase), ObservedGeneration: proxy.Generation, }) } else { @@ -1121,7 +1122,7 @@ func (r *MCPRemoteProxyReconciler) validateGroupRef(ctx context.Context, proxy * Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated, Status: metav1.ConditionTrue, Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyGroupRefValidated, - Message: fmt.Sprintf("MCPGroup '%s' is valid and ready", proxy.Spec.GroupRef), + Message: fmt.Sprintf("MCPGroup '%s' is valid and ready", groupName), ObservedGeneration: proxy.Generation, }) } diff --git a/cmd/thv-operator/controllers/mcpserver_controller.go b/cmd/thv-operator/controllers/mcpserver_controller.go index cfe8bb34f8..518b0c07c4 100644 --- a/cmd/thv-operator/controllers/mcpserver_controller.go +++ b/cmd/thv-operator/controllers/mcpserver_controller.go @@ -493,22 +493,23 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } func (r *MCPServerReconciler) validateGroupRef(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) { - if mcpServer.Spec.GroupRef == "" { + if mcpServer.Spec.GroupRef == nil { // No group reference, nothing to validate return } ctxLogger := log.FromContext(ctx) + groupName := mcpServer.Spec.GroupRef.Name // Find the referenced MCPGroup group := &mcpv1alpha1.MCPGroup{} - if err := r.Get(ctx, types.NamespacedName{Namespace: mcpServer.Namespace, Name: mcpServer.Spec.GroupRef}, group); err != nil { + if err := r.Get(ctx, types.NamespacedName{Namespace: mcpServer.Namespace, Name: groupName}, group); err != nil { ctxLogger.Error(err, "Failed to validate GroupRef") meta.SetStatusCondition(&mcpServer.Status.Conditions, metav1.Condition{ Type: mcpv1alpha1.ConditionGroupRefValidated, Status: metav1.ConditionFalse, Reason: mcpv1alpha1.ConditionReasonGroupRefNotFound, - Message: fmt.Sprintf("MCPGroup '%s' not found in namespace '%s'", mcpServer.Spec.GroupRef, mcpServer.Namespace), + Message: fmt.Sprintf("MCPGroup '%s' not found in namespace '%s'", groupName, mcpServer.Namespace), ObservedGeneration: mcpServer.Generation, }) } else if group.Status.Phase != mcpv1alpha1.MCPGroupPhaseReady { @@ -516,7 +517,7 @@ func (r *MCPServerReconciler) validateGroupRef(ctx context.Context, mcpServer *m Type: mcpv1alpha1.ConditionGroupRefValidated, Status: metav1.ConditionFalse, Reason: mcpv1alpha1.ConditionReasonGroupRefNotReady, - Message: fmt.Sprintf("MCPGroup '%s' is not ready (current phase: %s)", mcpServer.Spec.GroupRef, group.Status.Phase), + Message: fmt.Sprintf("MCPGroup '%s' is not ready (current phase: %s)", groupName, group.Status.Phase), ObservedGeneration: mcpServer.Generation, }) } else { @@ -524,7 +525,7 @@ func (r *MCPServerReconciler) validateGroupRef(ctx context.Context, mcpServer *m Type: mcpv1alpha1.ConditionGroupRefValidated, Status: metav1.ConditionTrue, Reason: mcpv1alpha1.ConditionReasonGroupRefValidated, - Message: fmt.Sprintf("MCPGroup '%s' is valid and ready", mcpServer.Spec.GroupRef), + Message: fmt.Sprintf("MCPGroup '%s' is valid and ready", groupName), ObservedGeneration: mcpServer.Generation, }) } diff --git a/cmd/thv-operator/controllers/mcpserver_groupref_test.go b/cmd/thv-operator/controllers/mcpserver_groupref_test.go index 988d3b346c..41580f85d5 100644 --- a/cmd/thv-operator/controllers/mcpserver_groupref_test.go +++ b/cmd/thv-operator/controllers/mcpserver_groupref_test.go @@ -40,7 +40,7 @@ func TestMCPServerReconciler_ValidateGroupRef(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, mcpGroups: []*mcpv1alpha1.MCPGroup{ @@ -66,7 +66,7 @@ func TestMCPServerReconciler_ValidateGroupRef(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: "non-existent-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "non-existent-group"}, }, }, mcpGroups: []*mcpv1alpha1.MCPGroup{}, @@ -82,7 +82,7 @@ func TestMCPServerReconciler_ValidateGroupRef(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, mcpGroups: []*mcpv1alpha1.MCPGroup{ @@ -109,7 +109,7 @@ func TestMCPServerReconciler_ValidateGroupRef(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, mcpGroups: []*mcpv1alpha1.MCPGroup{ @@ -221,7 +221,7 @@ func TestMCPServerReconciler_GroupRefValidation_Integration(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, mcpGroup: &mcpv1alpha1.MCPGroup{ @@ -244,7 +244,7 @@ func TestMCPServerReconciler_GroupRefValidation_Integration(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, mcpGroup: &mcpv1alpha1.MCPGroup{ @@ -314,7 +314,7 @@ func TestMCPServerReconciler_GroupRefCrossNamespace(t *testing.T) { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, } diff --git a/cmd/thv-operator/controllers/mcpserverentry_controller.go b/cmd/thv-operator/controllers/mcpserverentry_controller.go index f274262eed..035497c61b 100644 --- a/cmd/thv-operator/controllers/mcpserverentry_controller.go +++ b/cmd/thv-operator/controllers/mcpserverentry_controller.go @@ -161,8 +161,9 @@ func (r *MCPServerEntryReconciler) validateGroupRef( entry *mcpv1alpha1.MCPServerEntry, ) (bool, error) { ctxLogger := log.FromContext(ctx) + groupName := entry.Spec.GroupRef.GetName() group := &mcpv1alpha1.MCPGroup{} - groupKey := types.NamespacedName{Namespace: entry.Namespace, Name: entry.Spec.GroupRef} + groupKey := types.NamespacedName{Namespace: entry.Namespace, Name: groupName} if err := r.Get(ctx, groupKey, group); err != nil { if errors.IsNotFound(err) { @@ -170,7 +171,7 @@ func (r *MCPServerEntryReconciler) validateGroupRef( Type: mcpv1alpha1.ConditionTypeMCPServerEntryGroupRefValidated, Status: metav1.ConditionFalse, Reason: mcpv1alpha1.ConditionReasonMCPServerEntryGroupRefNotFound, - Message: fmt.Sprintf("MCPGroup '%s' not found in namespace '%s'", entry.Spec.GroupRef, entry.Namespace), + Message: fmt.Sprintf("MCPGroup '%s' not found in namespace '%s'", groupName, entry.Namespace), ObservedGeneration: entry.Generation, }) return false, nil @@ -185,7 +186,7 @@ func (r *MCPServerEntryReconciler) validateGroupRef( Type: mcpv1alpha1.ConditionTypeMCPServerEntryGroupRefValidated, Status: metav1.ConditionFalse, Reason: mcpv1alpha1.ConditionReasonMCPServerEntryGroupRefNotReady, - Message: fmt.Sprintf("MCPGroup '%s' is not ready (current phase: %s)", entry.Spec.GroupRef, group.Status.Phase), + Message: fmt.Sprintf("MCPGroup '%s' is not ready (current phase: %s)", groupName, group.Status.Phase), ObservedGeneration: entry.Generation, }) return false, nil diff --git a/cmd/thv-operator/controllers/mcpserverentry_controller_test.go b/cmd/thv-operator/controllers/mcpserverentry_controller_test.go index c138fde906..6da50ffa3b 100644 --- a/cmd/thv-operator/controllers/mcpserverentry_controller_test.go +++ b/cmd/thv-operator/controllers/mcpserverentry_controller_test.go @@ -74,7 +74,7 @@ func newMCPServerEntry( Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://example.com/mcp", Transport: "sse", - GroupRef: groupRef, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupRef}, ExternalAuthConfigRef: authConfigRef, CABundleRef: caBundleRef, }, @@ -408,11 +408,11 @@ func TestMCPGroupReconciler_MCPServerEntryIntegration(t *testing.T) { } entry1 := &mcpv1alpha1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{Name: "entry1", Namespace: testEntryNS}, - Spec: mcpv1alpha1.MCPServerEntrySpec{RemoteURL: "https://a.example.com", Transport: "sse", GroupRef: testEntryGroupRef}, + Spec: mcpv1alpha1.MCPServerEntrySpec{RemoteURL: "https://a.example.com", Transport: "sse", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testEntryGroupRef}}, } entry2 := &mcpv1alpha1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{Name: "entry2", Namespace: testEntryNS}, - Spec: mcpv1alpha1.MCPServerEntrySpec{RemoteURL: "https://b.example.com", Transport: "sse", GroupRef: testEntryGroupRef}, + Spec: mcpv1alpha1.MCPServerEntrySpec{RemoteURL: "https://b.example.com", Transport: "sse", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testEntryGroupRef}}, } fakeClient := fake.NewClientBuilder(). @@ -421,24 +421,24 @@ func TestMCPGroupReconciler_MCPServerEntryIntegration(t *testing.T) { WithStatusSubresource(&mcpv1alpha1.MCPGroup{}, &mcpv1alpha1.MCPServerEntry{}). WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { s := obj.(*mcpv1alpha1.MCPServer) - if s.Spec.GroupRef == "" { + if s.Spec.GroupRef.GetName() == "" { return nil } - return []string{s.Spec.GroupRef} + return []string{s.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { p := obj.(*mcpv1alpha1.MCPRemoteProxy) - if p.Spec.GroupRef == "" { + if p.Spec.GroupRef.GetName() == "" { return nil } - return []string{p.Spec.GroupRef} + return []string{p.Spec.GroupRef.GetName()} }). WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { e := obj.(*mcpv1alpha1.MCPServerEntry) - if e.Spec.GroupRef == "" { + if e.Spec.GroupRef.GetName() == "" { return nil } - return []string{e.Spec.GroupRef} + return []string{e.Spec.GroupRef.GetName()} }). Build() @@ -480,11 +480,11 @@ func TestMCPGroupReconciler_EntryDeletionHandler(t *testing.T) { entry1 := &mcpv1alpha1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{Name: "entry1", Namespace: testEntryNS}, - Spec: mcpv1alpha1.MCPServerEntrySpec{RemoteURL: "https://a.example.com", Transport: "sse", GroupRef: testEntryGroupRef}, + Spec: mcpv1alpha1.MCPServerEntrySpec{RemoteURL: "https://a.example.com", Transport: "sse", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testEntryGroupRef}}, } entry2 := &mcpv1alpha1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{Name: "entry2", Namespace: testEntryNS}, - Spec: mcpv1alpha1.MCPServerEntrySpec{RemoteURL: "https://b.example.com", Transport: "sse", GroupRef: testEntryGroupRef}, + Spec: mcpv1alpha1.MCPServerEntrySpec{RemoteURL: "https://b.example.com", Transport: "sse", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testEntryGroupRef}}, } fakeClient := fake.NewClientBuilder(). diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index bb461b77b8..c8d65b25f3 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -482,12 +482,12 @@ func (r *VirtualMCPServerReconciler) validateGroupRef( // Validate GroupRef exists mcpGroup := &mcpv1alpha1.MCPGroup{} err := r.Get(ctx, types.NamespacedName{ - Name: vmcp.Spec.Config.Group, + Name: vmcp.ResolveGroupName(), Namespace: vmcp.Namespace, }, mcpGroup) if errors.IsNotFound(err) { - message := fmt.Sprintf("Referenced MCPGroup %s not found", vmcp.Spec.Config.Group) + message := fmt.Sprintf("Referenced MCPGroup %s not found", vmcp.ResolveGroupName()) statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed) statusManager.SetMessage(message) statusManager.SetGroupRefValidatedCondition( @@ -505,7 +505,7 @@ func (r *VirtualMCPServerReconciler) validateGroupRef( // Check if MCPGroup is ready if mcpGroup.Status.Phase != mcpv1alpha1.MCPGroupPhaseReady { message := fmt.Sprintf("Referenced MCPGroup %s is not ready (phase: %s)", - vmcp.Spec.Config.Group, mcpGroup.Status.Phase) + vmcp.ResolveGroupName(), mcpGroup.Status.Phase) statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhasePending) statusManager.SetMessage(message) statusManager.SetGroupRefValidatedCondition( @@ -515,13 +515,13 @@ func (r *VirtualMCPServerReconciler) validateGroupRef( ) statusManager.SetObservedGeneration(vmcp.Generation) // Requeue to check again later - return fmt.Errorf("MCPGroup %s is not ready", vmcp.Spec.Config.Group) + return fmt.Errorf("MCPGroup %s is not ready", vmcp.ResolveGroupName()) } // GroupRef is valid and ready statusManager.SetGroupRefValidatedCondition( mcpv1alpha1.ConditionReasonVirtualMCPServerGroupRefValid, - fmt.Sprintf("MCPGroup %s is valid and ready", vmcp.Spec.Config.Group), + fmt.Sprintf("MCPGroup %s is valid and ready", vmcp.ResolveGroupName()), metav1.ConditionTrue, ) statusManager.SetObservedGeneration(vmcp.Generation) @@ -712,7 +712,7 @@ func (r *VirtualMCPServerReconciler) ensureAllResources( // This ensures consistency - all functions use the same workload list // rather than listing at different times which could yield different results workloadDiscoverer := workloads.NewK8SDiscovererWithClient(r.Client, vmcp.Namespace) - workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcp.Spec.Config.Group) + workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcp.ResolveGroupName()) if err != nil { ctxLogger.Error(err, "Failed to list workloads in group") return ctrl.Result{}, fmt.Errorf("failed to list workloads in group: %w", err) @@ -2262,7 +2262,7 @@ func (r *VirtualMCPServerReconciler) mapMCPGroupToVirtualMCPServer(ctx context.C var requests []reconcile.Request for _, vmcp := range vmcpList.Items { - if vmcp.Spec.Config.Group == mcpGroup.Name { + if vmcp.ResolveGroupName() == mcpGroup.Name { requests = append(requests, reconcile.Request{ NamespacedName: types.NamespacedName{ Name: vmcp.Name, @@ -2333,7 +2333,7 @@ func (r *VirtualMCPServerReconciler) mapMCPServerToVirtualMCPServer(ctx context. var requests []reconcile.Request for _, vmcp := range vmcpList.Items { // Only reconcile if this VirtualMCPServer references an affected MCPGroup - if affectedGroups[vmcp.Spec.Config.Group] { + if affectedGroups[vmcp.ResolveGroupName()] { requests = append(requests, reconcile.Request{ NamespacedName: types.NamespacedName{ Name: vmcp.Name, @@ -2342,7 +2342,7 @@ func (r *VirtualMCPServerReconciler) mapMCPServerToVirtualMCPServer(ctx context. }) ctxLogger.V(1).Info("Queuing VirtualMCPServer for reconciliation due to MCPServer change", "virtualMCPServer", vmcp.Name, - "mcpGroup", vmcp.Spec.Config.Group, + "mcpGroup", vmcp.ResolveGroupName(), "mcpServer", mcpServer.Name) } } @@ -2414,7 +2414,7 @@ func (r *VirtualMCPServerReconciler) mapMCPRemoteProxyToVirtualMCPServer( var requests []reconcile.Request for _, vmcp := range vmcpList.Items { // Only reconcile if this VirtualMCPServer references an affected MCPGroup - if affectedGroups[vmcp.Spec.Config.Group] { + if affectedGroups[vmcp.ResolveGroupName()] { requests = append(requests, reconcile.Request{ NamespacedName: types.NamespacedName{ Name: vmcp.Name, @@ -2423,7 +2423,7 @@ func (r *VirtualMCPServerReconciler) mapMCPRemoteProxyToVirtualMCPServer( }) ctxLogger.V(1).Info("Queuing VirtualMCPServer for reconciliation due to MCPRemoteProxy change", "virtualMCPServer", vmcp.Name, - "mcpGroup", vmcp.Spec.Config.Group, + "mcpGroup", vmcp.ResolveGroupName(), "mcpRemoteProxy", mcpRemoteProxy.Name) } } @@ -2490,7 +2490,7 @@ func (r *VirtualMCPServerReconciler) mapMCPServerEntryToVirtualMCPServer( var requests []reconcile.Request for _, vmcp := range vmcpList.Items { - if affectedGroups[vmcp.Spec.Config.Group] { + if affectedGroups[vmcp.ResolveGroupName()] { requests = append(requests, reconcile.Request{ NamespacedName: types.NamespacedName{ Name: vmcp.Name, @@ -2499,7 +2499,7 @@ func (r *VirtualMCPServerReconciler) mapMCPServerEntryToVirtualMCPServer( }) ctxLogger.V(1).Info("Queuing VirtualMCPServer for reconciliation due to MCPServerEntry change", "virtualMCPServer", vmcp.Name, - "mcpGroup", vmcp.Spec.Config.Group, + "mcpGroup", vmcp.ResolveGroupName(), "mcpServerEntry", mcpServerEntry.Name) } } @@ -2642,14 +2642,14 @@ func (r *VirtualMCPServerReconciler) mcpGroupBackendsReferenceExternalAuthConfig // Get the MCPGroup to verify it exists mcpGroup := &mcpv1alpha1.MCPGroup{} err := r.Get(ctx, types.NamespacedName{ - Name: vmcp.Spec.Config.Group, + Name: vmcp.ResolveGroupName(), Namespace: vmcp.Namespace, }, mcpGroup) if err != nil { // If we can't get the group, we can't determine if it references the auth config // Return false to avoid false positives ctxLogger.Error(err, "Failed to get MCPGroup for ExternalAuthConfig reference check", - "group", vmcp.Spec.Config.Group, + "group", vmcp.ResolveGroupName(), "vmcp", vmcp.Name) return false } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go index 0ea0f05d7c..89db8ecd03 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go @@ -722,7 +722,7 @@ func TestBuildCABundleVolumesForEntries(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -740,7 +740,7 @@ func TestBuildCABundleVolumesForEntries(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, CABundleRef: &mcpv1alpha1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "my-ca-configmap"}, @@ -776,7 +776,7 @@ func TestBuildCABundleVolumesForEntries(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, CABundleRef: &mcpv1alpha1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "custom-ca"}, @@ -806,7 +806,7 @@ func TestBuildCABundleVolumesForEntries(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, CABundleRef: &mcpv1alpha1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "ca-cm"}, @@ -819,7 +819,7 @@ func TestBuildCABundleVolumesForEntries(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp2.example.com", Transport: "streamable-http", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, }, diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go index 5fe3572e2b..a4eae8bb90 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go @@ -221,7 +221,7 @@ func (r *VirtualMCPServerReconciler) discoverBackendsWithMetadata( // Build auth config if OutgoingAuth is configured var authConfig *vmcpconfig.OutgoingAuthConfig if vmcp.Spec.OutgoingAuth != nil { - typedWorkloads, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcp.Spec.Config.Group) + typedWorkloads, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcp.ResolveGroupName()) if err != nil { return nil, fmt.Errorf("failed to list workloads in group: %w", err) } @@ -233,7 +233,7 @@ func (r *VirtualMCPServerReconciler) discoverBackendsWithMetadata( } backendDiscoverer := aggregator.NewUnifiedBackendDiscoverer(workloadDiscoverer, groupsManager, authConfig) - backends, err := backendDiscoverer.Discover(ctx, vmcp.Spec.Config.Group) + backends, err := backendDiscoverer.Discover(ctx, vmcp.ResolveGroupName()) if err != nil { return nil, fmt.Errorf("failed to discover backends: %w", err) } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go index a27acd11d4..181ae6fcd0 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go @@ -1522,7 +1522,7 @@ func TestConfigMapContent_StaticMode_InlineOverrides(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Transport: "sse", // Required for backend discovery }, Status: mcpv1alpha1.MCPServerStatus{ @@ -1639,7 +1639,7 @@ func TestConfigMapContent_StaticModeWithDiscovery(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Transport: "sse", // Required for static mode backend discovery ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "test-auth-config", @@ -2327,7 +2327,7 @@ func TestBuildCABundlePathMap(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -2344,7 +2344,7 @@ func TestBuildCABundlePathMap(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, CABundleRef: &mcpv1alpha1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "ca-cm"}, @@ -2368,7 +2368,7 @@ func TestBuildCABundlePathMap(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, CABundleRef: &mcpv1alpha1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "ca-cm"}, @@ -2393,7 +2393,7 @@ func TestBuildCABundlePathMap(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, CABundleRef: &mcpv1alpha1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "ca-cm"}, @@ -2406,7 +2406,7 @@ func TestBuildCABundlePathMap(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp2.example.com", Transport: "sse", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, }, diff --git a/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go b/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go index a816da9102..782bfb1a34 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go @@ -899,7 +899,7 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "test-auth", }, @@ -946,7 +946,7 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "other-auth", }, @@ -993,7 +993,7 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "test-auth", }, @@ -1040,7 +1040,7 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "other-auth", }, @@ -1082,17 +1082,19 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { WithObjects(objs...). WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + name := mcpServer.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{name} }). WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + name := mcpRemoteProxy.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }). Build() @@ -1528,7 +1530,7 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "test-auth", }, @@ -1567,7 +1569,7 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "other-auth", }, @@ -1623,7 +1625,7 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "other-auth", }, @@ -1635,7 +1637,7 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "test-auth", }, @@ -1647,7 +1649,7 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -1683,7 +1685,7 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "test-auth", }, @@ -1722,7 +1724,7 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "other-auth", }, @@ -1764,17 +1766,19 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { WithObjects(objs...). WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + name := mcpServer.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{name} }). WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + name := mcpRemoteProxy.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }). Build() diff --git a/cmd/thv-operator/main.go b/cmd/thv-operator/main.go index 522397ef6b..1068eb604b 100644 --- a/cmd/thv-operator/main.go +++ b/cmd/thv-operator/main.go @@ -198,10 +198,11 @@ func setupGroupRefFieldIndexes(mgr ctrl.Manager) error { "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + name := mcpServer.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{name} }, ); err != nil { return fmt.Errorf("unable to create field index for MCPServer spec.groupRef: %w", err) @@ -214,10 +215,11 @@ func setupGroupRefFieldIndexes(mgr ctrl.Manager) error { "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + name := mcpRemoteProxy.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }, ); err != nil { return fmt.Errorf("unable to create field index for MCPRemoteProxy spec.groupRef: %w", err) @@ -230,10 +232,11 @@ func setupGroupRefFieldIndexes(mgr ctrl.Manager) error { "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + name := mcpServerEntry.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{name} }, ); err != nil { return fmt.Errorf("unable to create field index for MCPServerEntry spec.groupRef: %w", err) diff --git a/cmd/thv-operator/test-integration/mcp-group/mcpgroup_controller_integration_test.go b/cmd/thv-operator/test-integration/mcp-group/mcpgroup_controller_integration_test.go index 5bffcf0630..d262095a12 100644 --- a/cmd/thv-operator/test-integration/mcp-group/mcpgroup_controller_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-group/mcpgroup_controller_integration_test.go @@ -52,7 +52,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, server1)).Should(Succeed()) @@ -64,7 +64,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, server2)).Should(Succeed()) @@ -263,7 +263,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, newServer)).Should(Succeed()) @@ -332,7 +332,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, server1)).Should(Succeed()) @@ -344,7 +344,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, server2)).Should(Succeed()) @@ -476,7 +476,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, server1)).Should(Succeed()) @@ -488,7 +488,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, server2)).Should(Succeed()) @@ -635,7 +635,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, serverA)).Should(Succeed()) @@ -648,7 +648,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: mcpGroupName, // Same group name, different namespace + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, // Same group name, different namespace }, } Expect(k8sClient.Create(ctx, serverB)).Should(Succeed()) diff --git a/cmd/thv-operator/test-integration/mcp-group/suite_test.go b/cmd/thv-operator/test-integration/mcp-group/suite_test.go index c41e0a9f2b..69facd2053 100644 --- a/cmd/thv-operator/test-integration/mcp-group/suite_test.go +++ b/cmd/thv-operator/test-integration/mcp-group/suite_test.go @@ -107,10 +107,11 @@ var _ = BeforeSuite(func() { "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + name := mcpServer.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{name} }, ) Expect(err).ToNot(HaveOccurred()) @@ -122,10 +123,11 @@ var _ = BeforeSuite(func() { "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + name := mcpRemoteProxy.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }, ) Expect(err).ToNot(HaveOccurred()) @@ -137,10 +139,11 @@ var _ = BeforeSuite(func() { "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + name := mcpServerEntry.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{name} }, ) Expect(err).ToNot(HaveOccurred()) diff --git a/cmd/thv-operator/test-integration/mcp-oidc-config/suite_test.go b/cmd/thv-operator/test-integration/mcp-oidc-config/suite_test.go index 7c97130b9f..a849394049 100644 --- a/cmd/thv-operator/test-integration/mcp-oidc-config/suite_test.go +++ b/cmd/thv-operator/test-integration/mcp-oidc-config/suite_test.go @@ -104,10 +104,11 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPServer.Spec.GroupRef (required by VirtualMCPServer controller) if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + name := mcpServer.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } @@ -115,10 +116,11 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPRemoteProxy.Spec.GroupRef (required by VirtualMCPServer controller) if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + name := mcpRemoteProxy.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } @@ -130,10 +132,11 @@ var _ = BeforeSuite(func() { "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + name := mcpServerEntry.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{name} }, ) Expect(err).ToNot(HaveOccurred()) diff --git a/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go b/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go index 5ae72a1ca8..700a42628e 100644 --- a/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go +++ b/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go @@ -145,7 +145,7 @@ func (rb *RemoteProxyBuilder) WithToolConfigRef(name string) *RemoteProxyBuilder // WithGroupRef sets the GroupRef for the proxy func (rb *RemoteProxyBuilder) WithGroupRef(name string) *RemoteProxyBuilder { - rb.proxy.Spec.GroupRef = name + rb.proxy.Spec.GroupRef = &mcpv1alpha1.MCPGroupRef{Name: name} return rb } diff --git a/cmd/thv-operator/test-integration/mcp-remote-proxy/suite_test.go b/cmd/thv-operator/test-integration/mcp-remote-proxy/suite_test.go index c98c633472..3502c9dbd5 100644 --- a/cmd/thv-operator/test-integration/mcp-remote-proxy/suite_test.go +++ b/cmd/thv-operator/test-integration/mcp-remote-proxy/suite_test.go @@ -106,10 +106,11 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPServer.Spec.GroupRef (required by MCPGroup controller) if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + name := mcpServer.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } @@ -117,10 +118,11 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPRemoteProxy.Spec.GroupRef if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + name := mcpRemoteProxy.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } @@ -132,10 +134,11 @@ var _ = BeforeSuite(func() { "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + name := mcpServerEntry.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{name} }, ) Expect(err).ToNot(HaveOccurred()) diff --git a/cmd/thv-operator/test-integration/mcp-server/mcpserver_controller_integration_test.go b/cmd/thv-operator/test-integration/mcp-server/mcpserver_controller_integration_test.go index 846cb3944b..f40d642ea7 100644 --- a/cmd/thv-operator/test-integration/mcp-server/mcpserver_controller_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/mcpserver_controller_integration_test.go @@ -1018,7 +1018,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { Image: "ghcr.io/stackloklabs/mcp-fetch:latest", Transport: "stdio", ProxyPort: 8080, - GroupRef: "non-existent-group", // This group doesn't exist + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "non-existent-group"}, // This group doesn't exist }, } @@ -1163,7 +1163,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { Image: "ghcr.io/stackloklabs/mcp-fetch:latest", Transport: "stdio", ProxyPort: 8080, - GroupRef: mcpGroupName, // This group exists + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, // This group exists }, } diff --git a/cmd/thv-operator/test-integration/mcp-server/suite_test.go b/cmd/thv-operator/test-integration/mcp-server/suite_test.go index 71f0343f47..1ef7f960e4 100644 --- a/cmd/thv-operator/test-integration/mcp-server/suite_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/suite_test.go @@ -106,10 +106,11 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPServer.Spec.GroupRef if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + name := mcpServer.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } @@ -117,10 +118,11 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPRemoteProxy.Spec.GroupRef if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + name := mcpRemoteProxy.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } @@ -132,10 +134,11 @@ var _ = BeforeSuite(func() { "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + name := mcpServerEntry.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{name} }, ) Expect(err).ToNot(HaveOccurred()) diff --git a/cmd/thv-operator/test-integration/virtualmcp/suite_test.go b/cmd/thv-operator/test-integration/virtualmcp/suite_test.go index bccc2edd7e..a8807ff6f8 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/suite_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/suite_test.go @@ -106,10 +106,11 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPServer.Spec.GroupRef if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { mcpServer := obj.(*mcpv1alpha1.MCPServer) - if mcpServer.Spec.GroupRef == "" { + name := mcpServer.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } @@ -117,10 +118,11 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPRemoteProxy.Spec.GroupRef if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + name := mcpRemoteProxy.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } @@ -132,10 +134,11 @@ var _ = BeforeSuite(func() { "spec.groupRef", func(obj client.Object) []string { mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + name := mcpServerEntry.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{name} }, ) Expect(err).ToNot(HaveOccurred()) diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go index 75cd9773d3..ef925d39dd 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go @@ -91,7 +91,7 @@ var _ = Describe("VirtualMCPServer ExternalAuthConfig Watch Integration Tests", Namespace: namespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: "test-image:latest", Transport: "streamable-http", ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml index 7536126e1b..842fa1974e 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -170,9 +170,17 @@ spec: type: object groupRef: description: |- - GroupRef is the name of the MCPGroup this proxy belongs to - Must reference an existing MCPGroup in the same namespace - type: string + GroupRef references the MCPGroup this proxy belongs to. + The referenced MCPGroup must be in the same namespace. + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object headerForward: description: |- HeaderForward configures headers to inject into requests to the remote MCP server. diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpserverentries.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpserverentries.yaml index 3c584effe7..85b4f8bf51 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpserverentries.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpserverentries.yaml @@ -28,7 +28,7 @@ spec: - jsonPath: .spec.remoteUrl name: Remote URL type: string - - jsonPath: .spec.groupRef + - jsonPath: .spec.groupRef.name name: Group type: string - jsonPath: .metadata.creationTimestamp @@ -110,10 +110,17 @@ spec: type: object groupRef: description: |- - GroupRef is the name of the MCPGroup this entry belongs to. + GroupRef references the MCPGroup this entry belongs to. Required — every MCPServerEntry must be part of a group for vMCP discovery. - minLength: 1 - type: string + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object headerForward: description: |- HeaderForward configures headers to inject into requests to the remote MCP server. diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml index 49798ed54d..74c0f4817e 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml @@ -204,9 +204,17 @@ spec: type: object groupRef: description: |- - GroupRef is the name of the MCPGroup this server belongs to - Must reference an existing MCPGroup in the same namespace - type: string + GroupRef references the MCPGroup this server belongs to. + The referenced MCPGroup must be in the same namespace. + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object image: description: Image is the container image for the MCP server type: string diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 11aeb30400..246544f0d2 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -638,11 +638,9 @@ spec: type: object config: description: |- - Config is the Virtual MCP server configuration - The only field currently required within config is `config.groupRef`. - GroupRef references an existing MCPGroup that defines backend workloads. - The referenced MCPGroup must exist in the same namespace. + Config is the Virtual MCP server configuration. The audit config from here is also supported, but not required. + Note: config.groupRef is deprecated in favor of spec.groupRef. Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference a shared MCPTelemetryConfig resource instead. properties: @@ -1174,6 +1172,7 @@ spec: description: |- Group references an existing MCPGroup that defines backend workloads. In Kubernetes, the referenced MCPGroup must exist in the same namespace. + Deprecated: Use spec.groupRef on VirtualMCPServerSpec instead. type: string incomingAuth: description: |- @@ -1771,8 +1770,6 @@ spec: Currently defaults to true; this will change to false in a future release. type: boolean type: object - required: - - groupRef type: object x-kubernetes-preserve-unknown-fields: true embeddingServerRef: @@ -1788,6 +1785,20 @@ spec: required: - name type: object + groupRef: + description: |- + GroupRef references the MCPGroup that defines backend workloads. + The referenced MCPGroup must exist in the same namespace. + This field takes precedence over config.groupRef and should be preferred. + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object incomingAuth: description: |- IncomingAuth configures authentication for clients connecting to the Virtual MCP server. diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml index c56ef0e206..cbdb9c5183 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -173,9 +173,17 @@ spec: type: object groupRef: description: |- - GroupRef is the name of the MCPGroup this proxy belongs to - Must reference an existing MCPGroup in the same namespace - type: string + GroupRef references the MCPGroup this proxy belongs to. + The referenced MCPGroup must be in the same namespace. + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object headerForward: description: |- HeaderForward configures headers to inject into requests to the remote MCP server. diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml index 030644ea3b..7b08cd0223 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml @@ -31,7 +31,7 @@ spec: - jsonPath: .spec.remoteUrl name: Remote URL type: string - - jsonPath: .spec.groupRef + - jsonPath: .spec.groupRef.name name: Group type: string - jsonPath: .metadata.creationTimestamp @@ -113,10 +113,17 @@ spec: type: object groupRef: description: |- - GroupRef is the name of the MCPGroup this entry belongs to. + GroupRef references the MCPGroup this entry belongs to. Required — every MCPServerEntry must be part of a group for vMCP discovery. - minLength: 1 - type: string + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object headerForward: description: |- HeaderForward configures headers to inject into requests to the remote MCP server. diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml index 00287c3ec7..c4dceb11b3 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml @@ -207,9 +207,17 @@ spec: type: object groupRef: description: |- - GroupRef is the name of the MCPGroup this server belongs to - Must reference an existing MCPGroup in the same namespace - type: string + GroupRef references the MCPGroup this server belongs to. + The referenced MCPGroup must be in the same namespace. + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object image: description: Image is the container image for the MCP server type: string diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 9c451d7765..b57df2b347 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -641,11 +641,9 @@ spec: type: object config: description: |- - Config is the Virtual MCP server configuration - The only field currently required within config is `config.groupRef`. - GroupRef references an existing MCPGroup that defines backend workloads. - The referenced MCPGroup must exist in the same namespace. + Config is the Virtual MCP server configuration. The audit config from here is also supported, but not required. + Note: config.groupRef is deprecated in favor of spec.groupRef. Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference a shared MCPTelemetryConfig resource instead. properties: @@ -1177,6 +1175,7 @@ spec: description: |- Group references an existing MCPGroup that defines backend workloads. In Kubernetes, the referenced MCPGroup must exist in the same namespace. + Deprecated: Use spec.groupRef on VirtualMCPServerSpec instead. type: string incomingAuth: description: |- @@ -1774,8 +1773,6 @@ spec: Currently defaults to true; this will change to false in a future release. type: boolean type: object - required: - - groupRef type: object x-kubernetes-preserve-unknown-fields: true embeddingServerRef: @@ -1791,6 +1788,20 @@ spec: required: - name type: object + groupRef: + description: |- + GroupRef references the MCPGroup that defines backend workloads. + The referenced MCPGroup must exist in the same namespace. + This field takes precedence over config.groupRef and should be preferred. + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object incomingAuth: description: |- IncomingAuth configures authentication for clients connecting to the Virtual MCP server. diff --git a/docs/arch/09-operator-architecture.md b/docs/arch/09-operator-architecture.md index 93444fea4c..53b0cd31c2 100644 --- a/docs/arch/09-operator-architecture.md +++ b/docs/arch/09-operator-architecture.md @@ -293,11 +293,11 @@ Logically groups MCPServer resources together for organizational purposes. **Implementation**: `cmd/thv-operator/api/v1alpha1/mcpgroup_types.go` -MCPGroup resources allow grouping related MCP servers. Servers reference their group using the `groupRef` field in MCPServer spec. The group tracks member servers in its status. +MCPGroup resources allow grouping related MCP servers. Servers reference their group using the `groupRef` typed struct (`MCPGroupRef`) in MCPServer spec. The group tracks member servers in its status. **Status fields** include phase (Ready, Pending, Failed), list of server names, and server count. -**Referenced by MCPServer** using `spec.groupRef`. +**Referenced by MCPServer** using `spec.groupRef.name`. **Controller**: `cmd/thv-operator/controllers/mcpgroup_controller.go` @@ -351,7 +351,7 @@ VirtualMCPServer creates a virtual MCP server that aggregates tools, resources, - Backend count - Detailed conditions for validation, discovery, and readiness -**References**: MCPGroup (via `spec.config.groupRef`) +**References**: MCPGroup (via `spec.groupRef.name`) **Controller**: `cmd/thv-operator/controllers/virtualmcpserver_controller.go` diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 0a7e9ee2fa..8a8e3a583e 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -263,7 +263,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `name` _string_ | Name is the virtual MCP server name. | | Optional: \{\}
| -| `groupRef` _string_ | Group references an existing MCPGroup that defines backend workloads.
In Kubernetes, the referenced MCPGroup must exist in the same namespace. | | Required: \{\}
| +| `groupRef` _string_ | Group references an existing MCPGroup that defines backend workloads.
In Kubernetes, the referenced MCPGroup must exist in the same namespace.
Deprecated: Use spec.groupRef on VirtualMCPServerSpec instead. | | Optional: \{\}
| | `backends` _[vmcp.config.StaticBackendConfig](#vmcpconfigstaticbackendconfig) array_ | Backends defines pre-configured backend servers for static mode.
When OutgoingAuth.Source is "inline", this field contains the full list of backend
servers with their URLs and transport types, eliminating the need for K8s API access.
When OutgoingAuth.Source is "discovered", this field is empty and backends are
discovered at runtime via Kubernetes API. | | Optional: \{\}
| | `incomingAuth` _[vmcp.config.IncomingAuthConfig](#vmcpconfigincomingauthconfig)_ | IncomingAuth configures how clients authenticate to the virtual MCP server.
When using the Kubernetes operator, this is populated by the converter from
VirtualMCPServerSpec.IncomingAuth and any values set here will be superseded. | | Optional: \{\}
| | `outgoingAuth` _[vmcp.config.OutgoingAuthConfig](#vmcpconfigoutgoingauthconfig)_ | OutgoingAuth configures how the virtual MCP server authenticates to backends.
When using the Kubernetes operator, this is populated by the converter from
VirtualMCPServerSpec.OutgoingAuth and any values set here will be superseded. | | Optional: \{\}
| @@ -1553,6 +1553,26 @@ _Appears in:_ | `Failed` | MCPGroupPhaseFailed indicates the MCPGroup has failed
| +#### api.v1alpha1.MCPGroupRef + + + +MCPGroupRef defines a reference to an MCPGroup resource. +The referenced MCPGroup must be in the same namespace. + + + +_Appears in:_ +- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) +- [api.v1alpha1.MCPServerEntrySpec](#apiv1alpha1mcpserverentryspec) +- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1alpha1.VirtualMCPServerSpec](#apiv1alpha1virtualmcpserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the MCPGroup resource in the same namespace | | MinLength: 1
Required: \{\}
| + + #### api.v1alpha1.MCPGroupSpec @@ -1913,7 +1933,7 @@ _Appears in:_ | `trustProxyHeaders` _boolean_ | TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies
When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port,
and X-Forwarded-Prefix headers to construct endpoint URLs | false | Optional: \{\}
| | `endpointPrefix` _string_ | EndpointPrefix is the path prefix to prepend to SSE endpoint URLs.
This is used to handle path-based ingress routing scenarios where the ingress
strips a path prefix before forwarding to the backend. | | Optional: \{\}
| | `resourceOverrides` _[api.v1alpha1.ResourceOverrides](#apiv1alpha1resourceoverrides)_ | ResourceOverrides allows overriding annotations and labels for resources created by the operator | | Optional: \{\}
| -| `groupRef` _string_ | GroupRef is the name of the MCPGroup this proxy belongs to
Must reference an existing MCPGroup in the same namespace | | Optional: \{\}
| +| `groupRef` _[api.v1alpha1.MCPGroupRef](#apiv1alpha1mcpgroupref)_ | GroupRef references the MCPGroup this proxy belongs to.
The referenced MCPGroup must be in the same namespace. | | Optional: \{\}
| | `sessionAffinity` _string_ | SessionAffinity controls whether the Service routes repeated client connections to the same pod.
MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default.
Set to "None" for stateless servers or when using an external load balancer with its own affinity. | ClientIP | Enum: [ClientIP None]
Optional: \{\}
| @@ -2045,7 +2065,7 @@ _Appears in:_ | --- | --- | --- | --- | | `remoteUrl` _string_ | RemoteURL is the URL of the remote MCP server.
Both HTTP and HTTPS schemes are accepted at admission time. | | Pattern: `^https?://`
Required: \{\}
| | `transport` _string_ | Transport is the transport method for the remote server (sse or streamable-http).
No default is set (unlike MCPRemoteProxy) because MCPServerEntry points at external
servers the user doesn't control — requiring explicit transport avoids silent mismatches. | | Enum: [sse streamable-http]
Required: \{\}
| -| `groupRef` _string_ | GroupRef is the name of the MCPGroup this entry belongs to.
Required — every MCPServerEntry must be part of a group for vMCP discovery. | | MinLength: 1
Required: \{\}
| +| `groupRef` _[api.v1alpha1.MCPGroupRef](#apiv1alpha1mcpgroupref)_ | GroupRef references the MCPGroup this entry belongs to.
Required — every MCPServerEntry must be part of a group for vMCP discovery. | | Required: \{\}
| | `externalAuthConfigRef` _[api.v1alpha1.ExternalAuthConfigRef](#apiv1alpha1externalauthconfigref)_ | ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange
when connecting to the remote MCP server. The referenced MCPExternalAuthConfig must
exist in the same namespace as this MCPServerEntry. | | Optional: \{\}
| | `headerForward` _[api.v1alpha1.HeaderForwardConfig](#apiv1alpha1headerforwardconfig)_ | HeaderForward configures headers to inject into requests to the remote MCP server.
Use this to add custom headers like API keys or correlation IDs. | | Optional: \{\}
| | `caBundleRef` _[api.v1alpha1.CABundleSource](#apiv1alpha1cabundlesource)_ | CABundleRef references a ConfigMap containing CA certificates for TLS verification
when connecting to the remote MCP server. | | Optional: \{\}
| @@ -2148,7 +2168,7 @@ _Appears in:_ | `telemetry` _[api.v1alpha1.TelemetryConfig](#apiv1alpha1telemetryconfig)_ | Telemetry defines inline observability configuration for the MCP server.
Deprecated: Use TelemetryConfigRef to reference a shared MCPTelemetryConfig resource instead.
This field will be removed in a future release. Setting both telemetry and telemetryConfigRef
is rejected by CEL validation. | | Optional: \{\}
| | `trustProxyHeaders` _boolean_ | TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies
When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port,
and X-Forwarded-Prefix headers to construct endpoint URLs | false | Optional: \{\}
| | `endpointPrefix` _string_ | EndpointPrefix is the path prefix to prepend to SSE endpoint URLs.
This is used to handle path-based ingress routing scenarios where the ingress
strips a path prefix before forwarding to the backend. | | Optional: \{\}
| -| `groupRef` _string_ | GroupRef is the name of the MCPGroup this server belongs to
Must reference an existing MCPGroup in the same namespace | | Optional: \{\}
| +| `groupRef` _[api.v1alpha1.MCPGroupRef](#apiv1alpha1mcpgroupref)_ | GroupRef references the MCPGroup this server belongs to.
The referenced MCPGroup must be in the same namespace. | | Optional: \{\}
| | `sessionAffinity` _string_ | SessionAffinity controls whether the Service routes repeated client connections to the same pod.
MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default.
Set to "None" for stateless servers or when using an external load balancer with its own affinity. | ClientIP | Enum: [ClientIP None]
Optional: \{\}
| | `replicas` _integer_ | Replicas is the desired number of proxy runner (thv run) pod replicas.
MCPServer creates two separate Deployments: one for the proxy runner and one
for the MCP server backend. This field controls the proxy runner Deployment.
When nil, the operator does not set Deployment.Spec.Replicas, leaving replica
management to an HPA or other external controller. | | Minimum: 0
Optional: \{\}
| | `backendReplicas` _integer_ | BackendReplicas is the desired number of MCP server backend pod replicas.
This controls the backend Deployment (the MCP server container itself),
independent of the proxy runner controlled by Replicas.
When nil, the operator does not set Deployment.Spec.Replicas, leaving replica
management to an HPA or other external controller. | | Minimum: 0
Optional: \{\}
| @@ -3421,7 +3441,8 @@ _Appears in:_ | `sessionAffinity` _string_ | SessionAffinity controls whether the Service routes repeated client connections to the same pod.
MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default.
Set to "None" for stateless servers or when using an external load balancer with its own affinity. | ClientIP | Enum: [ClientIP None]
Optional: \{\}
| | `serviceAccount` _string_ | ServiceAccount is the name of an already existing service account to use by the Virtual MCP server.
If not specified, a ServiceAccount will be created automatically and used by the Virtual MCP server. | | Optional: \{\}
| | `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the Virtual MCP server
This allows for customizing the pod configuration beyond what is provided by the other fields.
Note that to modify the specific container the Virtual MCP server runs in, you must specify
the 'vmcp' container name in the PodTemplateSpec.
This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object
Optional: \{\}
| -| `config` _[vmcp.config.Config](#vmcpconfigconfig)_ | Config is the Virtual MCP server configuration
The only field currently required within config is `config.groupRef`.
GroupRef references an existing MCPGroup that defines backend workloads.
The referenced MCPGroup must exist in the same namespace.
The audit config from here is also supported, but not required.
Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference
a shared MCPTelemetryConfig resource instead. | | Type: object
Optional: \{\}
| +| `groupRef` _[api.v1alpha1.MCPGroupRef](#apiv1alpha1mcpgroupref)_ | GroupRef references the MCPGroup that defines backend workloads.
The referenced MCPGroup must exist in the same namespace.
This field takes precedence over config.groupRef and should be preferred. | | Optional: \{\}
| +| `config` _[vmcp.config.Config](#vmcpconfigconfig)_ | Config is the Virtual MCP server configuration.
The audit config from here is also supported, but not required.
Note: config.groupRef is deprecated in favor of spec.groupRef.
Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference
a shared MCPTelemetryConfig resource instead. | | Type: object
Optional: \{\}
| | `telemetryConfigRef` _[api.v1alpha1.MCPTelemetryConfigReference](#apiv1alpha1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer.
Cross-namespace references are not supported for security and isolation reasons.
Mutually exclusive with the deprecated inline config.telemetry field. | | Optional: \{\}
| | `embeddingServerRef` _[api.v1alpha1.EmbeddingServerRef](#apiv1alpha1embeddingserverref)_ | EmbeddingServerRef references an existing EmbeddingServer resource by name.
When the optimizer is enabled, this field is required to point to a ready EmbeddingServer
that provides embedding capabilities.
The referenced EmbeddingServer must exist in the same namespace and be ready. | | Optional: \{\}
| | `authServerConfig` _[api.v1alpha1.EmbeddedAuthServerConfig](#apiv1alpha1embeddedauthserverconfig)_ | AuthServerConfig configures an embedded OAuth authorization server.
When set, the vMCP server acts as an OIDC issuer, drives users through
upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the
IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfig
so that tokens it issues are accepted by the vMCP's incoming auth middleware.
When nil, IncomingAuth uses an external IDP and behavior is unchanged. | | Optional: \{\}
| diff --git a/docs/operator/virtualmcpcompositetooldefinition-guide.md b/docs/operator/virtualmcpcompositetooldefinition-guide.md index a48ecd8ca5..2fcbdb32ca 100644 --- a/docs/operator/virtualmcpcompositetooldefinition-guide.md +++ b/docs/operator/virtualmcpcompositetooldefinition-guide.md @@ -733,8 +733,8 @@ metadata: name: production-vmcp namespace: default spec: - config: - groupRef: production-backends + groupRef: + name: production-backends # Reference composite tool definitions compositeToolRefs: diff --git a/docs/operator/virtualmcpserver-api.md b/docs/operator/virtualmcpserver-api.md index bda286f15e..516707025b 100644 --- a/docs/operator/virtualmcpserver-api.md +++ b/docs/operator/virtualmcpserver-api.md @@ -24,17 +24,20 @@ The `VirtualMCPServer` CRD enables aggregation of multiple backend MCPServers in ## Spec Fields -### `.spec.config.groupRef` (required) +### `.spec.groupRef` (required) References an existing `MCPGroup` that defines the backend workloads to aggregate. +The referenced MCPGroup must exist in the same namespace. -**Type**: `string` +**Type**: `MCPGroupRef` (object with `name` field) + +> **Note**: `spec.config.groupRef` (bare string) is deprecated. Use `spec.groupRef` instead. **Example**: ```yaml spec: - config: - groupRef: engineering-team + groupRef: + name: engineering-team ``` ### Backend Types @@ -62,7 +65,8 @@ metadata: spec: remoteUrl: https://mcp.context7.com/mcp transport: streamable-http - groupRef: engineering-team + groupRef: + name: engineering-team # No externalAuthConfigRef — public endpoint, no auth needed ``` @@ -202,8 +206,8 @@ Defines tool aggregation and conflict resolution strategies. **Example (prefix strategy)**: ```yaml spec: - config: - groupRef: my-services + groupRef: + name: my-services aggregation: conflictResolution: prefix conflictResolutionConfig: @@ -219,8 +223,8 @@ spec: **Example (priority strategy)**: ```yaml spec: - config: - groupRef: my-services + groupRef: + name: my-services aggregation: conflictResolution: priority conflictResolutionConfig: @@ -230,8 +234,8 @@ spec: **Example (manual strategy)**: ```yaml spec: - config: - groupRef: my-services + groupRef: + name: my-services aggregation: conflictResolution: manual tools: @@ -374,8 +378,9 @@ Configures OpenTelemetry-based observability for the Virtual MCP server, includi **Example**: ```yaml spec: + groupRef: + name: my-group config: - groupRef: my-group telemetry: endpoint: "otel-collector:4317" serviceName: "my-vmcp" @@ -473,10 +478,11 @@ metadata: name: engineering-vmcp namespace: default spec: - # Reference to MCPGroup defining backend workloads and tool aggregation + # Reference to MCPGroup defining backend workloads + groupRef: + name: engineering-team + # Tool aggregation config: - groupRef: engineering-team - # Tool aggregation aggregation: conflictResolution: prefix conflictResolutionConfig: @@ -608,9 +614,9 @@ status: The VirtualMCPServer CRD includes comprehensive validation: 1. **Required Fields**: - - `spec.config.groupRef` must be specified + - `spec.groupRef.name` must be specified (or `spec.config.groupRef` for backwards compatibility) - `spec.incomingAuth.type` must be explicitly specified (use `anonymous` when no auth is needed) -2. **Reference Validation**: All references (config.groupRef, authConfigRef, toolConfigRef) must be valid +2. **Reference Validation**: All references (groupRef, authConfigRef, toolConfigRef) must be valid 3. **Conflict Resolution**: Priority strategy requires `priorityOrder` configuration 4. **Composite Tools**: Must have unique names, valid steps with IDs, and proper dependencies 5. **Token Cache**: Redis provider requires valid address configuration diff --git a/docs/operator/virtualmcpserver-kubernetes-guide.md b/docs/operator/virtualmcpserver-kubernetes-guide.md index 4f048a1b41..859159353d 100644 --- a/docs/operator/virtualmcpserver-kubernetes-guide.md +++ b/docs/operator/virtualmcpserver-kubernetes-guide.md @@ -184,7 +184,8 @@ metadata: name: github namespace: default spec: - groupRef: my-services # Add this line + groupRef: + name: my-services # Add this field image: ghcr.io/example/github-mcp transport: streamable-http proxyPort: 8080 @@ -205,8 +206,9 @@ metadata: name: my-vmcp namespace: default spec: - config: - groupRef: my-services + groupRef: + name: my-services + config: {} # Configure authentication (adjust from CLI if using OIDC) # For OIDC, use oidcConfigRef with a shared MCPOIDCConfig resource: @@ -365,16 +367,16 @@ kind: MCPGroup metadata: name: services --- -# Include backend1.yaml content with groupRef: services -# Include backend2.yaml content with groupRef: services +# Include backend1.yaml content with groupRef: {name: services} +# Include backend2.yaml content with groupRef: {name: services} --- apiVersion: toolhive.stacklok.dev/v1alpha1 kind: VirtualMCPServer metadata: name: services-vmcp spec: - config: - groupRef: services + groupRef: + name: services incomingAuth: type: anonymous outgoingAuth: @@ -407,7 +409,7 @@ kubectl apply -f resources.yaml #### Issue: Backend servers not discovered by VirtualMCPServer **Solution**: -- Verify all MCPServers have `groupRef` set +- Verify all MCPServers have `groupRef.name` set - Ensure all resources are in the same namespace - Check MCPServer status: `kubectl get mcpserver` - Review VirtualMCPServer conditions: `kubectl describe virtualmcpserver ` @@ -443,7 +445,8 @@ metadata: spec: remoteUrl: https://mcp.context7.com/mcp transport: streamable-http - groupRef: engineering-team + groupRef: + name: engineering-team # Requires OIDC config, deploys proxy pod ``` @@ -456,7 +459,8 @@ metadata: spec: remoteUrl: https://mcp.context7.com/mcp transport: streamable-http - groupRef: engineering-team + groupRef: + name: engineering-team # No pods deployed, VirtualMCPServer connects directly ``` @@ -491,19 +495,20 @@ kubectl get virtualmcpserver my-vmcp -o yaml | grep -A 5 conditions kubectl get mcpgroup ``` -Create if missing or fix `spec.config.groupRef` in VirtualMCPServer spec. +Create if missing or fix `spec.groupRef.name` in VirtualMCPServer spec. **2. No Backend MCPServers in Group** ```bash -kubectl get mcpserver -o custom-columns=NAME:.metadata.name,GROUP:.spec.groupRef +kubectl get mcpserver -o custom-columns=NAME:.metadata.name,GROUP:.spec.groupRef.name ``` **Solution**: Create MCPServers and link them to the group: ```yaml spec: - groupRef: + groupRef: + name: ``` **3. Backend MCPServers Not Ready** @@ -636,13 +641,13 @@ kubectl get virtualmcpserver my-vmcp -o jsonpath='{.status.discoveredBackends}' **1. Backend Not in MCPGroup** ```bash -kubectl get mcpserver -o yaml | grep groupRef +kubectl get mcpserver -o yaml | grep -A1 groupRef ``` **Solution**: Verify backend has correct `groupRef`: ```bash -kubectl patch mcpserver -p '{"spec":{"groupRef":""}}' +kubectl patch mcpserver --type merge -p '{"spec":{"groupRef":{"name":""}}}' ``` **2. Namespace Mismatch** diff --git a/docs/operator/virtualmcpserver-observability.md b/docs/operator/virtualmcpserver-observability.md index 78621ac752..421b76cb7f 100644 --- a/docs/operator/virtualmcpserver-observability.md +++ b/docs/operator/virtualmcpserver-observability.md @@ -188,8 +188,8 @@ spec: telemetryConfigRef: name: shared-otel serviceName: my-vmcp - config: - groupRef: my-group + groupRef: + name: my-group incomingAuth: type: anonymous ``` @@ -209,8 +209,9 @@ kind: VirtualMCPServer metadata: name: my-vmcp spec: + groupRef: + name: my-group config: - groupRef: my-group telemetry: endpoint: "otel-collector:4317" serviceName: "my-vmcp" diff --git a/examples/operator/mcp-server-entries/mcpserverentry_basic.yaml b/examples/operator/mcp-server-entries/mcpserverentry_basic.yaml index e421f7b9b8..332979b0b2 100644 --- a/examples/operator/mcp-server-entries/mcpserverentry_basic.yaml +++ b/examples/operator/mcp-server-entries/mcpserverentry_basic.yaml @@ -22,4 +22,5 @@ metadata: spec: remoteUrl: https://mcp.context7.com/mcp transport: streamable-http - groupRef: remote-tools \ No newline at end of file + groupRef: + name: remote-tools \ No newline at end of file diff --git a/examples/operator/mcp-server-entries/mcpserverentry_mixed_group.yaml b/examples/operator/mcp-server-entries/mcpserverentry_mixed_group.yaml index ab197739b9..8cecb068f2 100644 --- a/examples/operator/mcp-server-entries/mcpserverentry_mixed_group.yaml +++ b/examples/operator/mcp-server-entries/mcpserverentry_mixed_group.yaml @@ -23,7 +23,8 @@ metadata: spec: image: ghcr.io/github/mcp-server:latest transport: streamable-http - groupRef: engineering-team + groupRef: + name: engineering-team --- # Remote MCP server (no pods deployed) @@ -35,7 +36,8 @@ metadata: spec: remoteUrl: https://mcp.context7.com/mcp transport: streamable-http - groupRef: engineering-team + groupRef: + name: engineering-team --- # VirtualMCPServer aggregates both backends @@ -49,8 +51,9 @@ spec: type: anonymous outgoingAuth: source: inline + groupRef: + name: engineering-team config: - groupRef: engineering-team aggregation: conflictResolution: prefix conflictResolutionConfig: diff --git a/examples/operator/mcp-server-entries/mcpserverentry_with_ca_bundle.yaml b/examples/operator/mcp-server-entries/mcpserverentry_with_ca_bundle.yaml index 290123b7f5..3591a43df0 100644 --- a/examples/operator/mcp-server-entries/mcpserverentry_with_ca_bundle.yaml +++ b/examples/operator/mcp-server-entries/mcpserverentry_with_ca_bundle.yaml @@ -25,7 +25,8 @@ metadata: spec: remoteUrl: https://internal-mcp.corp:8443/mcp transport: streamable-http - groupRef: remote-tools + groupRef: + name: remote-tools caBundleRef: configMapRef: name: corp-ca-bundle diff --git a/examples/operator/mcp-server-entries/mcpserverentry_with_header_forward.yaml b/examples/operator/mcp-server-entries/mcpserverentry_with_header_forward.yaml index 7b8ccc7f26..e29ea80634 100644 --- a/examples/operator/mcp-server-entries/mcpserverentry_with_header_forward.yaml +++ b/examples/operator/mcp-server-entries/mcpserverentry_with_header_forward.yaml @@ -12,7 +12,8 @@ metadata: spec: remoteUrl: https://internal-mcp.corp.example.com/mcp transport: sse - groupRef: remote-tools + groupRef: + name: remote-tools headerForward: addPlaintextHeaders: X-Tenant-ID: "tenant-123" diff --git a/examples/operator/mcp-server-entries/mcpserverentry_with_token_exchange.yaml b/examples/operator/mcp-server-entries/mcpserverentry_with_token_exchange.yaml index 3b80e6eb94..61fed2ec19 100644 --- a/examples/operator/mcp-server-entries/mcpserverentry_with_token_exchange.yaml +++ b/examples/operator/mcp-server-entries/mcpserverentry_with_token_exchange.yaml @@ -32,6 +32,7 @@ metadata: spec: remoteUrl: https://mcp.salesforce.com/v1 transport: streamable-http - groupRef: remote-tools + groupRef: + name: remote-tools externalAuthConfigRef: name: salesforce-auth \ No newline at end of file diff --git a/examples/operator/virtual-mcps/composite_tool_complex.yaml b/examples/operator/virtual-mcps/composite_tool_complex.yaml index 12f2b07108..0e3c0fa90e 100644 --- a/examples/operator/virtual-mcps/composite_tool_complex.yaml +++ b/examples/operator/virtual-mcps/composite_tool_complex.yaml @@ -38,7 +38,8 @@ metadata: name: fetch namespace: default spec: - groupRef: data-processing-services + groupRef: + name: data-processing-services image: ghcr.io/stackloklabs/gofetch/server transport: streamable-http proxyPort: 8080 @@ -59,7 +60,8 @@ metadata: name: yardstick-sse namespace: default spec: - groupRef: data-processing-services + groupRef: + name: data-processing-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: sse env: @@ -83,7 +85,8 @@ metadata: name: yardstick-streamable namespace: default spec: - groupRef: data-processing-services + groupRef: + name: data-processing-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: streamable-http env: @@ -288,8 +291,9 @@ metadata: name: vmcp-data-processor namespace: default spec: + groupRef: + name: data-processing-services config: - groupRef: data-processing-services # Conflict resolution for backend tools aggregation: conflictResolution: prefix diff --git a/examples/operator/virtual-mcps/composite_tool_simple.yaml b/examples/operator/virtual-mcps/composite_tool_simple.yaml index 208fc6043b..5472efc20d 100644 --- a/examples/operator/virtual-mcps/composite_tool_simple.yaml +++ b/examples/operator/virtual-mcps/composite_tool_simple.yaml @@ -31,7 +31,8 @@ metadata: name: fetch namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/gofetch/server transport: streamable-http proxyPort: 8080 @@ -52,7 +53,8 @@ metadata: name: yardstick-sse namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: sse env: @@ -146,8 +148,9 @@ metadata: name: vmcp-simple-composite namespace: default spec: + groupRef: + name: my-services config: - groupRef: my-services # Conflict resolution for backend tools aggregation: conflictResolution: prefix diff --git a/examples/operator/virtual-mcps/composite_tool_with_elicitations.yaml b/examples/operator/virtual-mcps/composite_tool_with_elicitations.yaml index 31b21dc8d7..196e439604 100644 --- a/examples/operator/virtual-mcps/composite_tool_with_elicitations.yaml +++ b/examples/operator/virtual-mcps/composite_tool_with_elicitations.yaml @@ -39,7 +39,8 @@ metadata: name: yardstick-streamable namespace: default spec: - groupRef: deployment-services + groupRef: + name: deployment-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: streamable-http env: @@ -203,8 +204,9 @@ metadata: name: vmcp-interactive-deploy namespace: default spec: + groupRef: + name: deployment-services config: - groupRef: deployment-services # Conflict resolution for backend tools aggregation: conflictResolution: prefix diff --git a/examples/operator/virtual-mcps/vmcp_conflict_resolution.yaml b/examples/operator/virtual-mcps/vmcp_conflict_resolution.yaml index b17c3a0e2d..d21b28fbfe 100644 --- a/examples/operator/virtual-mcps/vmcp_conflict_resolution.yaml +++ b/examples/operator/virtual-mcps/vmcp_conflict_resolution.yaml @@ -31,7 +31,8 @@ metadata: name: yardstick-sse namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: sse env: @@ -54,7 +55,8 @@ metadata: name: fetch namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/gofetch/server transport: streamable-http proxyPort: 8080 @@ -74,7 +76,8 @@ metadata: name: yardstick-streamable namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: streamable-http env: @@ -100,8 +103,9 @@ metadata: name: vmcp-prefix-strategy namespace: default spec: + groupRef: + name: my-services config: - groupRef: my-services # Prefix strategy configuration aggregation: conflictResolution: prefix @@ -140,8 +144,9 @@ metadata: name: vmcp-priority-strategy namespace: default spec: + groupRef: + name: my-services config: - groupRef: my-services # Priority strategy configuration aggregation: conflictResolution: priority @@ -183,8 +188,9 @@ metadata: name: vmcp-manual-strategy namespace: default spec: + groupRef: + name: my-services config: - groupRef: my-services # Manual strategy configuration # Manual strategy validates conflicts at runtime and requires # per-workload tool configuration to resolve them diff --git a/examples/operator/virtual-mcps/vmcp_inline_incoming_auth.yaml b/examples/operator/virtual-mcps/vmcp_inline_incoming_auth.yaml index 5fe6b6dc5c..a4209a8d13 100644 --- a/examples/operator/virtual-mcps/vmcp_inline_incoming_auth.yaml +++ b/examples/operator/virtual-mcps/vmcp_inline_incoming_auth.yaml @@ -56,7 +56,8 @@ metadata: name: yardstick-sse namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: sse env: @@ -80,7 +81,8 @@ metadata: name: fetch namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/gofetch/server transport: streamable-http proxyPort: 8080 @@ -101,7 +103,8 @@ metadata: name: yardstick-streamable namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: streamable-http env: @@ -124,8 +127,9 @@ metadata: name: inline-auth-vmcp namespace: default spec: + groupRef: + name: my-services config: - groupRef: my-services # Aggregation configuration aggregation: conflictResolution: prefix diff --git a/examples/operator/virtual-mcps/vmcp_optimizer_all_options.yaml b/examples/operator/virtual-mcps/vmcp_optimizer_all_options.yaml index f55207168b..4094f7af67 100644 --- a/examples/operator/virtual-mcps/vmcp_optimizer_all_options.yaml +++ b/examples/operator/virtual-mcps/vmcp_optimizer_all_options.yaml @@ -46,7 +46,8 @@ metadata: name: yardstick namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: stdio proxyPort: 8080 @@ -66,7 +67,8 @@ metadata: name: fetch namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: ghcr.io/stackloklabs/gofetch/server transport: streamable-http proxyPort: 8080 @@ -144,9 +146,9 @@ metadata: name: optimizer-vmcp namespace: default spec: + groupRef: + name: optimizer-services config: - groupRef: optimizer-services - # Aggregation: prefix strategy prevents tool name conflicts aggregation: conflictResolution: prefix diff --git a/examples/operator/virtual-mcps/vmcp_optimizer_quickstart.yaml b/examples/operator/virtual-mcps/vmcp_optimizer_quickstart.yaml index 017d033662..9f9e7d877b 100644 --- a/examples/operator/virtual-mcps/vmcp_optimizer_quickstart.yaml +++ b/examples/operator/virtual-mcps/vmcp_optimizer_quickstart.yaml @@ -89,7 +89,8 @@ metadata: name: yardstick namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: streamable-http proxyPort: 8080 @@ -112,7 +113,8 @@ metadata: name: fetch namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: ghcr.io/stackloklabs/gofetch/server transport: streamable-http proxyPort: 8080 @@ -136,7 +138,8 @@ metadata: name: github namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: ghcr.io/github/github-mcp-server transport: stdio proxyPort: 8080 @@ -160,7 +163,8 @@ metadata: name: memory namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: docker.io/mcp/memory transport: stdio proxyPort: 8080 @@ -180,7 +184,8 @@ metadata: name: puppeteer namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: docker.io/mcp/puppeteer transport: stdio proxyPort: 8080 @@ -200,7 +205,8 @@ metadata: name: osv namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: ghcr.io/stackloklabs/osv-mcp/server:0.0.7 transport: streamable-http proxyPort: 8080 @@ -220,7 +226,8 @@ metadata: name: terraform namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: docker.io/hashicorp/terraform-mcp-server:0.4.0 transport: streamable-http proxyPort: 8080 @@ -245,7 +252,8 @@ metadata: name: playwright namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: mcr.microsoft.com/playwright/mcp:v0.0.68 transport: stdio proxyPort: 8080 @@ -265,7 +273,8 @@ metadata: name: everything namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: docker.io/mcp/everything:latest transport: stdio proxyPort: 8080 @@ -285,7 +294,8 @@ metadata: name: ida-pro-mcp namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: ghcr.io/stacklok/dockyard/uvx/ida-pro-mcp:1.4.0 transport: stdio proxyPort: 8080 @@ -308,7 +318,8 @@ metadata: name: pagerduty namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: ghcr.io/stacklok/dockyard/uvx/pagerduty-mcp:0.12.0 transport: stdio proxyPort: 8080 @@ -350,9 +361,9 @@ metadata: name: optimizer-vmcp namespace: default spec: + groupRef: + name: optimizer-services config: - groupRef: optimizer-services - # Aggregation: prefix strategy prevents tool name conflicts aggregation: conflictResolution: prefix diff --git a/examples/operator/virtual-mcps/vmcp_production_full.yaml b/examples/operator/virtual-mcps/vmcp_production_full.yaml index ab1efafb9b..6df0d4d8b2 100644 --- a/examples/operator/virtual-mcps/vmcp_production_full.yaml +++ b/examples/operator/virtual-mcps/vmcp_production_full.yaml @@ -64,7 +64,8 @@ metadata: labels: environment: production spec: - groupRef: production-services + groupRef: + name: production-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: streamable-http env: @@ -90,7 +91,8 @@ metadata: labels: environment: production spec: - groupRef: production-services + groupRef: + name: production-services image: ghcr.io/stackloklabs/gofetch/server transport: streamable-http proxyPort: 8080 @@ -113,7 +115,8 @@ metadata: labels: environment: production spec: - groupRef: production-services + groupRef: + name: production-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: sse env: @@ -140,8 +143,9 @@ metadata: environment: production spec: # Reference to the MCPGroup containing backend MCPServers + groupRef: + name: production-services config: - groupRef: production-services # Aggregation configuration with priority conflict resolution aggregation: conflictResolution: priority diff --git a/examples/operator/virtual-mcps/vmcp_simple_discovered.yaml b/examples/operator/virtual-mcps/vmcp_simple_discovered.yaml index 8803fe336c..73b8d32261 100644 --- a/examples/operator/virtual-mcps/vmcp_simple_discovered.yaml +++ b/examples/operator/virtual-mcps/vmcp_simple_discovered.yaml @@ -32,7 +32,8 @@ metadata: name: yardstick-simple namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: stdio proxyPort: 8080 @@ -53,8 +54,9 @@ metadata: namespace: default spec: # Reference to the MCPGroup containing backend MCPServers + groupRef: + name: my-services config: - groupRef: my-services # Aggregation configuration # Prefix strategy prevents tool name conflicts by adding workload name prefix aggregation: diff --git a/examples/operator/virtual-mcps/vmcp_with_oidcconfig_ref.yaml b/examples/operator/virtual-mcps/vmcp_with_oidcconfig_ref.yaml index 8f4aaa9e6a..2b8fbe40b6 100644 --- a/examples/operator/virtual-mcps/vmcp_with_oidcconfig_ref.yaml +++ b/examples/operator/virtual-mcps/vmcp_with_oidcconfig_ref.yaml @@ -40,8 +40,9 @@ metadata: name: shared-auth-vmcp namespace: default spec: + groupRef: + name: my-services config: - groupRef: my-services aggregation: conflictResolution: prefix conflictResolutionConfig: diff --git a/examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml b/examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml index c029ae9df5..499639f0d5 100644 --- a/examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml +++ b/examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml @@ -61,7 +61,8 @@ metadata: name: yardstick-telemetry namespace: toolhive-system spec: - groupRef: telemetry-demo + groupRef: + name: telemetry-demo image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: stdio proxyPort: 8080 @@ -88,9 +89,11 @@ spec: name: shared-otel serviceName: telemetry-demo-vmcp + groupRef: + name: telemetry-demo + # vMCP configuration config: - groupRef: telemetry-demo aggregation: conflictResolution: prefix conflictResolutionConfig: diff --git a/pkg/vmcp/config/config.go b/pkg/vmcp/config/config.go index e09aa96f84..7e464f9708 100644 --- a/pkg/vmcp/config/config.go +++ b/pkg/vmcp/config/config.go @@ -99,8 +99,9 @@ type Config struct { // Group references an existing MCPGroup that defines backend workloads. // In Kubernetes, the referenced MCPGroup must exist in the same namespace. - // +kubebuilder:validation:Required - Group string `json:"groupRef" yaml:"groupRef"` + // Deprecated: Use spec.groupRef on VirtualMCPServerSpec instead. + // +optional + Group string `json:"groupRef,omitempty" yaml:"groupRef,omitempty"` // Backends defines pre-configured backend servers for static mode. // When OutgoingAuth.Source is "inline", this field contains the full list of backend diff --git a/pkg/vmcp/k8s/backend_reconciler.go b/pkg/vmcp/k8s/backend_reconciler.go index 6a7795c353..3f579a8786 100644 --- a/pkg/vmcp/k8s/backend_reconciler.go +++ b/pkg/vmcp/k8s/backend_reconciler.go @@ -156,7 +156,7 @@ func (r *BackendReconciler) fetchBackendResource( if errServer == nil { return &backendResourceInfo{ Name: mcpServer.Name, - GroupRef: mcpServer.Spec.GroupRef, + GroupRef: mcpServer.Spec.GroupRef.GetName(), Type: workloads.WorkloadTypeMCPServer, }, nil } @@ -168,7 +168,7 @@ func (r *BackendReconciler) fetchBackendResource( if errProxy == nil { return &backendResourceInfo{ Name: mcpRemoteProxy.Name, - GroupRef: mcpRemoteProxy.Spec.GroupRef, + GroupRef: mcpRemoteProxy.Spec.GroupRef.GetName(), Type: workloads.WorkloadTypeMCPRemoteProxy, }, nil } @@ -180,7 +180,7 @@ func (r *BackendReconciler) fetchBackendResource( if errEntry == nil { return &backendResourceInfo{ Name: mcpServerEntry.Name, - GroupRef: mcpServerEntry.Spec.GroupRef, + GroupRef: mcpServerEntry.Spec.GroupRef.GetName(), Type: workloads.WorkloadTypeMCPServerEntry, }, nil } @@ -220,7 +220,7 @@ func (r *BackendReconciler) MapAuthConfigToEntries(ctx context.Context, authConf var requests []reconcile.Request for _, entry := range entryList.Items { - if entry.Spec.GroupRef != r.GroupRef { + if entry.Spec.GroupRef.GetName() != r.GroupRef { continue } if entry.Spec.ExternalAuthConfigRef != nil && @@ -346,7 +346,7 @@ func (r *BackendReconciler) SetupWithManager(mgr ctrl.Manager) error { for _, server := range mcpServerList.Items { // Only reconcile if server matches our groupRef AND references this auth config - if server.Spec.GroupRef != r.GroupRef { + if server.Spec.GroupRef.GetName() != r.GroupRef { continue } @@ -370,7 +370,7 @@ func (r *BackendReconciler) SetupWithManager(mgr ctrl.Manager) error { for _, proxy := range proxyList.Items { // Only reconcile if proxy matches our groupRef AND references this auth config - if proxy.Spec.GroupRef != r.GroupRef { + if proxy.Spec.GroupRef.GetName() != r.GroupRef { continue } @@ -402,7 +402,7 @@ func (r *BackendReconciler) SetupWithManager(mgr ctrl.Manager) error { } // Only reconcile if matches groupRef (security + performance) - if server.Spec.GroupRef != r.GroupRef { + if server.Spec.GroupRef.GetName() != r.GroupRef { return nil } @@ -427,7 +427,7 @@ func (r *BackendReconciler) SetupWithManager(mgr ctrl.Manager) error { } // Only reconcile if matches groupRef (security + performance) - if proxy.Spec.GroupRef != r.GroupRef { + if proxy.Spec.GroupRef.GetName() != r.GroupRef { return nil } @@ -452,7 +452,7 @@ func (r *BackendReconciler) SetupWithManager(mgr ctrl.Manager) error { } // Only reconcile if matches groupRef (security + performance) - if entry.Spec.GroupRef != r.GroupRef { + if entry.Spec.GroupRef.GetName() != r.GroupRef { return nil } @@ -488,7 +488,7 @@ func (r *BackendReconciler) SetupWithManager(mgr ctrl.Manager) error { var requests []reconcile.Request for _, entry := range entryList.Items { - if entry.Spec.GroupRef != r.GroupRef { + if entry.Spec.GroupRef.GetName() != r.GroupRef { continue } requests = append(requests, reconcile.Request{ diff --git a/pkg/vmcp/k8s/backend_reconciler_integration_test.go b/pkg/vmcp/k8s/backend_reconciler_integration_test.go index 7f63935e22..e6c37e50a6 100644 --- a/pkg/vmcp/k8s/backend_reconciler_integration_test.go +++ b/pkg/vmcp/k8s/backend_reconciler_integration_test.go @@ -172,7 +172,7 @@ var _ = Describe("BackendReconciler Integration Tests", func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: testGroupRef, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupRef}, Image: "test-image:latest", Transport: "streamable-http", }, @@ -200,7 +200,7 @@ var _ = Describe("BackendReconciler Integration Tests", func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "different-group", // Does NOT match testGroupRef + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "different-group"}, // Does NOT match testGroupRef Image: "test-image:latest", Transport: "streamable-http", }, @@ -225,7 +225,7 @@ var _ = Describe("BackendReconciler Integration Tests", func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: testGroupRef, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupRef}, Image: "test-image:latest", Transport: "streamable-http", }, @@ -260,7 +260,7 @@ var _ = Describe("BackendReconciler Integration Tests", func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: testGroupRef, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupRef}, RemoteURL: "https://example.com/mcp", }, } @@ -285,7 +285,7 @@ var _ = Describe("BackendReconciler Integration Tests", func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: "other-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, RemoteURL: "https://example.com/mcp", }, } @@ -313,7 +313,7 @@ var _ = Describe("BackendReconciler Integration Tests", func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: testGroupRef, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupRef}, Image: "test-image:latest", Transport: "streamable-http", }, @@ -346,7 +346,7 @@ var _ = Describe("BackendReconciler Integration Tests", func() { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/mcp", Transport: "streamable-http", - GroupRef: testGroupRef, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupRef}, }, } @@ -381,7 +381,7 @@ var _ = Describe("BackendReconciler Integration Tests", func() { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/mcp", Transport: "streamable-http", - GroupRef: "other-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, }, } @@ -407,7 +407,7 @@ var _ = Describe("BackendReconciler Integration Tests", func() { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/mcp", Transport: "streamable-http", - GroupRef: testGroupRef, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupRef}, }, } @@ -441,7 +441,7 @@ var _ = Describe("BackendReconciler Integration Tests", func() { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/mcp", Transport: "streamable-http", - GroupRef: testGroupRef, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupRef}, }, } diff --git a/pkg/vmcp/k8s/backend_reconciler_test.go b/pkg/vmcp/k8s/backend_reconciler_test.go index ddaceddd07..5f1c9952e8 100644 --- a/pkg/vmcp/k8s/backend_reconciler_test.go +++ b/pkg/vmcp/k8s/backend_reconciler_test.go @@ -121,7 +121,7 @@ func TestReconcile_MCPServer_Success(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, } @@ -178,7 +178,7 @@ func TestReconcile_GroupRefMismatch(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "different-group", // Does NOT match reconciler's groupRef + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "different-group"}, // Does NOT match reconciler's groupRef }, } @@ -257,7 +257,7 @@ func TestReconcile_AuthFailure(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, } @@ -303,7 +303,7 @@ func TestReconcile_MCPRemoteProxy_Success(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, } @@ -352,7 +352,7 @@ func TestReconcile_ConversionError(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, } @@ -426,7 +426,7 @@ func TestReconcile_MCPServerEntry_Success(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/mcp", Transport: "streamable-http", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, } @@ -478,7 +478,7 @@ func TestReconcile_MCPServerEntry_GroupRefMismatch(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/mcp", Transport: "streamable-http", - GroupRef: "other-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, }, } @@ -561,7 +561,7 @@ func TestMapAuthConfigToEntries(t *testing.T) { { ObjectMeta: metav1.ObjectMeta{Name: "entry-1", Namespace: "default"}, Spec: mcpv1alpha1.MCPServerEntrySpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, RemoteURL: "https://example.com", Transport: "streamable-http", ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "my-auth"}, @@ -578,7 +578,7 @@ func TestMapAuthConfigToEntries(t *testing.T) { { ObjectMeta: metav1.ObjectMeta{Name: "entry-1", Namespace: "default"}, Spec: mcpv1alpha1.MCPServerEntrySpec{ - GroupRef: "other-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, RemoteURL: "https://example.com", Transport: "streamable-http", ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "my-auth"}, @@ -595,7 +595,7 @@ func TestMapAuthConfigToEntries(t *testing.T) { { ObjectMeta: metav1.ObjectMeta{Name: "entry-1", Namespace: "default"}, Spec: mcpv1alpha1.MCPServerEntrySpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, RemoteURL: "https://example.com", Transport: "streamable-http", ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "other-auth"}, @@ -612,7 +612,7 @@ func TestMapAuthConfigToEntries(t *testing.T) { { ObjectMeta: metav1.ObjectMeta{Name: "entry-1", Namespace: "default"}, Spec: mcpv1alpha1.MCPServerEntrySpec{ - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, RemoteURL: "https://example.com", Transport: "streamable-http", }, diff --git a/pkg/vmcp/workloads/k8s.go b/pkg/vmcp/workloads/k8s.go index b73a1bf6e3..9fc02c6473 100644 --- a/pkg/vmcp/workloads/k8s.go +++ b/pkg/vmcp/workloads/k8s.go @@ -98,7 +98,7 @@ func (d *k8sDiscoverer) ListWorkloadsInGroup(ctx context.Context, groupName stri for i := range mcpServerList.Items { mcpServer := &mcpServerList.Items[i] - if mcpServer.Spec.GroupRef == groupName { + if mcpServer.Spec.GroupRef.GetName() == groupName { groupWorkloads = append(groupWorkloads, TypedWorkload{ Name: mcpServer.Name, Type: WorkloadTypeMCPServer, @@ -114,7 +114,7 @@ func (d *k8sDiscoverer) ListWorkloadsInGroup(ctx context.Context, groupName stri for i := range mcpRemoteProxyList.Items { mcpRemoteProxy := &mcpRemoteProxyList.Items[i] - if mcpRemoteProxy.Spec.GroupRef == groupName { + if mcpRemoteProxy.Spec.GroupRef.GetName() == groupName { groupWorkloads = append(groupWorkloads, TypedWorkload{ Name: mcpRemoteProxy.Name, Type: WorkloadTypeMCPRemoteProxy, @@ -130,7 +130,7 @@ func (d *k8sDiscoverer) ListWorkloadsInGroup(ctx context.Context, groupName stri for i := range mcpServerEntryList.Items { mcpServerEntry := &mcpServerEntryList.Items[i] - if mcpServerEntry.Spec.GroupRef == groupName { + if mcpServerEntry.Spec.GroupRef.GetName() == groupName { groupWorkloads = append(groupWorkloads, TypedWorkload{ Name: mcpServerEntry.Name, Type: WorkloadTypeMCPServerEntry, diff --git a/pkg/vmcp/workloads/k8s_test.go b/pkg/vmcp/workloads/k8s_test.go index e279014708..02db8e163f 100644 --- a/pkg/vmcp/workloads/k8s_test.go +++ b/pkg/vmcp/workloads/k8s_test.go @@ -461,7 +461,7 @@ func TestListWorkloadsInGroup(t *testing.T) { Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, } @@ -473,7 +473,7 @@ func TestListWorkloadsInGroup(t *testing.T) { Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, } @@ -485,7 +485,7 @@ func TestListWorkloadsInGroup(t *testing.T) { Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", - GroupRef: "group-b", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-b"}, }, } @@ -523,7 +523,7 @@ func TestListWorkloadsInGroup_MCPRemoteProxies(t *testing.T) { Namespace: namespace, }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, } @@ -533,7 +533,7 @@ func TestListWorkloadsInGroup_MCPRemoteProxies(t *testing.T) { Namespace: namespace, }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, } @@ -543,7 +543,7 @@ func TestListWorkloadsInGroup_MCPRemoteProxies(t *testing.T) { Namespace: namespace, }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: "group-b", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-b"}, }, } @@ -583,7 +583,7 @@ func TestListWorkloadsInGroup_MixedWorkloads(t *testing.T) { Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, } @@ -595,7 +595,7 @@ func TestListWorkloadsInGroup_MixedWorkloads(t *testing.T) { Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", - GroupRef: "group-b", // Different group + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-b"}, // Different group }, } @@ -606,7 +606,7 @@ func TestListWorkloadsInGroup_MixedWorkloads(t *testing.T) { Namespace: namespace, }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, } @@ -616,7 +616,7 @@ func TestListWorkloadsInGroup_MixedWorkloads(t *testing.T) { Namespace: namespace, }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, } @@ -995,7 +995,7 @@ func TestListWorkloadsInGroup_MCPServerEntries(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp1.example.com", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, } @@ -1007,7 +1007,7 @@ func TestListWorkloadsInGroup_MCPServerEntries(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp2.example.com", Transport: "sse", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, } @@ -1019,7 +1019,7 @@ func TestListWorkloadsInGroup_MCPServerEntries(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp3.example.com", Transport: "streamable-http", - GroupRef: "group-b", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-b"}, }, } @@ -1057,7 +1057,7 @@ func TestListWorkloadsInGroup_AllWorkloadTypes(t *testing.T) { Spec: mcpv1alpha1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, } @@ -1067,7 +1067,7 @@ func TestListWorkloadsInGroup_AllWorkloadTypes(t *testing.T) { Namespace: namespace, }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, } @@ -1079,7 +1079,7 @@ func TestListWorkloadsInGroup_AllWorkloadTypes(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, } @@ -1108,7 +1108,7 @@ func TestGetWorkloadAsVMCPBackend_MCPServerEntry(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/v1", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, Status: mcpv1alpha1.MCPServerEntryStatus{ Phase: mcpv1alpha1.MCPServerEntryPhaseValid, @@ -1160,7 +1160,7 @@ func TestMCPServerEntryToBackend_BasicFields(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/v1", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, Status: mcpv1alpha1.MCPServerEntryStatus{ Phase: mcpv1alpha1.MCPServerEntryPhaseValid, @@ -1200,7 +1200,7 @@ func TestMCPServerEntryToBackend_SSETransport(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/sse", Transport: "sse", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, Status: mcpv1alpha1.MCPServerEntryStatus{ Phase: mcpv1alpha1.MCPServerEntryPhaseValid, @@ -1233,7 +1233,7 @@ func TestMCPServerEntryToBackend_WithAnnotations(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/v1", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, Status: mcpv1alpha1.MCPServerEntryStatus{ Phase: mcpv1alpha1.MCPServerEntryPhaseValid, @@ -1263,7 +1263,7 @@ func TestMCPServerEntryToBackend_EmptyRemoteURL(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, Status: mcpv1alpha1.MCPServerEntryStatus{ Phase: mcpv1alpha1.MCPServerEntryPhaseValid, @@ -1308,7 +1308,7 @@ func TestGetWorkloadAsVMCPBackend_MCPServerEntry_NonValidPhaseSkipped(t *testing Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/v1", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, Status: mcpv1alpha1.MCPServerEntryStatus{ Phase: tt.phase, @@ -1406,7 +1406,7 @@ func TestMCPServerEntryToBackend_HealthStatusMapping(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, Status: mcpv1alpha1.MCPServerEntryStatus{ Phase: tt.phase, @@ -1437,7 +1437,7 @@ func TestDiscoverAuth_MCPServerEntry_NoAuthConfig(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, }, Status: mcpv1alpha1.MCPServerEntryStatus{ Phase: mcpv1alpha1.MCPServerEntryPhaseValid, @@ -1470,7 +1470,7 @@ func TestDiscoverAuth_MCPServerEntry_AuthConfigNotFound(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "non-existent-auth-config", }, @@ -1538,7 +1538,7 @@ func TestDiscoverAuth_MCPServerEntry_TokenExchange(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: "group-a", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "entry-token-exchange", }, @@ -1579,7 +1579,7 @@ func TestMCPServerEntryToBackend_SetsBackendTypeEntry(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/mcp", Transport: "streamable-http", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, Status: mcpv1alpha1.MCPServerEntryStatus{ Phase: mcpv1alpha1.MCPServerEntryPhaseValid, @@ -1619,7 +1619,7 @@ func TestMCPServerEntryToBackend_WithCABundle(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://internal-mcp.corp:8443/mcp", Transport: "streamable-http", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, CABundleRef: &mcpv1alpha1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ @@ -1658,7 +1658,7 @@ func TestMCPServerEntryToBackend_CABundleMissing_ReturnsNil(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://internal-mcp.corp:8443/mcp", Transport: "streamable-http", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, CABundleRef: &mcpv1alpha1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ @@ -1706,7 +1706,7 @@ func TestMCPServerEntryToBackend_WithCABundleDefaultKey(t *testing.T) { Spec: mcpv1alpha1.MCPServerEntrySpec{ RemoteURL: "https://internal-mcp.corp:8443/mcp", Transport: "streamable-http", - GroupRef: "test-group", + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, CABundleRef: &mcpv1alpha1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/audit-chainsaw-test.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/audit-chainsaw-test.yaml index a40afff0ee..059ecb72d6 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/audit-chainsaw-test.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/audit-chainsaw-test.yaml @@ -40,8 +40,9 @@ spec: name: test-vmcp-audit-enabled namespace: toolhive-system spec: + groupRef: + name: test-audit-group config: - groupRef: test-audit-group audit: enabled: true incomingAuth: @@ -88,8 +89,8 @@ spec: name: test-vmcp-no-audit namespace: toolhive-system spec: - config: - groupRef: test-audit-group + groupRef: + name: test-audit-group incomingAuth: type: anonymous - assert: @@ -145,8 +146,9 @@ spec: name: test-vmcp-audit-disabled namespace: toolhive-system spec: + groupRef: + name: test-audit-group config: - groupRef: test-audit-group audit: enabled: false incomingAuth: diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/basic/chainsaw-test.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/basic/chainsaw-test.yaml index 7dfce47b30..bf3dcf2236 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/basic/chainsaw-test.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/basic/chainsaw-test.yaml @@ -25,8 +25,9 @@ spec: metadata: name: test-vmcp spec: + groupRef: + name: test-group config: - groupRef: test-group aggregation: conflictResolution: prefix incomingAuth: diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-controller.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-controller.yaml index c229d9713a..caa000d6ee 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-controller.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-controller.yaml @@ -4,8 +4,9 @@ metadata: name: test-vmcp-controller namespace: toolhive-system spec: + groupRef: + name: test-group-controller config: - groupRef: test-group-controller aggregation: conflictResolution: prefix conflictResolutionConfig: diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-with-oidc.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-with-oidc.yaml index 84c5ee5bbf..b19153b3be 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-with-oidc.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-with-oidc.yaml @@ -4,8 +4,9 @@ metadata: name: test-vmcp-oidc namespace: toolhive-system spec: + groupRef: + name: test-group-controller config: - groupRef: test-group-controller aggregation: conflictResolution: prefix conflictResolutionConfig: diff --git a/test/e2e/chainsaw/operator/validation/virtualmcpserver/chainsaw-test.yaml b/test/e2e/chainsaw/operator/validation/virtualmcpserver/chainsaw-test.yaml index 72b4ef6879..22601f4b25 100644 --- a/test/e2e/chainsaw/operator/validation/virtualmcpserver/chainsaw-test.yaml +++ b/test/e2e/chainsaw/operator/validation/virtualmcpserver/chainsaw-test.yaml @@ -26,8 +26,8 @@ spec: incomingAuth: type: oidc # Requires oidcConfig but it's missing serviceType: ClusterIP - config: - groupRef: test-group + groupRef: + name: test-group expect: - check: ($error != null): true @@ -47,8 +47,8 @@ spec: incomingAuth: type: anonymous serviceType: ClusterIP - config: - groupRef: test-group + groupRef: + name: test-group - assert: resource: apiVersion: toolhive.stacklok.dev/v1alpha1 diff --git a/test/e2e/thv-operator/virtualmcp/helpers.go b/test/e2e/thv-operator/virtualmcp/helpers.go index d68614afda..b5a02a4450 100644 --- a/test/e2e/thv-operator/virtualmcp/helpers.go +++ b/test/e2e/thv-operator/virtualmcp/helpers.go @@ -188,7 +188,7 @@ func GetMCPGroupBackends(ctx context.Context, c client.Client, groupName, namesp // Filter MCPServers that reference this group var backends []mcpv1alpha1.MCPServer for _, mcpServer := range mcpServerList.Items { - if mcpServer.Spec.GroupRef == groupName { + if mcpServer.Spec.GroupRef.GetName() == groupName { backends = append(backends, mcpServer) } } @@ -667,7 +667,7 @@ func CreateMCPServerAndWait( Namespace: namespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: groupRef, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupRef}, Image: image, Transport: "streamable-http", ProxyPort: 8080, @@ -757,7 +757,7 @@ func CreateMultipleMCPServersInParallel( Namespace: backends[idx].Namespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: backends[idx].GroupRef, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: backends[idx].GroupRef}, Image: backends[idx].Image, Transport: backendTransport, ProxyPort: 8080, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go index e933c8ee68..ac0c315bfe 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go @@ -1446,7 +1446,7 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -1468,7 +1468,7 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go index 5f10ab327f..332c4abc39 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go @@ -52,7 +52,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -71,7 +71,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go index 68c3978951..c37939831f 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go @@ -55,7 +55,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -71,7 +71,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.OSVMCPServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -478,7 +478,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go index 564ac7f04a..cdb8e2cfb8 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go @@ -65,7 +65,7 @@ var _ = Describe("VirtualMCPServer Unauthenticated Backend Auth", Ordered, func( Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -240,7 +240,7 @@ var _ = Describe("VirtualMCPServer Inline Unauthenticated Backend Auth", Ordered Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -416,7 +416,7 @@ var _ = Describe("VirtualMCPServer HeaderInjection Backend Auth", Ordered, func( Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -639,7 +639,7 @@ var _ = Describe("VirtualMCPServer Inline HeaderInjection Backend Auth", Ordered Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -830,7 +830,7 @@ var _ = Describe("VirtualMCPServer Health Check with HeaderInjection Auth", Orde Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -1046,7 +1046,7 @@ var _ = Describe("VirtualMCPServer Health Check with TokenExchange Auth", Ordere Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go index 07d47fe7df..9ab740b550 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go @@ -67,7 +67,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -441,7 +441,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go index 61f01c6904..625b1ca1f2 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go @@ -42,7 +42,7 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go index 4273a67cac..ba3dde3d58 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go @@ -46,7 +46,7 @@ var _ = Describe("VirtualMCPServer Yardstick Base", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -64,7 +64,7 @@ var _ = Describe("VirtualMCPServer Yardstick Base", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go index 41a56f8872..87d9de65a6 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go @@ -75,7 +75,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -175,7 +175,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, From b250b9065384d294261616631fbc7020b18301d6 Mon Sep 17 00:00:00 2001 From: Guillermo Gomez <86891850+gmogmzGithub@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:23:18 -0600 Subject: [PATCH 025/117] Add --remote-auth-scope-param-name for non-standard OAuth scope parameters (#4712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add --remote-auth-scope-param-name flag for non-standard OAuth scope parameters Some OAuth providers use non-standard query parameter names for scopes in the authorization URL. For example, Slack's OAuth v2 requires user-token scopes in "user_scope" instead of the standard "scope" parameter. This causes ToolHive's OAuth flow to fail with invalid_scope errors when connecting to providers like Slack's MCP server. Add a new --remote-auth-scope-param-name flag that allows users to override the query parameter name used for scopes. When set, scopes are sent under the specified parameter name and the standard "scope" parameter is cleared. The oauth2Config.Scopes field is preserved so token refresh requests continue to work correctly. Signed-off-by: Gustavo Gomez * Address review feedback: fix empty scope= and OIDC fallback path - Replace SetAuthURLParam("scope", "") with temporarily nil-ing oauth2Config.Scopes before AuthCodeURL, then restoring via defer. This omits the scope parameter entirely instead of producing an invalid empty scope= (RFC 6749 §3.3). - Propagate ScopeParamName on the OIDC discovery fallback path in createOAuthConfig, so --remote-auth-scope-param-name works with --remote-auth-issuer as well. - Strengthen test assertion to verify scope parameter is truly absent, not just empty-valued. Signed-off-by: Gustavo Gomez * Fix missing swagger docs for scope_param_name field Signed-off-by: Gustavo Gomez --------- Signed-off-by: Gustavo Gomez --- cmd/thv/app/auth_flags.go | 3 +++ cmd/thv/app/proxy.go | 34 ++++++++++++++------------ cmd/thv/app/run_flags.go | 4 +++ docs/cli/thv_proxy.md | 1 + docs/cli/thv_run.md | 1 + docs/server/docs.go | 4 +++ docs/server/swagger.json | 4 +++ docs/server/swagger.yaml | 6 +++++ pkg/auth/discovery/discovery.go | 9 ++++++- pkg/auth/oauth/flow.go | 24 ++++++++++++++++++ pkg/auth/oauth/flow_test.go | 43 +++++++++++++++++++++++++++++++++ pkg/auth/oauth/manual.go | 20 ++++++++------- pkg/auth/oauth/manual_test.go | 5 ++++ pkg/auth/remote/config.go | 5 ++++ pkg/auth/remote/handler.go | 21 ++++++++-------- 15 files changed, 148 insertions(+), 36 deletions(-) diff --git a/cmd/thv/app/auth_flags.go b/cmd/thv/app/auth_flags.go index 2a1a8a1bb6..d192b7379a 100644 --- a/cmd/thv/app/auth_flags.go +++ b/cmd/thv/app/auth_flags.go @@ -72,6 +72,7 @@ type RemoteAuthFlags struct { RemoteAuthClientSecret string RemoteAuthClientSecretFile string RemoteAuthScopes []string + RemoteAuthScopeParamName string RemoteAuthSkipBrowser bool RemoteAuthTimeout time.Duration RemoteAuthCallbackPort int @@ -163,6 +164,8 @@ func AddRemoteAuthFlags(cmd *cobra.Command, config *RemoteAuthFlags) { "authorization server supports dynamic client registration (RFC 7591) or if using PKCE)") cmd.Flags().StringSliceVar(&config.RemoteAuthScopes, "remote-auth-scopes", []string{}, "OAuth scopes to request for remote server authentication (defaults: OIDC uses 'openid,profile,email')") + cmd.Flags().StringVar(&config.RemoteAuthScopeParamName, "remote-auth-scope-param-name", "", + "Override the query parameter name for scopes in the authorization URL (e.g., 'user_scope' for Slack OAuth)") cmd.Flags().BoolVar(&config.RemoteAuthSkipBrowser, "remote-auth-skip-browser", false, "Skip opening browser for remote server OAuth flow (default false)") cmd.Flags().DurationVar(&config.RemoteAuthTimeout, "remote-auth-timeout", 30*time.Second, diff --git a/cmd/thv/app/proxy.go b/cmd/thv/app/proxy.go index b768d22ab5..de33227bcf 100644 --- a/cmd/thv/app/proxy.go +++ b/cmd/thv/app/proxy.go @@ -360,14 +360,15 @@ func handleOutgoingAuthentication(ctx context.Context) (*discovery.OAuthFlowResu } flowConfig := &discovery.OAuthFlowConfig{ - ClientID: remoteAuthFlags.RemoteAuthClientID, - ClientSecret: clientSecret, - AuthorizeURL: remoteAuthFlags.RemoteAuthAuthorizeURL, - TokenURL: remoteAuthFlags.RemoteAuthTokenURL, - Scopes: remoteAuthFlags.RemoteAuthScopes, - CallbackPort: remoteAuthFlags.RemoteAuthCallbackPort, - Timeout: remoteAuthFlags.RemoteAuthTimeout, - SkipBrowser: remoteAuthFlags.RemoteAuthSkipBrowser, + ClientID: remoteAuthFlags.RemoteAuthClientID, + ClientSecret: clientSecret, + AuthorizeURL: remoteAuthFlags.RemoteAuthAuthorizeURL, + TokenURL: remoteAuthFlags.RemoteAuthTokenURL, + Scopes: remoteAuthFlags.RemoteAuthScopes, + CallbackPort: remoteAuthFlags.RemoteAuthCallbackPort, + Timeout: remoteAuthFlags.RemoteAuthTimeout, + SkipBrowser: remoteAuthFlags.RemoteAuthSkipBrowser, + ScopeParamName: remoteAuthFlags.RemoteAuthScopeParamName, } result, err := discovery.PerformOAuthFlow(ctx, remoteAuthFlags.RemoteAuthIssuer, flowConfig) @@ -390,14 +391,15 @@ func handleOutgoingAuthentication(ctx context.Context) (*discovery.OAuthFlowResu // Perform OAuth flow with discovered configuration flowConfig := &discovery.OAuthFlowConfig{ - ClientID: remoteAuthFlags.RemoteAuthClientID, - ClientSecret: clientSecret, - AuthorizeURL: remoteAuthFlags.RemoteAuthAuthorizeURL, - TokenURL: remoteAuthFlags.RemoteAuthTokenURL, - Scopes: remoteAuthFlags.RemoteAuthScopes, - CallbackPort: remoteAuthFlags.RemoteAuthCallbackPort, - Timeout: remoteAuthFlags.RemoteAuthTimeout, - SkipBrowser: remoteAuthFlags.RemoteAuthSkipBrowser, + ClientID: remoteAuthFlags.RemoteAuthClientID, + ClientSecret: clientSecret, + AuthorizeURL: remoteAuthFlags.RemoteAuthAuthorizeURL, + TokenURL: remoteAuthFlags.RemoteAuthTokenURL, + Scopes: remoteAuthFlags.RemoteAuthScopes, + CallbackPort: remoteAuthFlags.RemoteAuthCallbackPort, + Timeout: remoteAuthFlags.RemoteAuthTimeout, + SkipBrowser: remoteAuthFlags.RemoteAuthSkipBrowser, + ScopeParamName: remoteAuthFlags.RemoteAuthScopeParamName, } result, err := discovery.PerformOAuthFlow(ctx, authInfo.Realm, flowConfig) diff --git a/cmd/thv/app/run_flags.go b/cmd/thv/app/run_flags.go index d17e310f8e..94c159af8a 100644 --- a/cmd/thv/app/run_flags.go +++ b/cmd/thv/app/run_flags.go @@ -962,6 +962,9 @@ func getRemoteAuthFromRemoteServerMetadata( authCfg.OAuthParams = oc.OAuthParams } + // ScopeParamName: from CLI flag only (not yet supported in registry metadata) + authCfg.ScopeParamName = f.RemoteAuthScopeParamName + // Resolve bearer token from multiple sources (flag, file, environment variable) resolvedBearerToken, err := resolveSecret( f.RemoteAuthBearerToken, @@ -1023,6 +1026,7 @@ func getRemoteAuthFromRunFlags(runFlags *RunFlags) (*remote.Config, error) { ClientID: runFlags.RemoteAuthFlags.RemoteAuthClientID, ClientSecret: clientSecret, Scopes: runFlags.RemoteAuthFlags.RemoteAuthScopes, + ScopeParamName: runFlags.RemoteAuthFlags.RemoteAuthScopeParamName, SkipBrowser: runFlags.RemoteAuthFlags.RemoteAuthSkipBrowser, Timeout: runFlags.RemoteAuthFlags.RemoteAuthTimeout, CallbackPort: runFlags.RemoteAuthFlags.RemoteAuthCallbackPort, diff --git a/docs/cli/thv_proxy.md b/docs/cli/thv_proxy.md index d93a8ed72e..be2e8d92d2 100644 --- a/docs/cli/thv_proxy.md +++ b/docs/cli/thv_proxy.md @@ -117,6 +117,7 @@ thv proxy [flags] SERVER_NAME --remote-auth-client-secret-file string Path to file containing OAuth client secret (alternative to --remote-auth-client-secret) (optional if the authorization server supports dynamic client registration (RFC 7591) or if using PKCE) --remote-auth-issuer string OAuth/OIDC issuer URL for remote server authentication (e.g., https://accounts.google.com) --remote-auth-resource string OAuth 2.0 resource indicator (RFC 8707) + --remote-auth-scope-param-name string Override the query parameter name for scopes in the authorization URL (e.g., 'user_scope' for Slack OAuth) --remote-auth-scopes strings OAuth scopes to request for remote server authentication (defaults: OIDC uses 'openid,profile,email') --remote-auth-skip-browser Skip opening browser for remote server OAuth flow (default false) --remote-auth-timeout duration Timeout for OAuth authentication flow (e.g., 30s, 1m, 2m30s) (default 30s) diff --git a/docs/cli/thv_run.md b/docs/cli/thv_run.md index 257705f733..d3c38dc4cb 100644 --- a/docs/cli/thv_run.md +++ b/docs/cli/thv_run.md @@ -167,6 +167,7 @@ thv run [flags] SERVER_OR_IMAGE_OR_PROTOCOL [-- ARGS...] --remote-auth-client-secret-file string Path to file containing OAuth client secret (alternative to --remote-auth-client-secret) (optional if the authorization server supports dynamic client registration (RFC 7591) or if using PKCE) --remote-auth-issuer string OAuth/OIDC issuer URL for remote server authentication (e.g., https://accounts.google.com) --remote-auth-resource string OAuth 2.0 resource indicator (RFC 8707) + --remote-auth-scope-param-name string Override the query parameter name for scopes in the authorization URL (e.g., 'user_scope' for Slack OAuth) --remote-auth-scopes strings OAuth scopes to request for remote server authentication (defaults: OIDC uses 'openid,profile,email') --remote-auth-skip-browser Skip opening browser for remote server OAuth flow (default false) --remote-auth-timeout duration Timeout for OAuth authentication flow (e.g., 30s, 1m, 2m30s) (default 30s) diff --git a/docs/server/docs.go b/docs/server/docs.go index df70eb0529..86da675929 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -316,6 +316,10 @@ const docTemplate = `{ "description": "Resource is the OAuth 2.0 resource indicator (RFC 8707).", "type": "string" }, + "scope_param_name": { + "description": "ScopeParamName overrides the query parameter name used to send scopes in the\nauthorization URL. When empty, the standard \"scope\" parameter is used.\nSome providers require a non-standard name (e.g., Slack uses \"user_scope\").", + "type": "string" + }, "scopes": { "items": { "type": "string" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 4008970275..405f04dcd8 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -309,6 +309,10 @@ "description": "Resource is the OAuth 2.0 resource indicator (RFC 8707).", "type": "string" }, + "scope_param_name": { + "description": "ScopeParamName overrides the query parameter name used to send scopes in the\nauthorization URL. When empty, the standard \"scope\" parameter is used.\nSome providers require a non-standard name (e.g., Slack uses \"user_scope\").", + "type": "string" + }, "scopes": { "items": { "type": "string" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index ac859bc102..81d5eb3c9b 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -310,6 +310,12 @@ components: resource: description: Resource is the OAuth 2.0 resource indicator (RFC 8707). type: string + scope_param_name: + description: |- + ScopeParamName overrides the query parameter name used to send scopes in the + authorization URL. When empty, the standard "scope" parameter is used. + Some providers require a non-standard name (e.g., Slack uses "user_scope"). + type: string scopes: items: type: string diff --git a/pkg/auth/discovery/discovery.go b/pkg/auth/discovery/discovery.go index 6a69fc6a15..b3ef713b73 100644 --- a/pkg/auth/discovery/discovery.go +++ b/pkg/auth/discovery/discovery.go @@ -509,6 +509,7 @@ type OAuthFlowConfig struct { SkipBrowser bool Resource string // RFC 8707 resource indicator (optional) OAuthParams map[string]string + ScopeParamName string // Override scope query parameter name (e.g., "user_scope" for Slack) } // OAuthFlowResult contains the result of an OAuth flow @@ -645,12 +646,13 @@ func createOAuthConfig(ctx context.Context, issuer string, config *OAuthFlowConf config.CallbackPort, config.Resource, config.OAuthParams, + config.ScopeParamName, ) } // Fall back to OIDC discovery slog.Debug("Using OIDC discovery") - return oauth.CreateOAuthConfigFromOIDC( + cfg, err := oauth.CreateOAuthConfigFromOIDC( ctx, issuer, config.ClientID, @@ -660,6 +662,11 @@ func createOAuthConfig(ctx context.Context, issuer string, config *OAuthFlowConf config.CallbackPort, config.Resource, ) + if err != nil { + return nil, err + } + cfg.ScopeParamName = config.ScopeParamName + return cfg, nil } func newOAuthFlow(ctx context.Context, oauthConfig *oauth.Config, config *OAuthFlowConfig) (*OAuthFlowResult, error) { diff --git a/pkg/auth/oauth/flow.go b/pkg/auth/oauth/flow.go index da9a9249df..bb17dd3060 100644 --- a/pkg/auth/oauth/flow.go +++ b/pkg/auth/oauth/flow.go @@ -15,6 +15,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -60,6 +61,13 @@ type Config struct { // OAuthParams are additional parameters to pass to the authorization URL OAuthParams map[string]string + + // ScopeParamName overrides the query parameter name used to send scopes in the + // authorization URL. When empty (default), the standard "scope" parameter is used. + // Some providers use non-standard parameter names (e.g., Slack uses "user_scope" + // for user-token scopes). When set, scopes are sent under this parameter name + // instead of "scope", and the standard "scope" parameter is cleared. + ScopeParamName string } // Flow handles the OAuth authentication flow @@ -267,6 +275,22 @@ func (f *Flow) buildAuthURL() string { } } + // When a custom scope parameter name is configured, move scopes from the + // standard "scope" parameter to the custom one. This supports OAuth providers + // that use non-standard parameter names (e.g., Slack's "user_scope"). + // We temporarily nil out oauth2Config.Scopes so the library omits the standard + // "scope" parameter entirely (an empty scope= would violate RFC 6749 §3.3). + // Scopes are restored via defer so token refresh requests still work correctly. + if f.config.ScopeParamName != "" && len(f.oauth2Config.Scopes) > 0 { + scopeValue := strings.Join(f.oauth2Config.Scopes, " ") + savedScopes := f.oauth2Config.Scopes + f.oauth2Config.Scopes = nil + defer func() { f.oauth2Config.Scopes = savedScopes }() + opts = append(opts, + oauth2.SetAuthURLParam(f.config.ScopeParamName, scopeValue), + ) + } + // Add PKCE parameters if enabled if f.config.UsePKCE { opts = append(opts, diff --git a/pkg/auth/oauth/flow_test.go b/pkg/auth/oauth/flow_test.go index 713e7c649f..6a410a054b 100644 --- a/pkg/auth/oauth/flow_test.go +++ b/pkg/auth/oauth/flow_test.go @@ -255,6 +255,49 @@ func TestBuildAuthURL(t *testing.T) { assert.Equal(t, "S256", query.Get("code_challenge_method")) }, }, + { + name: "auth URL with custom scope parameter name", + config: &Config{ + ClientID: "test-client", + AuthURL: "https://example.com/auth", + TokenURL: "https://example.com/token", + Scopes: []string{"search:read", "chat:write"}, + ScopeParamName: "user_scope", + }, + validate: func(t *testing.T, authURL string, _ *Flow) { + t.Helper() + parsedURL, err := url.Parse(authURL) + require.NoError(t, err) + + query := parsedURL.Query() + // Standard "scope" parameter should be absent, not empty + _, hasScope := query["scope"] + assert.False(t, hasScope, "scope parameter should be absent, not empty") + // Scopes should appear under the custom parameter name + assert.Contains(t, query.Get("user_scope"), "search:read") + assert.Contains(t, query.Get("user_scope"), "chat:write") + }, + }, + { + name: "auth URL with scope param name but no scopes", + config: &Config{ + ClientID: "test-client", + AuthURL: "https://example.com/auth", + TokenURL: "https://example.com/token", + Scopes: []string{}, + ScopeParamName: "user_scope", + }, + validate: func(t *testing.T, authURL string, _ *Flow) { + t.Helper() + parsedURL, err := url.Parse(authURL) + require.NoError(t, err) + + query := parsedURL.Query() + // Neither scope nor user_scope should be present + assert.Empty(t, query.Get("scope")) + assert.Empty(t, query.Get("user_scope")) + }, + }, } for _, tt := range tests { diff --git a/pkg/auth/oauth/manual.go b/pkg/auth/oauth/manual.go index d6abeaf882..40a8ca7054 100644 --- a/pkg/auth/oauth/manual.go +++ b/pkg/auth/oauth/manual.go @@ -19,6 +19,7 @@ func CreateOAuthConfigManual( callbackPort int, resource string, oauthParams map[string]string, + scopeParamName string, ) (*Config, error) { if clientID == "" { return nil, fmt.Errorf("client ID is required") @@ -44,14 +45,15 @@ func CreateOAuthConfigManual( } return &Config{ - ClientID: clientID, - ClientSecret: clientSecret, - AuthURL: authURL, - TokenURL: tokenURL, - Scopes: scopes, - UsePKCE: usePKCE, - CallbackPort: callbackPort, - Resource: resource, - OAuthParams: oauthParams, + ClientID: clientID, + ClientSecret: clientSecret, + AuthURL: authURL, + TokenURL: tokenURL, + Scopes: scopes, + UsePKCE: usePKCE, + CallbackPort: callbackPort, + Resource: resource, + OAuthParams: oauthParams, + ScopeParamName: scopeParamName, }, nil } diff --git a/pkg/auth/oauth/manual_test.go b/pkg/auth/oauth/manual_test.go index 5333b4d265..7eb8925afd 100644 --- a/pkg/auth/oauth/manual_test.go +++ b/pkg/auth/oauth/manual_test.go @@ -269,6 +269,7 @@ func TestCreateOAuthConfigManual(t *testing.T) { tt.callbackPort, tt.resource, oauthParams, + "", ) if tt.expectError { @@ -335,6 +336,7 @@ func TestCreateOAuthConfigManual_ScopeDefaultBehavior(t *testing.T) { 8080, "", nil, // No OAuth params for basic tests + "", // No scope param name override ) require.NoError(t, err) @@ -378,6 +380,7 @@ func TestCreateOAuthConfigManual_PKCEBehavior(t *testing.T) { 8080, "", nil, // No OAuth params for basic tests + "", // No scope param name override ) require.NoError(t, err) @@ -426,6 +429,7 @@ func TestCreateOAuthConfigManual_CallbackPortBehavior(t *testing.T) { tt.port, "", nil, // No OAuth params for basic tests + "", // No scope param name override ) require.NoError(t, err) @@ -491,6 +495,7 @@ func TestCreateOAuthConfigManual_OAuthParamsBehavior(t *testing.T) { 8080, "", tt.oauthParams, + "", ) require.NoError(t, err) diff --git a/pkg/auth/remote/config.go b/pkg/auth/remote/config.go index 049e5ab6b2..98c8fecd2f 100644 --- a/pkg/auth/remote/config.go +++ b/pkg/auth/remote/config.go @@ -44,6 +44,11 @@ type Config struct { // OAuth parameters for server-specific customization OAuthParams map[string]string `json:"oauth_params,omitempty" yaml:"oauth_params,omitempty"` + // ScopeParamName overrides the query parameter name used to send scopes in the + // authorization URL. When empty, the standard "scope" parameter is used. + // Some providers require a non-standard name (e.g., Slack uses "user_scope"). + ScopeParamName string `json:"scope_param_name,omitempty" yaml:"scope_param_name,omitempty"` + // Bearer token configuration (alternative to OAuth) BearerToken string `json:"bearer_token,omitempty" yaml:"bearer_token,omitempty"` //nolint:gosec // G117 BearerTokenFile string `json:"bearer_token_file,omitempty" yaml:"bearer_token_file,omitempty"` diff --git a/pkg/auth/remote/handler.go b/pkg/auth/remote/handler.go index a798698831..ccce3fb17b 100644 --- a/pkg/auth/remote/handler.go +++ b/pkg/auth/remote/handler.go @@ -146,16 +146,17 @@ func (h *Handler) performOAuthFlow( // buildOAuthFlowConfig creates the OAuth flow configuration func (h *Handler) buildOAuthFlowConfig(scopes []string, authServerInfo *discovery.AuthServerInfo) *discovery.OAuthFlowConfig { flowConfig := &discovery.OAuthFlowConfig{ - ClientID: h.config.ClientID, - ClientSecret: h.config.ClientSecret, - AuthorizeURL: h.config.AuthorizeURL, - TokenURL: h.config.TokenURL, - Scopes: scopes, - CallbackPort: h.config.CallbackPort, - Timeout: h.config.Timeout, - SkipBrowser: h.config.SkipBrowser, - Resource: h.config.Resource, - OAuthParams: h.config.OAuthParams, + ClientID: h.config.ClientID, + ClientSecret: h.config.ClientSecret, + AuthorizeURL: h.config.AuthorizeURL, + TokenURL: h.config.TokenURL, + Scopes: scopes, + CallbackPort: h.config.CallbackPort, + Timeout: h.config.Timeout, + SkipBrowser: h.config.SkipBrowser, + Resource: h.config.Resource, + OAuthParams: h.config.OAuthParams, + ScopeParamName: h.config.ScopeParamName, } // If we have discovered endpoints from the authorization server metadata, From 7c3cdc7decd600b93fe8378759d3ffd4add46017 Mon Sep 17 00:00:00 2001 From: Stacklok Bot <140063061+stacklokbot@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:29:12 -0700 Subject: [PATCH 026/117] Release v0.20.0 (#4816) * Update VERSION for release Release-Triggered-By: blkt * Update Chart.yaml for release Release-Triggered-By: blkt * Update Chart.yaml for release Release-Triggered-By: blkt * Update Chart.yaml for release Release-Triggered-By: blkt * Update Chart.yaml for release Release-Triggered-By: blkt * Update values.yaml for release Release-Triggered-By: blkt * Update values.yaml for release Release-Triggered-By: blkt * Update values.yaml for release Release-Triggered-By: blkt * Update VERSION for release Release-Triggered-By: blkt * Update Chart.yaml for release Release-Triggered-By: blkt * Update README.md for release Release-Triggered-By: blkt * Update Chart.yaml for release Release-Triggered-By: blkt * Update README.md for release Release-Triggered-By: blkt * Update values.yaml for release Release-Triggered-By: blkt --- VERSION | 2 +- deploy/charts/operator-crds/Chart.yaml | 4 ++-- deploy/charts/operator-crds/README.md | 2 +- deploy/charts/operator/Chart.yaml | 4 ++-- deploy/charts/operator/README.md | 10 +++++----- deploy/charts/operator/values.yaml | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index 1cf0537c34..5a03fb737b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.19.0 +0.20.0 diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index 9e4869fca9..8149dffaaa 100644 --- a/deploy/charts/operator-crds/Chart.yaml +++ b/deploy/charts/operator-crds/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator-crds description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. type: application -version: 0.19.0 -appVersion: "v0.19.0" +version: 0.20.0 +appVersion: "v0.20.0" diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index 42722e6368..84c6ecd60d 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -1,6 +1,6 @@ # ToolHive Operator CRDs Helm Chart -![Version: 0.19.0](https://img.shields.io/badge/Version-0.19.0-informational?style=flat-square) +![Version: 0.20.0](https://img.shields.io/badge/Version-0.20.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. diff --git a/deploy/charts/operator/Chart.yaml b/deploy/charts/operator/Chart.yaml index 7a7ae616f6..488c63fcd8 100644 --- a/deploy/charts/operator/Chart.yaml +++ b/deploy/charts/operator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator description: A Helm chart for deploying the ToolHive Operator into Kubernetes. type: application -version: 0.19.0 -appVersion: "v0.19.0" +version: 0.20.0 +appVersion: "v0.20.0" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index 01676ea559..5d86059cb4 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -1,6 +1,6 @@ # ToolHive Operator Helm Chart -![Version: 0.19.0](https://img.shields.io/badge/Version-0.19.0-informational?style=flat-square) +![Version: 0.20.0](https://img.shields.io/badge/Version-0.20.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for deploying the ToolHive Operator into Kubernetes. @@ -52,7 +52,7 @@ The command removes all the Kubernetes components associated with the chart and |-----|------|---------|-------------| | fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources | | nameOverride | string | `""` | Override the name of the chart | -| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"env":[],"features":{"experimental":false,"registry":true,"server":true,"virtualMCP":true},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.19.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.19.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.19.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | +| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"env":[],"features":{"experimental":false,"registry":true,"server":true,"virtualMCP":true},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.20.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.20.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.20.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | | operator.affinity | object | `{}` | Affinity settings for the operator pod | | operator.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | Configuration for horizontal pod autoscaling | | operator.autoscaling.enabled | bool | `false` | Enable autoscaling for the operator | @@ -68,7 +68,7 @@ The command removes all the Kubernetes components associated with the chart and | operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container | | operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container | | operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container | -| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.19.0"` | Container image for the operator | +| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.20.0"` | Container image for the operator | | operator.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy for the operator container | | operator.imagePullSecrets | list | `[]` | List of image pull secrets to use | | operator.leaderElectionRole | object | `{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]}` | Leader election role configuration | @@ -95,8 +95,8 @@ The command removes all the Kubernetes components associated with the chart and | operator.serviceAccount.labels | object | `{}` | Labels to add to the service account | | operator.serviceAccount.name | string | `"toolhive-operator"` | The name of the service account to use. If not set and create is true, a name is generated. | | operator.tolerations | list | `[]` | Tolerations for the operator pod | -| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.19.0"` | Image to use for Toolhive runners | -| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.19.0"` | Image to use for Virtual MCP Server (vMCP) deployments | +| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.20.0"` | Image to use for Toolhive runners | +| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.20.0"` | Image to use for Virtual MCP Server (vMCP) deployments | | operator.volumeMounts | list | `[]` | Additional volume mounts on the operator container | | operator.volumes | list | `[]` | Additional volumes to mount on the operator pod | | registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.1.1"}` | All values for the registry API deployment and associated resources | diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index 39cefcd7cc..7d1022831e 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -26,15 +26,15 @@ operator: # -- List of image pull secrets to use imagePullSecrets: [] # -- Container image for the operator - image: ghcr.io/stacklok/toolhive/operator:v0.19.0 + image: ghcr.io/stacklok/toolhive/operator:v0.20.0 # -- Image pull policy for the operator container imagePullPolicy: IfNotPresent # -- Image to use for Toolhive runners - toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.19.0 + toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.20.0 # -- Image to use for Virtual MCP Server (vMCP) deployments - vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.19.0 + vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.20.0 # -- Host for the proxy deployed by the operator proxyHost: 0.0.0.0 From 4298cfe53bd7cbf311149aeddc4a3d7018f49872 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:43:09 +0100 Subject: [PATCH 027/117] Remove stale OpenShift values overlay from Helm chart (#4817) The values-openshift.yaml was a copy of the default values with outdated image tags (v0.15.0) and different resource limits. It provided no OpenShift-specific configuration and was misleading as a reference. Remove it along with the README references that pointed to it. Co-authored-by: Claude Opus 4.6 (1M context) --- deploy/charts/operator/README.md | 6 - deploy/charts/operator/README.md.gotmpl | 6 - deploy/charts/operator/values-openshift.yaml | 209 ------------------- 3 files changed, 221 deletions(-) delete mode 100644 deploy/charts/operator/values-openshift.yaml diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index 5d86059cb4..107ee17f1e 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -13,12 +13,6 @@ A Helm chart for deploying the ToolHive Operator into Kubernetes. helm upgrade -i toolhive-operator oci://ghcr.io/stacklok/toolhive/toolhive-operator -n toolhive-system --create-namespace ``` -Or for a custom values file: - -```consoleCustom -helm upgrade -i toolhive-operator oci://ghcr.io/stacklok/toolhive/toolhive-operator -n toolhive-system --create-namespace --values values-openshift.yaml -``` - ## Prerequisites - Kubernetes 1.25+ diff --git a/deploy/charts/operator/README.md.gotmpl b/deploy/charts/operator/README.md.gotmpl index 20cec36230..fed616e595 100644 --- a/deploy/charts/operator/README.md.gotmpl +++ b/deploy/charts/operator/README.md.gotmpl @@ -21,12 +21,6 @@ helm upgrade -i toolhive-operator oci://ghcr.io/stacklok/toolhive/toolhive-operator -n toolhive-system --create-namespace ``` -Or for a custom values file: - -```consoleCustom -helm upgrade -i toolhive-operator oci://ghcr.io/stacklok/toolhive/toolhive-operator -n toolhive-system --create-namespace --values values-openshift.yaml -``` - ## Prerequisites - Kubernetes 1.25+ diff --git a/deploy/charts/operator/values-openshift.yaml b/deploy/charts/operator/values-openshift.yaml deleted file mode 100644 index 02ca30c067..0000000000 --- a/deploy/charts/operator/values-openshift.yaml +++ /dev/null @@ -1,209 +0,0 @@ -# -- Override the name of the chart -nameOverride: "" -# -- Provide a fully-qualified name override for resources -fullnameOverride: "toolhive-operator" - -# -- All values for the operator deployment and associated resources -operator: - # Feature flags to enable/disable controller groups - features: - # -- Enable experimental features - experimental: false - # -- Enable server-related controllers (MCPServer, MCPExternalAuthConfig, MCPRemoteProxy, and ToolConfig). - # This automatically sets ENABLE_SERVER environment variable. - server: true - # -- Enable registry controller (MCPRegistry). - # This automatically sets ENABLE_REGISTRY environment variable. - registry: true - # -- Enable Virtual MCP aggregation features (VirtualMCPServer, MCPGroup controllers and webhooks). - # Set to false to disable Virtual MCP controllers when Virtual MCP CRDs are not installed. - # This automatically sets ENABLE_VMCP environment variable. - # Requires server to be enabled (server: true). - virtualMCP: true - # -- Number of replicas for the operator deployment - replicaCount: 1 - - # -- List of image pull secrets to use - imagePullSecrets: [] - # -- Container image for the operator - image: ghcr.io/stacklok/toolhive/operator:v0.15.0 - # -- Image pull policy for the operator container - imagePullPolicy: IfNotPresent - - # -- Image to use for Toolhive runners - toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.15.0 - - # -- Image to use for Virtual MCP Server (vMCP) deployments - vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.15.0 - - # -- Host for the proxy deployed by the operator - proxyHost: 0.0.0.0 - -# -- Environment variables to set in the operator container - env: [] - - # -- List of ports to expose from the operator container - ports: - - containerPort: 8080 - name: metrics - protocol: TCP - - containerPort: 8081 - name: health - protocol: TCP - - # -- Annotations to add to the operator pod - podAnnotations: {} - # -- Labels to add to the operator pod - podLabels: {} - - # -- Pod security context settings - podSecurityContext: - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - - # -- Container security context settings for the operator - containerSecurityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsUser: - capabilities: - drop: - - ALL - - # -- Liveness probe configuration for the operator - livenessProbe: - httpGet: - path: /healthz - port: health - initialDelaySeconds: 15 - periodSeconds: 20 - # -- Readiness probe configuration for the operator - readinessProbe: - httpGet: - path: /readyz - port: health - initialDelaySeconds: 5 - periodSeconds: 10 - - # -- Configuration for horizontal pod autoscaling - autoscaling: - # -- Enable autoscaling for the operator - enabled: false - # -- Minimum number of replicas - minReplicas: 1 - # -- Maximum number of replicas - maxReplicas: 100 - # -- Target CPU utilization percentage for autoscaling - targetCPUUtilizationPercentage: 80 - # -- Target memory utilization percentage for autoscaling (uncomment to enable) - # targetMemoryUtilizationPercentage: 80 - - # -- Resource requests and limits for the operator container - resources: - limits: - cpu: 500m - memory: 512Mi - requests: - cpu: 10m - memory: 256Mi - - # -- Go memory limits and garbage collection percentage for the operator container - gc: - # -- Go memory limits for the operator container - gomemlimit: 300MiB - # -- Go garbage collection percentage for the operator container - gogc: 75 # 75% heap growth before GC (as Go default) - - # -- RBAC configuration for the operator - rbac: - # -- Scope of the RBAC configuration. - # - cluster: The operator will have cluster-wide permissions via ClusterRole and ClusterRoleBinding. - # - namespace: The operator will have permissions to manage resources in the namespaces specified in `allowedNamespaces`. - # The operator will have a ClusterRole and RoleBinding for each namespace in `allowedNamespaces`. - scope: cluster - # -- List of namespaces that the operator is allowed to have permissions to manage. - # Only used if scope is set to "namespace". - allowedNamespaces: [] - - # -- Service account configuration for the operator - serviceAccount: - # -- Specifies whether a service account should be created - create: true - # -- Automatically mount a ServiceAccount's API credentials - automountServiceAccountToken: true - # -- Annotations to add to the service account - annotations: {} - # -- Labels to add to the service account - labels: {} - # -- The name of the service account to use. If not set and create is true, a name is generated. - name: "toolhive-operator" - - # -- Leader election role configuration - leaderElectionRole: - # -- Name of the role for leader election - name: toolhive-operator-leader-election-role - binding: - # -- Name of the role binding for leader election - name: toolhive-operator-leader-election-rolebinding - # -- Rules for the leader election role - rules: - - apiGroups: - - "" - resources: - - configmaps - verbs: - - get - - list - - watch - - create - - update - - patch - - delete - - apiGroups: - - coordination.k8s.io - resources: - - leases - verbs: - - get - - list - - watch - - create - - update - - patch - - delete - - apiGroups: - - "" - resources: - - events - verbs: - - create - - patch - - # -- Additional volumes to mount on the operator pod - volumes: [] - # - name: foo - # secret: - # secretName: mysecret - # optional: false - - # -- Additional volume mounts on the operator container - volumeMounts: [] - # - name: foo - # mountPath: "/etc/foo" - # readOnly: true - - # -- Node selector for the operator pod - nodeSelector: {} - - # -- Tolerations for the operator pod - tolerations: [] - - # -- Affinity settings for the operator pod - affinity: {} - -# -- All values for the registry API deployment and associated resources -registryAPI: - # -- Container image for the registry API - image: "ghcr.io/stacklok/thv-registry-api:v1.1.1" From 6c8d7d45173f12140cad17cac0c7f27a46be5068 Mon Sep 17 00:00:00 2001 From: Jeremy Drouillard Date: Tue, 14 Apr 2026 10:49:04 -0700 Subject: [PATCH 028/117] Introduce script engine package for vMCP code mode (#4748) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add script engine public API types Introduce the pkg/script/ package with the public interface for vMCP code mode. The Executor interface provides Execute() for running Starlark scripts and ToolDescription() for tools/list injection. Tool binds MCP tool metadata with a dispatch callback. Config holds execution parameters (step limit, parallel concurrency cap, tool call timeout). Part of #4742 Co-Authored-By: Claude Opus 4.6 (1M context) * Add Starlark execution engine in internal/core Core execution engine that wraps user scripts in a function body for top-level return support, enforces step limits to prevent runaway computation, and captures print() output as logs. This is the lowest layer of the script package — it only knows about Starlark, not MCP. Part of #4742 Co-Authored-By: Claude Opus 4.6 (1M context) * Use require/table-driven tests per testing guidelines Convert tests in description_test.go and execute_test.go to use testify require and table-driven structure per project testing rules. Part of #4742 Co-Authored-By: Claude Opus 4.6 (1M context) * Add Go/Starlark type conversions and MCP result parsing Introduce internal/conversions with three concerns: - Bidirectional Go ↔ Starlark type conversion (preserving JSON number fidelity by promoting whole float64s to Int) - MCP result parsing that handles structured content with/without the mcp-go SDK wrapper, text content as JSON or plain string, and error results - Tool name sanitization for converting MCP names to valid Starlark identifiers Part of #4742 Co-Authored-By: Claude Opus 4.6 (1M context) * Add parallel(), call_tool(), and tool callable builtins Introduce internal/builtins with: - Tool callable factory supporting positional args (arg0, arg1, ...), keyword args, and mixed calling conventions - call_tool("name", ...) for dispatching to tools by original name when the name is not a valid Starlark identifier - parallel() builtin for concurrent fan-out with optional concurrency limit via semaphore - BuildToolMap for collision-safe name → callback mapping Part of #4742 Co-Authored-By: Claude Opus 4.6 (1M context) * Wire executor to internal packages with integration tests Connect the public Executor interface to internal/core, internal/builtins, and internal/conversions. The executor builds a Starlark environment from bound tools (each as a callable with positional+kwargs support), injects data arguments, executes the script, and wraps the result as an mcp.CallToolResult with optional log content. Integration tests verify multi-tool scripts, loops/conditionals, parallel fan-out, data arguments, call_tool dispatch, step limit enforcement, and log capture. Part of #4742 Co-Authored-By: Claude Opus 4.6 (1M context) * Address self-review findings Fix correctness and safety issues found during code review: - Fix data race: parallel() child threads now get their own log buffers instead of sharing the parent Print callback. Logs are merged after wg.Wait() completes. - Propagate step limit to parallel() child threads so scripts cannot bypass CPU limits via parallel([lambda: infinite_loop()]). - Thread context.Context into parallel() for cancellation support. Semaphore acquire and task launch check ctx.Done(). - Set thread.Load to return an error instead of nil (prevents panic if a script uses a load() statement). - Reject data argument keys that shadow builtins or tool names instead of silently overwriting them. - Return errors for invalid data argument types instead of silently dropping them. - Simplify builtins package to expose a single Build() function that returns globals and reserved names. All other functions are unexported. - Consolidate builtins tests into a single test file using table-driven tests. Part of #4742 Co-Authored-By: Claude Opus 4.6 (1M context) * Address review comments - Remove reserved set from builtins.Build() — caller checks globals keys directly to prevent data argument shadowing - Consolidate integration tests into a single table-driven TestExecutor - Add test for automatic JSON string → structured data conversion Part of #4742 Co-Authored-By: Claude Opus 4.6 (1M context) * Address automated review comments - Make per-tool-call timeout a caller responsibility: remove ToolCallTimeout from Config and document the contract on Tool.Call. Add test proving caller-set context deadlines are respected. - Fix UTF-8 truncation in GenerateToolDescription: slice by rune count instead of byte index to avoid splitting multi-byte characters. - Run go mod tidy to mark go.starlark.net as a direct dependency. Part of #4742 Co-Authored-By: Claude Opus 4.6 (1M context) * Address reviewer feedback on parallel execution and edge cases - Introduce pkg/foreach with generic bounded concurrent iteration using a worker pool. Goroutine count matches the concurrency limit, not the task count. Reusable outside pkg/script. - Rewrite parallel() to use foreach.Concurrent instead of raw goroutines, fixing the goroutine explosion for large task lists. - Divide step budget evenly across parallel children (stepLimit/N per child) to prevent amplification via nested parallel() calls. - Document wrapScript triple-quoted string limitation. - Strengthen collision test to assert dispatch reaches the correct tool. - Clamp negative ParallelMax to 0 (unlimited) in resolveConfig. Part of #4742 Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- go.mod | 2 + go.sum | 2 + pkg/foreach/foreach.go | 67 +++++ pkg/foreach/foreach_test.go | 164 +++++++++++ pkg/script/description.go | 36 +++ pkg/script/description_test.go | 55 ++++ pkg/script/executor.go | 87 ++++++ pkg/script/internal/builtins/builtins.go | 63 +++++ pkg/script/internal/builtins/builtins_test.go | 258 ++++++++++++++++++ pkg/script/internal/builtins/calltool.go | 38 +++ pkg/script/internal/builtins/parallel.go | 79 ++++++ pkg/script/internal/builtins/tools.go | 64 +++++ pkg/script/internal/conversions/result.go | 75 +++++ .../internal/conversions/result_test.go | 98 +++++++ pkg/script/internal/conversions/starlark.go | 131 +++++++++ .../internal/conversions/starlark_test.go | 169 ++++++++++++ pkg/script/internal/conversions/toolname.go | 25 ++ .../internal/conversions/toolname_test.go | 36 +++ pkg/script/internal/core/execute.go | 90 ++++++ pkg/script/internal/core/execute_test.go | 156 +++++++++++ pkg/script/script.go | 93 +++++++ pkg/script/script_test.go | 231 ++++++++++++++++ 22 files changed, 2019 insertions(+) create mode 100644 pkg/foreach/foreach.go create mode 100644 pkg/foreach/foreach_test.go create mode 100644 pkg/script/description.go create mode 100644 pkg/script/description_test.go create mode 100644 pkg/script/executor.go create mode 100644 pkg/script/internal/builtins/builtins.go create mode 100644 pkg/script/internal/builtins/builtins_test.go create mode 100644 pkg/script/internal/builtins/calltool.go create mode 100644 pkg/script/internal/builtins/parallel.go create mode 100644 pkg/script/internal/builtins/tools.go create mode 100644 pkg/script/internal/conversions/result.go create mode 100644 pkg/script/internal/conversions/result_test.go create mode 100644 pkg/script/internal/conversions/starlark.go create mode 100644 pkg/script/internal/conversions/starlark_test.go create mode 100644 pkg/script/internal/conversions/toolname.go create mode 100644 pkg/script/internal/conversions/toolname_test.go create mode 100644 pkg/script/internal/core/execute.go create mode 100644 pkg/script/internal/core/execute_test.go create mode 100644 pkg/script/script.go create mode 100644 pkg/script/script_test.go diff --git a/go.mod b/go.mod index df57d00110..5e36c7477b 100644 --- a/go.mod +++ b/go.mod @@ -81,6 +81,8 @@ require github.com/getsentry/sentry-go/otel v0.44.1 require github.com/hashicorp/golang-lru/v2 v2.0.7 +require go.starlark.net v0.0.0-20260326113308-fadfc96def35 + require ( cel.dev/expr v0.25.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect diff --git a/go.sum b/go.sum index e0b09d5a30..4a3170e326 100644 --- a/go.sum +++ b/go.sum @@ -936,6 +936,8 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.starlark.net v0.0.0-20260326113308-fadfc96def35 h1:VYAqieSOJNxBDX8KJneTAwvdf4J4zRDE2u+UFXtt9h4= +go.starlark.net v0.0.0-20260326113308-fadfc96def35/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= go.step.sm/crypto v0.74.0 h1:/APBEv45yYR4qQFg47HA8w1nesIGcxh44pGyQNw6JRA= go.step.sm/crypto v0.74.0/go.mod h1:UoXqCAJjjRgzPte0Llaqen7O9P7XjPmgjgTHQGkKCDk= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= diff --git a/pkg/foreach/foreach.go b/pkg/foreach/foreach.go new file mode 100644 index 0000000000..24573cf70c --- /dev/null +++ b/pkg/foreach/foreach.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package foreach provides bounded concurrent iteration. A fixed pool of +// worker goroutines processes tasks, so the goroutine count matches the +// concurrency limit rather than the task count. +package foreach + +import ( + "context" + "fmt" + "sync" +) + +// Concurrent executes fn for each index in [0, n) using at most maxWorkers +// concurrent goroutines. If maxWorkers is <= 0, it defaults to n (all tasks +// run concurrently). The context is checked before dispatching each task; +// cancelled contexts cause remaining tasks to return ctx.Err(). +// +// Results and errors are collected by index. The first error encountered +// does not cancel other tasks — all tasks run to completion or until the +// context is cancelled. The returned error is from the lowest-indexed +// failing task. +func Concurrent[T any](ctx context.Context, n int, maxWorkers int, fn func(ctx context.Context, i int) (T, error)) ([]T, error) { + if n == 0 { + return nil, nil + } + + if maxWorkers <= 0 || maxWorkers > n { + maxWorkers = n + } + + results := make([]T, n) + errs := make([]error, n) + + tasks := make(chan int, n) + go func() { + defer close(tasks) + for i := range n { + select { + case tasks <- i: + case <-ctx.Done(): + return + } + } + }() + + var wg sync.WaitGroup + wg.Add(maxWorkers) + for range maxWorkers { + go func() { + defer wg.Done() + for idx := range tasks { + results[idx], errs[idx] = fn(ctx, idx) + } + }() + } + wg.Wait() + + for i, err := range errs { + if err != nil { + return results, fmt.Errorf("task %d failed: %w", i, err) + } + } + + return results, nil +} diff --git a/pkg/foreach/foreach_test.go b/pkg/foreach/foreach_test.go new file mode 100644 index 0000000000..f5440dec9a --- /dev/null +++ b/pkg/foreach/foreach_test.go @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package foreach + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestConcurrent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + n int + maxWorkers int + fn func(ctx context.Context, i int) (string, error) + expect []string + wantErr string + }{ + { + name: "empty input", + n: 0, + maxWorkers: 4, + fn: func(_ context.Context, _ int) (string, error) { return "", nil }, + }, + { + name: "all succeed", + n: 3, + maxWorkers: 2, + fn: func(_ context.Context, i int) (string, error) { + return fmt.Sprintf("result-%d", i), nil + }, + expect: []string{"result-0", "result-1", "result-2"}, + }, + { + name: "error reported from lowest index", + n: 3, + maxWorkers: 3, + fn: func(_ context.Context, i int) (string, error) { + if i == 1 { + return "", fmt.Errorf("task 1 broke") + } + return "ok", nil + }, + wantErr: "task 1 failed", + }, + { + name: "maxWorkers zero defaults to n", + n: 3, + maxWorkers: 0, + fn: func(_ context.Context, i int) (string, error) { + return fmt.Sprintf("r%d", i), nil + }, + expect: []string{"r0", "r1", "r2"}, + }, + { + name: "maxWorkers exceeds n capped to n", + n: 2, + maxWorkers: 100, + fn: func(_ context.Context, i int) (string, error) { + return fmt.Sprintf("v%d", i), nil + }, + expect: []string{"v0", "v1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + results, err := Concurrent(context.Background(), tt.n, tt.maxWorkers, tt.fn) + if tt.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tt.expect, results) + }) + } +} + +func TestConcurrent_BoundsGoroutines(t *testing.T) { + t.Parallel() + + var maxConcurrent atomic.Int32 + var current atomic.Int32 + + fn := func(ctx context.Context, i int) (string, error) { + if ctx.Err() != nil { + return "", ctx.Err() + } + cur := current.Add(1) + for { + old := maxConcurrent.Load() + if cur <= old { + break + } + if maxConcurrent.CompareAndSwap(old, cur) { + break + } + } + time.Sleep(10 * time.Millisecond) + current.Add(-1) + return fmt.Sprintf("done-%d", i), nil + } + + done := make(chan struct{}) + go func() { + results, err := Concurrent(context.Background(), 10, 2, fn) + require.NoError(t, err) + require.Len(t, results, 10) + require.Equal(t, "done-0", results[0]) + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for concurrent execution") + } + + require.LessOrEqual(t, maxConcurrent.Load(), int32(2), + "should never exceed worker pool size of 2") +} + +func TestConcurrent_RespectsContextCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + + var started atomic.Int32 + results, err := Concurrent(ctx, 100, 1, func(ctx context.Context, _ int) (string, error) { + n := started.Add(1) + // Cancel after a few tasks have started + if n == 3 { + cancel() + } + if ctx.Err() != nil { + return "", ctx.Err() + } + return "ok", nil + }) + + // Some tasks should have completed, but not all 100 + completedCount := 0 + for _, r := range results { + if r == "ok" { + completedCount++ + } + } + // With 1 worker and cancellation after task 3, most tasks should not run + require.Less(t, completedCount, 100, "cancellation should prevent most tasks from running") + + // Either we get an error from a cancelled task, or all dispatched tasks succeeded + // before the producer noticed the cancellation + _ = err +} diff --git a/pkg/script/description.go b/pkg/script/description.go new file mode 100644 index 0000000000..f1b65cb8b3 --- /dev/null +++ b/pkg/script/description.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package script + +import ( + "fmt" + "strings" +) + +// ExecuteToolScriptName is the name of the virtual tool exposed by the +// script middleware. +const ExecuteToolScriptName = "execute_tool_script" + +// GenerateToolDescription creates a dynamic description for the +// execute_tool_script tool, listing all available tools and their +// calling conventions. +func GenerateToolDescription(tools []Tool) string { + var b strings.Builder + b.WriteString("Execute a Starlark script that orchestrates multiple tool calls ") + b.WriteString("and returns an aggregated result. Use 'return' to produce output.\n\n") + b.WriteString("Available tools (callable as functions with keyword or positional arguments):\n") + for _, t := range tools { + desc := t.Description + runes := []rune(desc) + if len(runes) > 80 { + desc = string(runes[:77]) + "..." + } + fmt.Fprintf(&b, " - %s: %s\n", t.Name, desc) + } + b.WriteString("\nUse call_tool(\"name\", ...) to call any tool by its original name.\n\n") + b.WriteString("Built-in: parallel([fn1, fn2, ...]) executes zero-arg callables concurrently ") + b.WriteString("and returns results in order. Use with lambda to fan out tool calls.\n\n") + b.WriteString("Named data arguments passed in the 'data' parameter are available as top-level variables.") + return b.String() +} diff --git a/pkg/script/description_test.go b/pkg/script/description_test.go new file mode 100644 index 0000000000..1fc56cb85d --- /dev/null +++ b/pkg/script/description_test.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package script + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerateToolDescription(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tools []Tool + contains []string + }{ + { + name: "lists tools and builtins", + tools: []Tool{ + {Name: "github-fetch-prs", Description: "Fetch pull requests from GitHub"}, + {Name: "slack-send-message", Description: "Send a message to a Slack channel"}, + }, + contains: []string{ + "github-fetch-prs", "slack-send-message", + "Fetch pull requests", "parallel", "call_tool", + }, + }, + { + name: "truncates long descriptions", + tools: []Tool{{Name: "my-tool", Description: strings.Repeat("a", 100)}}, + contains: []string{ + "my-tool", "...", + }, + }, + { + name: "empty tool list still describes builtins", + tools: nil, + contains: []string{"parallel", "call_tool"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + desc := GenerateToolDescription(tt.tools) + for _, s := range tt.contains { + require.Contains(t, desc, s) + } + }) + } +} diff --git a/pkg/script/executor.go b/pkg/script/executor.go new file mode 100644 index 0000000000..0ebd9d8dbf --- /dev/null +++ b/pkg/script/executor.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package script + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + "go.starlark.net/starlark" + + "github.com/stacklok/toolhive/pkg/script/internal/builtins" + "github.com/stacklok/toolhive/pkg/script/internal/conversions" + "github.com/stacklok/toolhive/pkg/script/internal/core" +) + +// executor is the unexported implementation of Executor. +type executor struct { + tools []Tool + config Config +} + +// Execute runs a Starlark script against the bound tools. +func (e *executor) Execute(ctx context.Context, script string, data map[string]interface{}) (*mcp.CallToolResult, error) { + globals := e.buildGlobals(ctx) + + // Inject data arguments, rejecting any that shadow builtins or tools + for k, v := range data { + if _, exists := globals[k]; exists { + return nil, fmt.Errorf("data argument %q conflicts with a builtin or tool name", k) + } + sv, err := conversions.GoToStarlark(v) + if err != nil { + return nil, fmt.Errorf("data argument %q: %w", k, err) + } + globals[k] = sv + } + + result, err := core.Execute(script, globals, e.config.StepLimit) + if err != nil { + return nil, err + } + + return buildResult(result) +} + +// ToolDescription returns the dynamic description for the virtual tool. +func (e *executor) ToolDescription() string { + return GenerateToolDescription(e.tools) +} + +// buildGlobals creates the Starlark global environment from the bound tools. +func (e *executor) buildGlobals(ctx context.Context) starlark.StringDict { + defs := make([]builtins.ToolDef, len(e.tools)) + for i, t := range e.tools { + defs[i] = builtins.ToolDef{ + Name: t.Name, + Description: t.Description, + Call: t.Call, + } + } + + return builtins.Build(ctx, defs, e.config.StepLimit, e.config.ParallelMax) +} + +// buildResult converts a core.ExecuteResult into an mcp.CallToolResult. +func buildResult(execResult *core.ExecuteResult) (*mcp.CallToolResult, error) { + goVal := conversions.StarlarkToGo(execResult.Value) + + resultJSON, err := json.Marshal(goVal) + if err != nil { + return nil, fmt.Errorf("failed to serialize script result: %w", err) + } + + result := mcp.NewToolResultText(string(resultJSON)) + + if len(execResult.Logs) > 0 { + result.Content = append(result.Content, mcp.NewTextContent( + "Script logs:\n"+strings.Join(execResult.Logs, "\n"), + )) + } + + return result, nil +} diff --git a/pkg/script/internal/builtins/builtins.go b/pkg/script/internal/builtins/builtins.go new file mode 100644 index 0000000000..3cd94e220f --- /dev/null +++ b/pkg/script/internal/builtins/builtins.go @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package builtins provides Starlark builtin functions for the script engine. +package builtins + +import ( + "context" + "log/slog" + + "github.com/mark3labs/mcp-go/mcp" + "go.starlark.net/starlark" + + "github.com/stacklok/toolhive/pkg/script/internal/conversions" +) + +// ToolDef defines a tool for the script environment. +type ToolDef struct { + Name string + Description string + Call func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) +} + +// Build creates all Starlark globals for the script environment. +// +// The returned globals include: +// - Tool callables by sanitized name (e.g., github_fetch_prs) +// - call_tool("name", ...) for name-based dispatch +// - parallel([fn1, fn2, ...]) for concurrent fan-out +// +// The caller can check for key existence in the returned globals to +// prevent data arguments from shadowing builtins or tools. +func Build( + ctx context.Context, tools []ToolDef, stepLimit uint64, parallelMax int, +) starlark.StringDict { + byName := make(map[string]callFunc, len(tools)) + seen := make(map[string]string, len(tools)) // sanitized → original + + globals := make(starlark.StringDict, len(tools)+2) + + // Register each tool as a callable by its sanitized name + for _, t := range tools { + byName[t.Name] = t.Call + + sanitized := conversions.SanitizeName(t.Name) + if existing, ok := seen[sanitized]; ok { + slog.Warn("tool name collision after sanitization", + "tool1", existing, "tool2", t.Name, "sanitized", sanitized) + continue + } + seen[sanitized] = t.Name + + globals[sanitized] = makeToolCallable(ctx, sanitized, t.Call) + } + + // Register call_tool() for name-based dispatch + globals["call_tool"] = newCallTool(ctx, byName) + + // Register parallel() for concurrent fan-out + globals["parallel"] = newParallel(ctx, stepLimit, parallelMax) + + return globals +} diff --git a/pkg/script/internal/builtins/builtins_test.go b/pkg/script/internal/builtins/builtins_test.go new file mode 100644 index 0000000000..2068004c77 --- /dev/null +++ b/pkg/script/internal/builtins/builtins_test.go @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package builtins + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "go.starlark.net/starlark" + + "github.com/stacklok/toolhive/pkg/script/internal/core" +) + +func TestBuild_ToolCallable(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + script string + expectArgs map[string]interface{} + }{ + { + name: "kwargs", + script: `return my_tool(name="test", count=42)`, + expectArgs: map[string]interface{}{"name": "test", "count": int64(42)}, + }, + { + name: "positional", + script: `return my_tool("hello", "world")`, + expectArgs: map[string]interface{}{"arg0": "hello", "arg1": "world"}, + }, + { + name: "mixed", + script: `return my_tool("positional", key="named")`, + expectArgs: map[string]interface{}{"arg0": "positional", "key": "named"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var captured map[string]interface{} + tools := []ToolDef{{ + Name: "my-tool", + Call: func(_ context.Context, args map[string]interface{}) (*mcp.CallToolResult, error) { + captured = args + return mcp.NewToolResultText("ok"), nil + }, + }} + + globals := Build(context.Background(), tools, 100_000, 0) + _, err := core.Execute(tt.script, globals, 100_000) + require.NoError(t, err) + require.Equal(t, tt.expectArgs, captured) + }) + } +} + +func TestBuild_CallTool(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + script string + expectArgs map[string]interface{} + wantErr string + }{ + { + name: "kwargs dispatch", + script: `return call_tool("my-tool", query="test")`, + expectArgs: map[string]interface{}{"query": "test"}, + }, + { + name: "positional dispatch", + script: `return call_tool("my-tool", "value")`, + expectArgs: map[string]interface{}{"arg0": "value"}, + }, + { + name: "no arguments", + script: `return call_tool()`, + wantErr: "requires at least 1 argument", + }, + { + name: "unknown tool", + script: `return call_tool("nonexistent")`, + wantErr: `unknown tool "nonexistent"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var captured map[string]interface{} + tools := []ToolDef{{ + Name: "my-tool", + Call: func(_ context.Context, args map[string]interface{}) (*mcp.CallToolResult, error) { + captured = args + return mcp.NewToolResultText("ok"), nil + }, + }} + + globals := Build(context.Background(), tools, 100_000, 0) + _, err := core.Execute(tt.script, globals, 100_000) + + if tt.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.Equal(t, tt.expectArgs, captured) + }) + } +} + +func TestBuild_Parallel_OrderedResults(t *testing.T) { + t.Parallel() + + globals := Build(context.Background(), nil, 100_000, 0) + + result, err := core.Execute(` +results = parallel([ + lambda: "first", + lambda: "second", + lambda: "third", +]) +return results +`, globals, 100_000) + require.NoError(t, err) + + list, ok := result.Value.(*starlark.List) + require.True(t, ok) + require.Equal(t, 3, list.Len()) + require.Equal(t, starlark.String("first"), list.Index(0)) + require.Equal(t, starlark.String("second"), list.Index(1)) + require.Equal(t, starlark.String("third"), list.Index(2)) +} + +func TestBuild_Parallel_ErrorPropagation(t *testing.T) { + t.Parallel() + + failing := starlark.NewBuiltin("failing", func( + _ *starlark.Thread, _ *starlark.Builtin, _ starlark.Tuple, _ []starlark.Tuple, + ) (starlark.Value, error) { + return nil, fmt.Errorf("intentional failure") + }) + + globals := Build(context.Background(), nil, 100_000, 0) + globals["failing"] = failing + + _, err := core.Execute(`return parallel([lambda: failing()])`, globals, 100_000) + require.Error(t, err) + require.Contains(t, err.Error(), "intentional failure") +} + +func TestBuild_Parallel_ConcurrencyLimit(t *testing.T) { + t.Parallel() + + var maxConcurrent atomic.Int32 + var current atomic.Int32 + + slow := starlark.NewBuiltin("slow", func( + _ *starlark.Thread, _ *starlark.Builtin, _ starlark.Tuple, _ []starlark.Tuple, + ) (starlark.Value, error) { + cur := current.Add(1) + for { + old := maxConcurrent.Load() + if cur <= old { + break + } + if maxConcurrent.CompareAndSwap(old, cur) { + break + } + } + time.Sleep(10 * time.Millisecond) + current.Add(-1) + return starlark.String("done"), nil + }) + + globals := Build(context.Background(), nil, 1_000_000, 2) // limit to 2 + globals["slow"] = slow + + done := make(chan struct{}) + go func() { + result, err := core.Execute(` +return parallel([ + lambda: slow(), + lambda: slow(), + lambda: slow(), + lambda: slow(), +]) +`, globals, 1_000_000) + require.NoError(t, err) + + list, ok := result.Value.(*starlark.List) + require.True(t, ok) + require.Equal(t, 4, list.Len()) + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for parallel execution") + } + + require.LessOrEqual(t, maxConcurrent.Load(), int32(2), + "should never exceed concurrency limit of 2") +} + +func TestBuild_GlobalsContainBuiltins(t *testing.T) { + t.Parallel() + + tools := []ToolDef{ + {Name: "my-tool", Call: func(_ context.Context, _ map[string]interface{}) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText("ok"), nil + }}, + } + + globals := Build(context.Background(), tools, 100_000, 0) + + require.Contains(t, globals, "my_tool", "sanitized tool name should be in globals") + require.Contains(t, globals, "call_tool", "call_tool should be in globals") + require.Contains(t, globals, "parallel", "parallel should be in globals") +} + +func TestBuild_NameCollision(t *testing.T) { + t.Parallel() + + tools := []ToolDef{ + {Name: "my-tool", Call: func(_ context.Context, _ map[string]interface{}) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText("first"), nil + }}, + {Name: "my.tool", Call: func(_ context.Context, _ map[string]interface{}) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText("second"), nil + }}, + } + + globals := Build(context.Background(), tools, 100_000, 0) + + // Only the first tool should survive sanitization collision + _, hasMyTool := globals["my_tool"] + require.True(t, hasMyTool) + + // But both should be callable via call_tool by original name + result, err := core.Execute(`return call_tool("my.tool")`, globals, 100_000) + require.NoError(t, err) + require.Equal(t, starlark.String("second"), result.Value, "should dispatch to my.tool, not my-tool") +} diff --git a/pkg/script/internal/builtins/calltool.go b/pkg/script/internal/builtins/calltool.go new file mode 100644 index 0000000000..f4f2757d22 --- /dev/null +++ b/pkg/script/internal/builtins/calltool.go @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package builtins + +import ( + "context" + "fmt" + + "go.starlark.net/starlark" +) + +// newCallTool creates a generic call_tool("name", ...) Starlark builtin +// that dispatches to tools by name. +func newCallTool(ctx context.Context, toolMap map[string]callFunc) *starlark.Builtin { + return starlark.NewBuiltin("call_tool", func( + _ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + if len(args) < 1 { + return nil, fmt.Errorf("call_tool: requires at least 1 argument (tool name)") + } + + nameVal, ok := args[0].(starlark.String) + if !ok { + return nil, fmt.Errorf("call_tool: first argument must be a string, got %s", args[0].Type()) + } + toolName := string(nameVal) + + fn, exists := toolMap[toolName] + if !exists { + return nil, fmt.Errorf("call_tool: unknown tool %q", toolName) + } + + // Remaining positional args (after name) + kwargs + arguments := argsToGoMap(args[1:], kwargs) + return callToolAndConvert(ctx, fn, arguments) + }) +} diff --git a/pkg/script/internal/builtins/parallel.go b/pkg/script/internal/builtins/parallel.go new file mode 100644 index 0000000000..ad43acfa18 --- /dev/null +++ b/pkg/script/internal/builtins/parallel.go @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package builtins + +import ( + "context" + "fmt" + + "go.starlark.net/starlark" + + "github.com/stacklok/toolhive/pkg/foreach" +) + +// newParallel creates a parallel() Starlark builtin that executes a list +// of zero-arg callables concurrently and returns results in order. +// +// Uses a bounded worker pool (via foreach.Concurrent) so the goroutine +// count matches maxConcurrency, not the task count. The step budget is +// divided evenly across children to prevent amplification. +func newParallel(ctx context.Context, stepLimit uint64, maxConcurrency int) *starlark.Builtin { + return starlark.NewBuiltin("parallel", func( + thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + var fns *starlark.List + if err := starlark.UnpackPositionalArgs("parallel", args, kwargs, 1, &fns); err != nil { + return nil, err + } + + n := fns.Len() + if n == 0 { + return starlark.NewList(nil), nil + } + + // Divide step budget evenly across children to prevent amplification. + // With N children, each gets stepLimit/N steps. + childStepLimit := stepLimit / uint64(n) //nolint:gosec // n is from starlark.List.Len(), always non-negative + + childLogs := make([][]string, n) + + results, err := foreach.Concurrent(ctx, n, maxConcurrency, + func(_ context.Context, idx int) (starlark.Value, error) { + callable, ok := fns.Index(idx).(starlark.Callable) + if !ok { + return nil, fmt.Errorf("parallel: element %d is not callable (got %s)", + idx, fns.Index(idx).Type()) + } + + // Each child gets its own log buffer to avoid data races + var logs []string + childThread := &starlark.Thread{ + Name: fmt.Sprintf("%s/parallel-%d", thread.Name, idx), + Print: func(_ *starlark.Thread, msg string) { + logs = append(logs, msg) + }, + } + if childStepLimit > 0 { + childThread.SetMaxExecutionSteps(childStepLimit) + } + + result, callErr := starlark.Call(childThread, callable, nil, nil) + childLogs[idx] = logs + return result, callErr + }, + ) + if err != nil { + return nil, fmt.Errorf("parallel: %w", err) + } + + // Merge child logs into parent thread in order + for _, logs := range childLogs { + for _, msg := range logs { + thread.Print(thread, msg) + } + } + + return starlark.NewList(results), nil + }) +} diff --git a/pkg/script/internal/builtins/tools.go b/pkg/script/internal/builtins/tools.go new file mode 100644 index 0000000000..59766c2d1a --- /dev/null +++ b/pkg/script/internal/builtins/tools.go @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package builtins + +import ( + "context" + "fmt" + + "github.com/mark3labs/mcp-go/mcp" + "go.starlark.net/starlark" + + "github.com/stacklok/toolhive/pkg/script/internal/conversions" +) + +// callFunc is the signature for invoking an MCP tool. +type callFunc func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) + +// makeToolCallable creates a Starlark builtin that invokes an MCP tool. +// It supports both positional and keyword arguments: +// - my_tool(key=val) → {"key": val} +// - my_tool(val1, val2) → {"arg0": val1, "arg1": val2} +// - my_tool(val1, key=val2) → {"arg0": val1, "key": val2} +func makeToolCallable(ctx context.Context, displayName string, fn callFunc) *starlark.Builtin { + return starlark.NewBuiltin(displayName, func( + _ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + arguments := argsToGoMap(args, kwargs) + return callToolAndConvert(ctx, fn, arguments) + }) +} + +// callToolAndConvert invokes a tool and converts the result to a Starlark value. +func callToolAndConvert(ctx context.Context, fn callFunc, arguments map[string]interface{}) (starlark.Value, error) { + result, err := fn(ctx, arguments) + if err != nil { + return nil, err + } + + goVal, err := conversions.ParseToolResult(result) + if err != nil { + return nil, err + } + + sv, err := conversions.GoToStarlark(goVal) + if err != nil { + return nil, fmt.Errorf("result conversion failed: %w", err) + } + return sv, nil +} + +// argsToGoMap converts positional and keyword Starlark arguments into a +// Go map. Positional args are keyed as "arg0", "arg1", etc. +func argsToGoMap(args starlark.Tuple, kwargs []starlark.Tuple) map[string]interface{} { + m := make(map[string]interface{}, len(args)+len(kwargs)) + for i, arg := range args { + m[fmt.Sprintf("arg%d", i)] = conversions.StarlarkToGo(arg) + } + for _, kv := range kwargs { + key := string(kv[0].(starlark.String)) + m[key] = conversions.StarlarkToGo(kv[1]) + } + return m +} diff --git a/pkg/script/internal/conversions/result.go b/pkg/script/internal/conversions/result.go new file mode 100644 index 0000000000..2af2951b8f --- /dev/null +++ b/pkg/script/internal/conversions/result.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package conversions + +import ( + "encoding/json" + "fmt" + "log/slog" + + "github.com/mark3labs/mcp-go/mcp" +) + +// ParseToolResult converts an mcp.CallToolResult into a Go value suitable +// for Starlark consumption. +// +// It handles multiple response formats: +// - Structured content with the mcp-go SDK {"result": v} wrapper pattern +// - Structured content without the wrapper +// - Text content parsed as JSON +// - Plain text returned as-is +// - Multi-item responses (first text item used) +// - Error results returned as an error +func ParseToolResult(result *mcp.CallToolResult) (interface{}, error) { + if result.IsError { + msg := "tool execution error" + if len(result.Content) > 0 { + if tc, ok := mcp.AsTextContent(result.Content[0]); ok && tc.Text != "" { + msg = tc.Text + } + } + return nil, fmt.Errorf("%s", msg) + } + + // Prefer structured content, but unwrap the common SDK wrapper + // pattern where the result is {"result": }. + if result.StructuredContent != nil { + sc, ok := result.StructuredContent.(map[string]interface{}) + if ok && len(sc) == 1 { + if v, exists := sc["result"]; exists { + return v, nil + } + } + return result.StructuredContent, nil + } + + // Fall back to text content + if len(result.Content) == 0 { + return nil, nil + } + + if len(result.Content) > 1 { + slog.Debug("tool returned multiple content items, using first text item only", + "count", len(result.Content)) + } + + // Find the first text content item + for _, content := range result.Content { + tc, ok := mcp.AsTextContent(content) + if !ok { + continue + } + + // Try to parse as JSON + var parsed interface{} + if err := json.Unmarshal([]byte(tc.Text), &parsed); err != nil { + // Not valid JSON — return as plain string + return tc.Text, nil + } + return parsed, nil + } + + // No text content found — return nil + return nil, nil +} diff --git a/pkg/script/internal/conversions/result_test.go b/pkg/script/internal/conversions/result_test.go new file mode 100644 index 0000000000..5f534fe04c --- /dev/null +++ b/pkg/script/internal/conversions/result_test.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package conversions + +import ( + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" +) + +func TestParseToolResult(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + result *mcp.CallToolResult + expect interface{} + wantErr string + }{ + { + name: "structured content with SDK wrapper", + result: &mcp.CallToolResult{ + StructuredContent: map[string]interface{}{ + "result": map[string]interface{}{"key": "value"}, + }, + }, + expect: map[string]interface{}{"key": "value"}, + }, + { + name: "structured content without wrapper", + result: &mcp.CallToolResult{ + StructuredContent: map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + }, + expect: map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + }, + { + name: "text content with JSON", + result: mcp.NewToolResultText(`{"items": [1, 2, 3]}`), + expect: map[string]interface{}{ + "items": []interface{}{float64(1), float64(2), float64(3)}, + }, + }, + { + name: "text content plain string", + result: mcp.NewToolResultText("hello world"), + expect: "hello world", + }, + { + name: "empty content", + result: &mcp.CallToolResult{}, + expect: nil, + }, + { + name: "error result", + result: mcp.NewToolResultError("something went wrong"), + wantErr: "something went wrong", + }, + { + name: "error result with empty content", + result: &mcp.CallToolResult{ + IsError: true, + }, + wantErr: "tool execution error", + }, + { + name: "structured content non-map type", + result: &mcp.CallToolResult{ + StructuredContent: []interface{}{"a", "b"}, + }, + expect: []interface{}{"a", "b"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := ParseToolResult(tt.result) + + if tt.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.Equal(t, tt.expect, got) + }) + } +} diff --git a/pkg/script/internal/conversions/starlark.go b/pkg/script/internal/conversions/starlark.go new file mode 100644 index 0000000000..1959839f08 --- /dev/null +++ b/pkg/script/internal/conversions/starlark.go @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package conversions provides bidirectional type conversion between Go +// values and Starlark values, MCP result parsing, and tool name sanitization. +package conversions + +import ( + "encoding/json" + "fmt" + "math" + + "go.starlark.net/starlark" +) + +// GoToStarlark converts a Go value to a Starlark value. +// +//nolint:gocyclo // type switch over Go types is inherently branchy +func GoToStarlark(v interface{}) (starlark.Value, error) { + switch v := v.(type) { + case nil: + return starlark.None, nil + case bool: + return starlark.Bool(v), nil + case int: + return starlark.MakeInt(v), nil + case int64: + return starlark.MakeInt64(v), nil + case float64: + return goFloat64ToStarlark(v), nil + case string: + return starlark.String(v), nil + case []interface{}: + return goSliceToStarlark(v) + case map[string]interface{}: + return goMapToStarlark(v) + case json.Number: + return goJSONNumberToStarlark(v) + default: + return nil, fmt.Errorf("unsupported Go type %T for Starlark conversion", v) + } +} + +// StarlarkToGo converts a Starlark value to a Go value. +func StarlarkToGo(v starlark.Value) interface{} { + switch v := v.(type) { + case starlark.NoneType: + return nil + case starlark.Bool: + return bool(v) + case starlark.Int: + if i, ok := v.Int64(); ok { + return i + } + return v.String() + case starlark.Float: + return float64(v) + case starlark.String: + return string(v) + case *starlark.List: + result := make([]interface{}, v.Len()) + for i := range v.Len() { + result[i] = StarlarkToGo(v.Index(i)) + } + return result + case *starlark.Dict: + result := make(map[string]interface{}) + for _, item := range v.Items() { + key := StarlarkToGo(item[0]) + keyStr, ok := key.(string) + if !ok { + keyStr = fmt.Sprintf("%v", key) + } + result[keyStr] = StarlarkToGo(item[1]) + } + return result + case starlark.Tuple: + result := make([]interface{}, len(v)) + for i, elem := range v { + result[i] = StarlarkToGo(elem) + } + return result + default: + return v.String() + } +} + +// goFloat64ToStarlark converts a float64 to a Starlark value, promoting +// whole numbers to Int for JSON number fidelity. +func goFloat64ToStarlark(v float64) starlark.Value { + if v == math.Trunc(v) && !math.IsInf(v, 0) && !math.IsNaN(v) && math.Abs(v) < (1<<53) { + return starlark.MakeInt64(int64(v)) + } + return starlark.Float(v) +} + +func goSliceToStarlark(v []interface{}) (starlark.Value, error) { + elems := make([]starlark.Value, len(v)) + for i, e := range v { + sv, err := GoToStarlark(e) + if err != nil { + return nil, err + } + elems[i] = sv + } + return starlark.NewList(elems), nil +} + +func goMapToStarlark(v map[string]interface{}) (starlark.Value, error) { + d := starlark.NewDict(len(v)) + for k, val := range v { + sv, err := GoToStarlark(val) + if err != nil { + return nil, err + } + if err := d.SetKey(starlark.String(k), sv); err != nil { + return nil, err + } + } + return d, nil +} + +func goJSONNumberToStarlark(v json.Number) (starlark.Value, error) { + if i, err := v.Int64(); err == nil { + return starlark.MakeInt64(i), nil + } + if f, err := v.Float64(); err == nil { + return starlark.Float(f), nil + } + return starlark.String(v.String()), nil +} diff --git a/pkg/script/internal/conversions/starlark_test.go b/pkg/script/internal/conversions/starlark_test.go new file mode 100644 index 0000000000..a7b080d8c1 --- /dev/null +++ b/pkg/script/internal/conversions/starlark_test.go @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package conversions + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "go.starlark.net/starlark" +) + +func TestGoToStarlark(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input interface{} + check func(t *testing.T, v starlark.Value) + wantErr bool + }{ + { + name: "nil", + input: nil, + check: func(t *testing.T, v starlark.Value) { t.Helper(); require.Equal(t, starlark.None, v) }, + }, + { + name: "bool true", + input: true, + check: func(t *testing.T, v starlark.Value) { t.Helper(); require.Equal(t, starlark.True, v) }, + }, + { + name: "int", + input: 42, + check: func(t *testing.T, v starlark.Value) { + t.Helper() + intVal, ok := v.(starlark.Int) + require.True(t, ok) + got, _ := intVal.Int64() + require.Equal(t, int64(42), got) + }, + }, + { + name: "float64 whole number becomes Int", + input: float64(42), + check: func(t *testing.T, v starlark.Value) { + t.Helper() + _, ok := v.(starlark.Int) + require.True(t, ok, "whole float64 should become Int") + }, + }, + { + name: "float64 fractional stays Float", + input: float64(3.14), + check: func(t *testing.T, v starlark.Value) { + t.Helper() + _, ok := v.(starlark.Float) + require.True(t, ok, "fractional float64 should stay Float") + }, + }, + { + name: "string", + input: "hello", + check: func(t *testing.T, v starlark.Value) { + t.Helper() + require.Equal(t, starlark.String("hello"), v) + }, + }, + { + name: "slice", + input: []interface{}{"a", "b"}, + check: func(t *testing.T, v starlark.Value) { + t.Helper() + list, ok := v.(*starlark.List) + require.True(t, ok) + require.Equal(t, 2, list.Len()) + }, + }, + { + name: "map", + input: map[string]interface{}{"key": "val"}, + check: func(t *testing.T, v starlark.Value) { + t.Helper() + d, ok := v.(*starlark.Dict) + require.True(t, ok) + require.Equal(t, 1, d.Len()) + }, + }, + { + name: "json.Number integer", + input: json.Number("42"), + check: func(t *testing.T, v starlark.Value) { + t.Helper() + intVal, ok := v.(starlark.Int) + require.True(t, ok) + got, _ := intVal.Int64() + require.Equal(t, int64(42), got) + }, + }, + { + name: "json.Number float", + input: json.Number("3.14"), + check: func(t *testing.T, v starlark.Value) { + t.Helper() + _, ok := v.(starlark.Float) + require.True(t, ok) + }, + }, + { + name: "unsupported type", + input: struct{}{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + v, err := GoToStarlark(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + tt.check(t, v) + }) + } +} + +func TestStarlarkToGo(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input starlark.Value + expect interface{} + }{ + {"None", starlark.None, nil}, + {"Bool", starlark.True, true}, + {"Int", starlark.MakeInt(42), int64(42)}, + {"Float", starlark.Float(3.14), float64(3.14)}, + {"String", starlark.String("hello"), "hello"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := StarlarkToGo(tt.input) + require.Equal(t, tt.expect, got) + }) + } +} + +func TestRoundTrip(t *testing.T) { + t.Parallel() + + original := map[string]interface{}{ + "name": "test", + "count": int64(42), + "items": []interface{}{"a", "b"}, + } + + sv, err := GoToStarlark(original) + require.NoError(t, err) + + roundTripped := StarlarkToGo(sv) + require.Equal(t, original, roundTripped) +} diff --git a/pkg/script/internal/conversions/toolname.go b/pkg/script/internal/conversions/toolname.go new file mode 100644 index 0000000000..c4f8ee9c5e --- /dev/null +++ b/pkg/script/internal/conversions/toolname.go @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package conversions + +import ( + "regexp" + "unicode" +) + +var nonIdentChar = regexp.MustCompile(`[^a-zA-Z0-9_]`) + +// SanitizeName converts an MCP tool name into a valid Starlark identifier. +// Characters that are not alphanumeric or underscore are replaced with +// underscores. Leading digits get a prefix underscore. +func SanitizeName(name string) string { + s := nonIdentChar.ReplaceAllString(name, "_") + if len(s) > 0 && unicode.IsDigit(rune(s[0])) { + s = "_" + s + } + if s == "" { + s = "_" + } + return s +} diff --git a/pkg/script/internal/conversions/toolname_test.go b/pkg/script/internal/conversions/toolname_test.go new file mode 100644 index 0000000000..97c1da2b60 --- /dev/null +++ b/pkg/script/internal/conversions/toolname_test.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package conversions + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSanitizeName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expect string + }{ + {"simple name", "my_tool", "my_tool"}, + {"hyphens to underscores", "github-fetch-prs", "github_fetch_prs"}, + {"dots to underscores", "pagerduty.list.services", "pagerduty_list_services"}, + {"leading digit", "3scale-api", "_3scale_api"}, + {"empty string", "", "_"}, + {"all special chars", "---", "___"}, + {"mixed", "my-tool.v2/endpoint", "my_tool_v2_endpoint"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := SanitizeName(tt.input) + require.Equal(t, tt.expect, got) + }) + } +} diff --git a/pkg/script/internal/core/execute.go b/pkg/script/internal/core/execute.go new file mode 100644 index 0000000000..23853d72cf --- /dev/null +++ b/pkg/script/internal/core/execute.go @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package core provides the Starlark script execution engine. +package core + +import ( + "fmt" + "strings" + + "go.starlark.net/starlark" + "go.starlark.net/syntax" +) + +// ExecuteResult holds the raw Starlark result of a script execution. +type ExecuteResult struct { + // Value is the Starlark value returned by the script's top-level return. + Value starlark.Value + // Logs collects print() output from the script. + Logs []string +} + +// Execute runs a Starlark script with the given predeclared globals and step limit. +// +// The script is wrapped in a function body so that top-level return statements +// work naturally. A step limit of 0 disables the execution step cap (not +// recommended for untrusted scripts). +func Execute(script string, globals starlark.StringDict, stepLimit uint64) (*ExecuteResult, error) { + wrapped := wrapScript(script) + + var logs []string + thread := &starlark.Thread{ + Name: "script-exec", + Print: func(_ *starlark.Thread, msg string) { + logs = append(logs, msg) + }, + Load: func(_ *starlark.Thread, module string) (starlark.StringDict, error) { + return nil, fmt.Errorf("load(%q) is not permitted in scripts", module) + }, + } + + if stepLimit > 0 { + thread.SetMaxExecutionSteps(stepLimit) + } + + predeclared := make(starlark.StringDict, len(globals)) + for k, v := range globals { + predeclared[k] = v + } + + resultGlobals, err := starlark.ExecFileOptions( + &syntax.FileOptions{}, + thread, + "script.star", + wrapped, + predeclared, + ) + if err != nil { + return nil, fmt.Errorf("script execution failed: %w", err) + } + + result, ok := resultGlobals["__result__"] + if !ok { + result = starlark.None + } + + return &ExecuteResult{ + Value: result, + Logs: logs, + }, nil +} + +// wrapScript wraps a user script in a function body so top-level return works. +// The script becomes the body of __main__(), and its return value is captured +// in __result__. +// +// Known limitation: the 4-space indentation changes the content of multi-line +// string literals (triple-quoted strings). This is acceptable for tool +// orchestration scripts where triple-quoted strings are uncommon. +func wrapScript(script string) string { + var b strings.Builder + b.WriteString("def __main__():\n") + for _, line := range strings.Split(script, "\n") { + b.WriteString(" ") + b.WriteString(line) + b.WriteString("\n") + } + b.WriteString("__result__ = __main__()\n") + return b.String() +} diff --git a/pkg/script/internal/core/execute_test.go b/pkg/script/internal/core/execute_test.go new file mode 100644 index 0000000000..ef4f7a7c7e --- /dev/null +++ b/pkg/script/internal/core/execute_test.go @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.starlark.net/starlark" +) + +func TestExecute(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + script string + globals starlark.StringDict + stepLimit uint64 + check func(t *testing.T, result *ExecuteResult) + wantErr string + }{ + { + name: "returns integer", + script: `return 42`, + stepLimit: 100_000, + check: func(t *testing.T, result *ExecuteResult) { + t.Helper() + intVal, ok := result.Value.(starlark.Int) + require.True(t, ok, "expected Int, got %T", result.Value) + got, _ := intVal.Int64() + require.Equal(t, int64(42), got) + }, + }, + { + name: "returns string", + script: `return "hello"`, + stepLimit: 100_000, + check: func(t *testing.T, result *ExecuteResult) { + t.Helper() + strVal, ok := result.Value.(starlark.String) + require.True(t, ok, "expected String, got %T", result.Value) + require.Equal(t, "hello", string(strVal)) + }, + }, + { + name: "no return yields None", + script: `x = 1 + 1`, + stepLimit: 100_000, + check: func(t *testing.T, result *ExecuteResult) { + t.Helper() + require.Equal(t, starlark.None, result.Value) + }, + }, + { + name: "uses predeclared globals", + script: `return x + 5`, + globals: starlark.StringDict{"x": starlark.MakeInt(10)}, + stepLimit: 100_000, + check: func(t *testing.T, result *ExecuteResult) { + t.Helper() + intVal, ok := result.Value.(starlark.Int) + require.True(t, ok) + got, _ := intVal.Int64() + require.Equal(t, int64(15), got) + }, + }, + { + name: "captures print output", + script: ` +print("line1") +print("line2") +return "done" +`, + stepLimit: 100_000, + check: func(t *testing.T, result *ExecuteResult) { + t.Helper() + require.Equal(t, []string{"line1", "line2"}, result.Logs) + }, + }, + { + name: "step limit exceeded", + script: ` +x = 0 +for i in range(10000): + x = x + 1 +return x +`, + stepLimit: 100, + wantErr: "too many steps", + }, + { + name: "syntax error", + script: `return ][`, + stepLimit: 100_000, + wantErr: "script execution failed", + }, + { + name: "load statement rejected", + script: `load("module.star", "func")`, + stepLimit: 100_000, + wantErr: "load statement within a function", + }, + { + name: "loops and conditionals", + script: ` +items = [1, 2, 3, 4, 5] +total = 0 +for item in items: + if item % 2 == 0: + total = total + item +return total +`, + stepLimit: 100_000, + check: func(t *testing.T, result *ExecuteResult) { + t.Helper() + intVal, ok := result.Value.(starlark.Int) + require.True(t, ok) + got, _ := intVal.Int64() + require.Equal(t, int64(6), got) + }, + }, + { + name: "returns dict", + script: ` +d = {"key": "value", "count": 42} +return d +`, + stepLimit: 100_000, + check: func(t *testing.T, result *ExecuteResult) { + t.Helper() + dictVal, ok := result.Value.(*starlark.Dict) + require.True(t, ok, "expected Dict, got %T", result.Value) + require.Equal(t, 2, dictVal.Len()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := Execute(tt.script, tt.globals, tt.stepLimit) + + if tt.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + tt.check(t, result) + }) + } +} diff --git a/pkg/script/script.go b/pkg/script/script.go new file mode 100644 index 0000000000..dc1091310b --- /dev/null +++ b/pkg/script/script.go @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package script provides a Starlark-based script execution engine for +// orchestrating MCP tool calls. It allows agents to send scripts that call +// multiple tools and return aggregated results in a single round-trip. +package script + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" +) + +// DefaultStepLimit is the default maximum number of Starlark execution steps. +const DefaultStepLimit uint64 = 100_000 + +// Executor runs Starlark scripts and describes the virtual tool. +// +// Scripts can call any tool bound at construction time, use loops and +// conditionals, fan out with parallel(), and return aggregated results. +// The returned CallToolResult is ready for direct serialization into a +// JSON-RPC response by the middleware layer. +type Executor interface { + // Execute runs a Starlark script with optional named data arguments + // injected as top-level variables. Tools are already bound from + // construction and available as callable functions within the script. + Execute(ctx context.Context, script string, data map[string]interface{}) (*mcp.CallToolResult, error) + + // ToolDescription returns the dynamic description for the + // execute_tool_script virtual tool definition, listing all available + // tools and their calling conventions. + ToolDescription() string +} + +// Tool bundles an MCP tool's metadata with a callback for invoking it. +// +// The middleware layer constructs these with Call closures that route +// invocations through the middleware chain, ensuring authz and other +// policies are enforced on inner tool calls. +type Tool struct { + // Name is the MCP tool name (e.g., "github-fetch-prs"). + Name string + + // Description is the human-readable tool description. + Description string + + // Call invokes the tool with the given arguments and returns the MCP result. + // Arguments are always a string-keyed map. When scripts use positional + // arguments, they are converted to "arg0", "arg1", etc. + // + // The caller is responsible for enforcing per-call timeouts (e.g., by + // wrapping ctx with context.WithTimeout in the closure). The engine + // passes the context through but does not apply additional deadlines. + Call func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) +} + +// Config holds script execution parameters. A nil Config passed to New +// uses sensible defaults for all fields. +type Config struct { + // StepLimit is the maximum number of Starlark execution steps per script. + // Prevents infinite loops and runaway computation. + // Zero uses DefaultStepLimit (100,000). + StepLimit uint64 + + // ParallelMax is the maximum number of concurrent goroutines that + // parallel() can spawn. Zero means unlimited. + ParallelMax int +} + +// New creates an Executor bound to the given tools and configuration. +// A nil cfg uses defaults (DefaultStepLimit, unlimited parallelism, no timeout). +func New(tools []Tool, cfg *Config) Executor { + c := resolveConfig(cfg) + return &executor{ + tools: tools, + config: c, + } +} + +func resolveConfig(cfg *Config) Config { + if cfg == nil { + return Config{StepLimit: DefaultStepLimit} + } + c := *cfg + if c.StepLimit == 0 { + c.StepLimit = DefaultStepLimit + } + if c.ParallelMax < 0 { + c.ParallelMax = 0 + } + return c +} diff --git a/pkg/script/script_test.go b/pkg/script/script_test.go new file mode 100644 index 0000000000..a9f65ec50b --- /dev/null +++ b/pkg/script/script_test.go @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package script + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" +) + +func TestExecutor(t *testing.T) { + t.Parallel() + + echoTool := Tool{ + Name: "echo", + Description: "Returns arguments as JSON", + Call: func(_ context.Context, args map[string]interface{}) (*mcp.CallToolResult, error) { + b, _ := json.Marshal(args) + return mcp.NewToolResultText(string(b)), nil + }, + } + + statusTool := Tool{ + Name: "check-status", + Description: "Check service status", + Call: func(_ context.Context, args map[string]interface{}) (*mcp.CallToolResult, error) { + svc, _ := args["service"].(string) + status := "healthy" + if svc == "db" { + status = "degraded" + } + return mcp.NewToolResultText(fmt.Sprintf(`{"service": "%s", "status": "%s"}`, svc, status)), nil + }, + } + + fetchTool := Tool{ + Name: "fetch", + Description: "Fetch data by ID", + Call: func(_ context.Context, args map[string]interface{}) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText(fmt.Sprintf(`"result-%v"`, args["id"])), nil + }, + } + + hyphenatedTool := Tool{ + Name: "my-hyphenated-tool", + Description: "A tool with hyphens", + Call: func(_ context.Context, _ map[string]interface{}) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText(`"called"`), nil + }, + } + + tests := []struct { + name string + tools []Tool + config *Config + script string + data map[string]interface{} + check func(t *testing.T, result *mcp.CallToolResult) + errMsg string + }{ + { + name: "multi-tool script with loops and conditionals", + tools: []Tool{statusTool}, + script: ` +services = ["api", "db", "cache"] +degraded = [] +for svc in services: + status = check_status(service=svc) + if status["status"] != "healthy": + degraded.append(status["service"]) +return degraded +`, + check: func(t *testing.T, result *mcp.CallToolResult) { + t.Helper() + var parsed []interface{} + require.NoError(t, json.Unmarshal([]byte(extractText(t, result)), &parsed)) + require.Equal(t, []interface{}{"db"}, parsed) + }, + }, + { + name: "JSON text automatically parsed into structured data", + tools: []Tool{echoTool}, + script: ` +result = echo(name="alice", count=42) +return {"name": result["name"], "count": result["count"]} +`, + check: func(t *testing.T, result *mcp.CallToolResult) { + t.Helper() + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(extractText(t, result)), &parsed)) + require.Equal(t, "alice", parsed["name"]) + require.Equal(t, float64(42), parsed["count"]) + }, + }, + { + name: "parallel fan-out returns ordered results", + tools: []Tool{fetchTool}, + script: ` +results = parallel([ + lambda: fetch(id=1), + lambda: fetch(id=2), + lambda: fetch(id=3), +]) +return results +`, + check: func(t *testing.T, result *mcp.CallToolResult) { + t.Helper() + var parsed []interface{} + require.NoError(t, json.Unmarshal([]byte(extractText(t, result)), &parsed)) + require.Len(t, parsed, 3) + }, + }, + { + name: "data arguments injected as top-level variables", + tools: []Tool{echoTool}, + script: ` +return echo(name=user_name) +`, + data: map[string]interface{}{"user_name": "Alice"}, + check: func(t *testing.T, result *mcp.CallToolResult) { + t.Helper() + require.Contains(t, extractText(t, result), "Alice") + }, + }, + { + name: "call_tool dispatches by original name", + tools: []Tool{hyphenatedTool}, + script: `return call_tool("my-hyphenated-tool")`, + check: func(t *testing.T, result *mcp.CallToolResult) { + t.Helper() + require.Contains(t, extractText(t, result), "called") + }, + }, + { + name: "step limit exceeded", + config: &Config{StepLimit: 100}, + script: ` +x = 0 +for i in range(10000): + x = x + 1 +return x +`, + errMsg: "too many steps", + }, + { + name: "script logs appear as second content item", + script: ` +print("log line 1") +print("log line 2") +return "done" +`, + check: func(t *testing.T, result *mcp.CallToolResult) { + t.Helper() + require.Len(t, result.Content, 2, "should have result text + logs") + logsContent, ok := mcp.AsTextContent(result.Content[1]) + require.True(t, ok) + require.Contains(t, logsContent.Text, "log line 1") + require.Contains(t, logsContent.Text, "log line 2") + }, + }, + { + name: "data argument shadowing builtin rejected", + tools: []Tool{echoTool}, + script: `return 1`, + data: map[string]interface{}{"call_tool": "shadow"}, + errMsg: "conflicts with", + }, + { + name: "data argument shadowing tool rejected", + tools: []Tool{echoTool}, + script: `return 1`, + data: map[string]interface{}{"echo": "shadow"}, + errMsg: "conflicts with", + }, + { + name: "invalid data argument type rejected", + script: `return 1`, + data: map[string]interface{}{"bad": struct{}{}}, + errMsg: `data argument "bad"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + exec := New(tt.tools, tt.config) + result, err := exec.Execute(context.Background(), tt.script, tt.data) + + if tt.errMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + tt.check(t, result) + }) + } +} + +func TestExecutor_ToolDescription(t *testing.T) { + t.Parallel() + + tools := []Tool{ + {Name: "tool-a", Description: "Does A"}, + {Name: "tool-b", Description: "Does B"}, + } + + exec := New(tools, nil) + desc := exec.ToolDescription() + + require.Contains(t, desc, "tool-a") + require.Contains(t, desc, "tool-b") + require.Contains(t, desc, "parallel") +} + +// extractText gets the first text content from a CallToolResult. +func extractText(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + require.NotEmpty(t, result.Content) + tc, ok := mcp.AsTextContent(result.Content[0]) + require.True(t, ok, "expected text content") + return tc.Text +} From 940fc7be40fcd7cf2a79ccdccf2faacadc367b90 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Tue, 14 Apr 2026 19:00:50 +0100 Subject: [PATCH 029/117] Allow local key provider to bypass OIDC discovery failure (#4774) When the vMCP has an embedded auth server, ensureOIDCDiscovered() fails fatally if the issuer URL is unreachable from inside the cluster, preventing the local key provider from ever being consulted. Make the discovery error non-fatal when a keyProvider is configured. The local provider resolves signing keys in-process, so HTTP-based OIDC discovery is not required for token validation. When discovery fails with a keyProvider present, mark it as completed to avoid the per-request retry penalty (3 attempts with backoff). When the issuer IS reachable, discovery succeeds normally and the HTTP JWKS fallback remains available for key ID misses against the local provider. Also improve the error message when the local provider cannot resolve a key and no JWKS URL is available, and add a constructor log distinguishing deferred discovery from non-fatal discovery. Fixes #4747 Co-authored-by: Claude Opus 4.6 (1M context) --- pkg/auth/token.go | 34 +++++-- pkg/auth/token_test.go | 213 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 6 deletions(-) diff --git a/pkg/auth/token.go b/pkg/auth/token.go index 4f82ce0858..026b925231 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -641,7 +641,13 @@ func NewTokenValidator(ctx context.Context, config TokenValidatorConfig, opts .. "issuer", config.Issuer, ) } else if jwksURL == "" && config.Issuer != "" { - slog.Debug("OIDC discovery deferred - will discover on first validation request", "issuer", config.Issuer) + if o.keyProvider != nil { + slog.Debug("OIDC discovery deferred - failure non-fatal with local key provider", + "issuer", config.Issuer) + } else { + slog.Debug("OIDC discovery deferred - will discover on first validation request", + "issuer", config.Issuer) + } } // Ensure we have either an explicit JWKS URL, an issuer to discover from, @@ -892,9 +898,15 @@ func (v *TokenValidator) getKeyFromJWKS(ctx context.Context, token *jwt.Token) ( } // Fall through to HTTP-based JWKS lookup. - // Defensive check: JWKS URL must be set before calling this function. - // This invariant is normally guaranteed by ValidateToken calling ensureOIDCDiscovered first. + // When a local key provider is present, OIDC discovery may have been + // skipped entirely, so jwksURL can legitimately be empty. if v.jwksURL == "" { + if v.keyProvider != nil { + return nil, fmt.Errorf( + "local key provider could not resolve key and no JWKS URL is available: %w", + ErrMissingJWKSURL, + ) + } return nil, ErrMissingJWKSURL } @@ -1041,10 +1053,20 @@ func (v *TokenValidator) introspectOpaqueToken(ctx context.Context, tokenStr str // ValidateToken validates a token. func (v *TokenValidator) ValidateToken(ctx context.Context, tokenString string) (jwt.MapClaims, error) { - // Ensure OIDC discovery is complete (lazy discovery with retry) - // This is a no-op if discovery was already performed or if JWKS URL was provided directly + // Ensure OIDC discovery is complete (lazy discovery with retry). + // When a local key provider is configured (embedded auth server), + // discovery failure is non-fatal — signing keys can be resolved + // in-process and the issuer URL may not be reachable from within + // the cluster. Mark discovery as done to avoid per-request retries. if err := v.ensureOIDCDiscovered(ctx); err != nil { - return nil, fmt.Errorf("OIDC discovery failed: %w", err) + if v.keyProvider == nil { + return nil, fmt.Errorf("OIDC discovery failed: %w", err) + } + slog.Warn("OIDC discovery failed, proceeding with local key provider", + "issuer", v.issuer, "error", err) + v.oidcDiscoveryMu.Lock() + v.oidcDiscovered = true + v.oidcDiscoveryMu.Unlock() } // Parse the token diff --git a/pkg/auth/token_test.go b/pkg/auth/token_test.go index a51f76ea03..6b3c5d3710 100644 --- a/pkg/auth/token_test.go +++ b/pkg/auth/token_test.go @@ -2599,3 +2599,216 @@ func TestGetKeyFromLocalProvider(t *testing.T) { require.Nil(t, key) }) } + +func TestValidateToken_DiscoveryFailsWithKeyProvider(t *testing.T) { + t.Parallel() + + // closedTLSServer returns a closed TLS server URL and its CA cert path. + // Connection refused is instant because DNS resolves but the socket is closed. + closedTLSServer := func(t *testing.T) (string, string) { + t.Helper() + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + certPath := writeTestServerCert(t, server) + server.Close() + return server.URL, certPath + } + + // setupClosedServerTest generates an RSA key pair, creates a validator pointed + // at a closed TLS server, and returns a signed JWT for that issuer. The + // keyProviderKID controls whether a mock key provider is configured: + // - non-empty: configures a mock returning a key with that kid + // - empty: no key provider is attached + type closedServerFixture struct { + validator *TokenValidator + tokenString string + } + setupClosedServerTest := func(t *testing.T, keyProviderKID string) closedServerFixture { + t.Helper() + + ctrl := gomock.NewController(t) + mockEnv := envmocks.NewMockReader(ctrl) + mockEnv.EXPECT().Getenv(gomock.Any()).Return("").AnyTimes() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + opts := []TokenValidatorOption{WithEnvReader(mockEnv)} + if keyProviderKID != "" { + mockProvider := keysmocks.NewMockPublicKeyProvider(ctrl) + mockProvider.EXPECT().PublicKeys(gomock.Any()).Return([]*keys.PublicKeyData{ + {KeyID: keyProviderKID, Algorithm: "RS256", PublicKey: &privateKey.PublicKey}, + }, nil).AnyTimes() + opts = append(opts, WithKeyProvider(mockProvider)) + } + + closedURL, certPath := closedTLSServer(t) + + ctx := context.Background() + validator, err := NewTokenValidator(ctx, TokenValidatorConfig{ + Issuer: closedURL, + Audience: "test-audience", + ClientID: "test-client", + CACertPath: certPath, + AllowPrivateIP: true, + }, opts...) + require.NoError(t, err) + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": closedURL, + "aud": "test-audience", + "exp": time.Now().Add(time.Hour).Unix(), + "sub": "test-user", + }) + token.Header["kid"] = testKeyID + tokenString, err := token.SignedString(privateKey) + require.NoError(t, err) + + return closedServerFixture{validator: validator, tokenString: tokenString} + } + + tests := []struct { + name string + keyProviderKID string // empty means no key provider + wantErr error // nil means success + wantSub string // checked only when wantErr is nil + checkDiscovered bool // whether to assert oidcDiscovered state after tolerated failure + }{ + { + name: "discovery fails but keyProvider resolves key", + keyProviderKID: testKeyID, + wantErr: nil, + wantSub: "test-user", + checkDiscovered: true, + }, + { + name: "discovery fails and keyProvider kid miss returns error", + keyProviderKID: "other-kid", + wantErr: ErrMissingJWKSURL, + }, + { + name: "discovery fails without keyProvider returns discovery error", + wantErr: ErrFailedToDiscoverOIDC, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fix := setupClosedServerTest(t, tt.keyProviderKID) + ctx := context.Background() + + claims, err := fix.validator.ValidateToken(ctx, fix.tokenString) + if tt.wantErr != nil { + require.Error(t, err) + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + require.Equal(t, tt.wantSub, claims["sub"]) + } + if tt.checkDiscovered { + // Discovery was attempted, failed, and tolerated — marked as done + // to avoid per-request retry penalty. + require.True(t, fix.validator.oidcDiscovered) + } + }) + } + + t.Run("keyProvider miss falls through to explicit JWKS URL", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mockEnv := envmocks.NewMockReader(ctrl) + mockEnv.EXPECT().Getenv(gomock.Any()).Return("").AnyTimes() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + // Mock key provider returns a key with a DIFFERENT kid than the token, + // so getKeyFromLocalProvider returns (nil, nil) on kid mismatch. + mockProvider := keysmocks.NewMockPublicKeyProvider(ctrl) + mockProvider.EXPECT().PublicKeys(gomock.Any()).Return([]*keys.PublicKeyData{ + {KeyID: "other-kid", Algorithm: "RS256", PublicKey: &privateKey.PublicKey}, + }, nil).AnyTimes() + + // Build JWK key set for the JWKS server with the CORRECT kid + jwkKey, err := jwk.Import(&privateKey.PublicKey) + require.NoError(t, err) + require.NoError(t, jwkKey.Set(jwk.KeyIDKey, testKeyID)) + require.NoError(t, jwkKey.Set(jwk.AlgorithmKey, "RS256")) + require.NoError(t, jwkKey.Set(jwk.KeyUsageKey, "sig")) + keySet := jwk.NewSet() + require.NoError(t, keySet.AddKey(jwkKey)) + + jwksServer, certPath := createTestJWKSServer(t, keySet) + t.Cleanup(jwksServer.Close) + + ctx := context.Background() + validator, err := NewTokenValidator(ctx, TokenValidatorConfig{ + JWKSURL: jwksServer.URL, + Audience: "test-audience", + ClientID: "test-client", + CACertPath: certPath, + AllowPrivateIP: true, + }, WithEnvReader(mockEnv), WithKeyProvider(mockProvider)) + require.NoError(t, err) + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "aud": "test-audience", + "exp": time.Now().Add(time.Hour).Unix(), + "sub": "test-user", + }) + token.Header["kid"] = testKeyID + tokenString, err := token.SignedString(privateKey) + require.NoError(t, err) + + claims, err := validator.ValidateToken(ctx, tokenString) + require.NoError(t, err) + require.Equal(t, "test-user", claims["sub"]) + }) + + t.Run("keyProvider PublicKeys error falls through to JWKS miss", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mockEnv := envmocks.NewMockReader(ctrl) + mockEnv.EXPECT().Getenv(gomock.Any()).Return("").AnyTimes() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + mockProvider := keysmocks.NewMockPublicKeyProvider(ctrl) + mockProvider.EXPECT().PublicKeys(gomock.Any()).Return(nil, errors.New("key store unavailable")).AnyTimes() + + closedURL, certPath := closedTLSServer(t) + + ctx := context.Background() + validator, err := NewTokenValidator(ctx, TokenValidatorConfig{ + Issuer: closedURL, + Audience: "test-audience", + ClientID: "test-client", + CACertPath: certPath, + AllowPrivateIP: true, + }, WithEnvReader(mockEnv), WithKeyProvider(mockProvider)) + require.NoError(t, err) + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": closedURL, + "aud": "test-audience", + "exp": time.Now().Add(time.Hour).Unix(), + "sub": "test-user", + }) + token.Header["kid"] = testKeyID + tokenString, err := token.SignedString(privateKey) + require.NoError(t, err) + + // Provider error is swallowed (falls back to HTTP JWKS), but + // discovery was also skipped so no JWKS URL is available. + _, err = validator.ValidateToken(ctx, tokenString) + require.Error(t, err) + require.ErrorIs(t, err, ErrMissingJWKSURL) + require.Contains(t, err.Error(), "local key provider could not resolve key") + }) +} From 47f428988713d55cd92a4856a00150c3292b563c Mon Sep 17 00:00:00 2001 From: Michelangelo Mori <328978+blkt@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:16:07 +0200 Subject: [PATCH 030/117] Fix port reuse test using hardcoded port number (#4812) The "Edit with explicit port" test case used port 9090 in the request body. The port-availability check (`networking.IsAvailable`) makes a real TCP bind, so the test failed whenever port 9090 was already in use on the host machine. Move the test case out of the static table and into a standalone sub-test that allocates a free port at runtime via `net.Listen` with `":0"`. The port is released before the HTTP handler runs so the availability check can bind it. Also add the missing `net` and `require` imports that this approach needs. --- pkg/api/v1/workloads_test.go | 98 +++++++++++++++++++++++++----------- 1 file changed, 69 insertions(+), 29 deletions(-) diff --git a/pkg/api/v1/workloads_test.go b/pkg/api/v1/workloads_test.go index e940dfc155..7c6abedc50 100644 --- a/pkg/api/v1/workloads_test.go +++ b/pkg/api/v1/workloads_test.go @@ -6,6 +6,7 @@ package v1 import ( "context" "fmt" + "net" "net/http" "net/http/httptest" "strings" @@ -13,6 +14,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "golang.org/x/sync/errgroup" @@ -267,9 +269,7 @@ func TestCreateWorkload(t *testing.T) { mockRetriever := makeMockRetriever(t, expectedServerOrImage, - "test-image", ®types.ImageMetadata{Image: "test-image"}, - nil, tt.expectedRuntimeConfig, ) @@ -488,11 +488,9 @@ func TestUpdateWorkload(t *testing.T) { tt.setupMock(t, mockWorkloadManager, mockRuntime, mockGroupManager) mockRetriever := makeMockRetriever(t, - "test-image", "test-image", ®types.ImageMetadata{Image: "test-image"}, nil, - nil, ) routes := &WorkloadRoutes{ @@ -558,26 +556,6 @@ func TestUpdateWorkload_PortReuse(t *testing.T) { expectedBody: "test-workload", description: "When proxy_port is 0, the existing port should be reused", }, - { - name: "Edit with explicit port should use that port", - workloadName: "test-workload", - requestBody: `{"image": "test-image", "proxy_port": 9090}`, - existingPort: 8080, - setupMock: func(t *testing.T, wm *workloadsmocks.MockManager, _ *runtimemocks.MockRuntime, gm *groupsmocks.MockManager) { - t.Helper() - wm.EXPECT().GetWorkload(gomock.Any(), "test-workload"). - Return(core.Workload{Name: "test-workload", Port: 8080}, nil) - gm.EXPECT().Exists(gomock.Any(), "default").Return(true, nil) - wm.EXPECT().UpdateWorkload(gomock.Any(), "test-workload", gomock.Any()). - DoAndReturn(func(_ context.Context, _ string, runConfig *runner.RunConfig) (*errgroup.Group, error) { - assert.Equal(t, 9090, runConfig.Port, "Port should be set to explicitly requested port") - return &errgroup.Group{}, nil - }) - }, - expectedStatus: http.StatusOK, - expectedBody: "test-workload", - description: "When an explicit port is provided, it should be used instead of reusing", - }, { name: "Edit with same port should skip validation", workloadName: "test-workload", @@ -633,11 +611,9 @@ func TestUpdateWorkload_PortReuse(t *testing.T) { tt.setupMock(t, mockWorkloadManager, mockRuntime, mockGroupManager) mockRetriever := makeMockRetriever(t, - "test-image", "test-image", ®types.ImageMetadata{Image: "test-image"}, nil, - nil, ) routes := &WorkloadRoutes{ @@ -670,14 +646,78 @@ func TestUpdateWorkload_PortReuse(t *testing.T) { assert.Contains(t, w.Body.String(), tt.expectedBody, tt.description) }) } + + // This sub-test must allocate a free port at runtime; it cannot use a + // hardcoded port number because the port availability check makes a real + // network bind and an in-use port causes a spurious 400 response. + t.Run("Edit with explicit port should use that port", func(t *testing.T) { + t.Parallel() + + // Obtain a free port, then release it so the port-availability check + // inside config.WithPorts can bind it immediately afterward. + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "should be able to listen on a free port") + freePort := ln.Addr().(*net.TCPAddr).Port + require.NoError(t, ln.Close(), "should be able to release the free port") + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockWorkloadManager := workloadsmocks.NewMockManager(ctrl) + mockRuntime := runtimemocks.NewMockRuntime(ctrl) + mockGroupManager := groupsmocks.NewMockManager(ctrl) + + mockWorkloadManager.EXPECT().GetWorkload(gomock.Any(), "test-workload"). + Return(core.Workload{Name: "test-workload", Port: 8080}, nil) + mockGroupManager.EXPECT().Exists(gomock.Any(), "default").Return(true, nil) + mockWorkloadManager.EXPECT().UpdateWorkload(gomock.Any(), "test-workload", gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, runConfig *runner.RunConfig) (*errgroup.Group, error) { + assert.Equal(t, freePort, runConfig.Port, "Port should be set to explicitly requested port") + return &errgroup.Group{}, nil + }) + + mockRetriever := makeMockRetriever(t, + "test-image", + ®types.ImageMetadata{Image: "test-image"}, + nil, + ) + + routes := &WorkloadRoutes{ + workloadManager: mockWorkloadManager, + containerRuntime: mockRuntime, + groupManager: mockGroupManager, + debugMode: false, + workloadService: &WorkloadService{ + groupManager: mockGroupManager, + workloadManager: mockWorkloadManager, + containerRuntime: mockRuntime, + imageRetriever: mockRetriever, + imagePuller: func(_ context.Context, _ string) error { return nil }, + configProvider: config.NewDefaultProvider(), + }, + } + + body := fmt.Sprintf(`{"image": "test-image", "proxy_port": %d}`, freePort) + req := httptest.NewRequest("POST", "/api/v1beta/workloads/test-workload/edit", + strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("name", "test-workload") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + apierrors.ErrorHandler(routes.updateWorkload).ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "When an explicit port is provided, it should be used instead of reusing") + assert.Contains(t, w.Body.String(), "test-workload", "When an explicit port is provided, it should be used instead of reusing") + }) } func makeMockRetriever( t *testing.T, expectedServerOrImage string, - returnedImage string, returnedServerMetadata regtypes.ServerMetadata, - returnedError error, expectedRuntimeConfig *templates.RuntimeConfig, ) retriever.Retriever { t.Helper() @@ -686,6 +726,6 @@ func makeMockRetriever( assert.Equal(t, expectedServerOrImage, serverOrImage) assert.Equal(t, retriever.VerifyImageWarn, verificationType) assert.Equal(t, expectedRuntimeConfig, runtimeConfig) - return returnedImage, returnedServerMetadata, returnedError + return "test-image", returnedServerMetadata, nil } } From 8c4f765d3082a7b218709ccd46bd3af3bfc27cc7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:58:37 +0100 Subject: [PATCH 031/117] Bump go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp from 1.41.0 to 1.43.0 (#4687) Bump go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp Bumps [go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp](https://github.com/open-telemetry/opentelemetry-go) from 1.41.0 to 1.43.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.41.0...v1.43.0) --- updated-dependencies: - dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp dependency-version: 1.43.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 5e36c7477b..ec4c677f99 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,7 @@ require ( github.com/xeipuuv/gojsonschema v1.2.0 github.com/zalando/go-keyring v0.2.8 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 go.opentelemetry.io/otel/exporters/prometheus v0.63.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 @@ -280,7 +280,7 @@ require ( go.opentelemetry.io/contrib/propagators/jaeger v1.21.1 // indirect go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1 // indirect go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/exporters/zipkin v1.21.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/atomic v1.11.0 // indirect diff --git a/go.sum b/go.sum index 4a3170e326..70f8cf0b79 100644 --- a/go.sum +++ b/go.sum @@ -918,10 +918,10 @@ go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= go.opentelemetry.io/otel/exporters/prometheus v0.63.0 h1:OLo1FNb0pBZykLqbKRZolKtGZd0Waqlr240YdMEnhhg= go.opentelemetry.io/otel/exporters/prometheus v0.63.0/go.mod h1:8yeQAdhrK5xsWuFehO13Dk/Xb9FuhZoVpJfpoNCfJnw= go.opentelemetry.io/otel/exporters/zipkin v1.21.0 h1:D+Gv6lSfrFBWmQYyxKjDd0Zuld9SRXpIrEsKZvE4DO4= From daea31b506870736b1447a7a7618c0bedb314e03 Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:13:42 +0200 Subject: [PATCH 032/117] Address review findings from v0.1 skills OpenAPI PR (#4802) --- docs/server/docs.go | 3 +++ docs/server/swagger.json | 3 +++ docs/server/swagger.yaml | 3 +++ pkg/api/v1/registry.go | 8 ++++++-- pkg/api/v1/registry_v01_skills.go | 12 +++++++++--- pkg/api/v1/registry_v01_skills_test.go | 6 +++++- 6 files changed, 29 insertions(+), 6 deletions(-) diff --git a/docs/server/docs.go b/docs/server/docs.go index 86da675929..ed55ea6cc9 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -2511,11 +2511,14 @@ const docTemplate = `{ "type": "object" }, "pkg_api_v1.registryErrorResponse": { + "description": "Structured error response returned by registry endpoints", "properties": { "code": { + "description": "Code is a machine-readable error code (e.g. \"not_found\", \"registry_auth_required\")", "type": "string" }, "message": { + "description": "Message is a human-readable description of the error", "type": "string" } }, diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 405f04dcd8..4145699631 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -2504,11 +2504,14 @@ "type": "object" }, "pkg_api_v1.registryErrorResponse": { + "description": "Structured error response returned by registry endpoints", "properties": { "code": { + "description": "Code is a machine-readable error code (e.g. \"not_found\", \"registry_auth_required\")", "type": "string" }, "message": { + "description": "Message is a human-readable description of the error", "type": "string" } }, diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 81d5eb3c9b..9eb1f34734 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -2227,10 +2227,13 @@ components: type: string type: object pkg_api_v1.registryErrorResponse: + description: Structured error response returned by registry endpoints properties: code: + description: Code is a machine-readable error code (e.g. "not_found", "registry_auth_required") type: string message: + description: Message is a human-readable description of the error type: string type: object pkg_api_v1.registryInfo: diff --git a/pkg/api/v1/registry.go b/pkg/api/v1/registry.go index 7d09234a5e..72b74959c6 100644 --- a/pkg/api/v1/registry.go +++ b/pkg/api/v1/registry.go @@ -26,11 +26,15 @@ import ( // Desktop clients (Studio) match on this value to display the correct UI. const RegistryAuthRequiredCode = "registry_auth_required" -// registryErrorResponse is the JSON body for structured HTTP 503 error responses. +// registryErrorResponse is the JSON body for structured HTTP error responses. // The "code" field allows clients (e.g. Studio) to distinguish between // "registry_auth_required" and "registry_unavailable" conditions. +// +// @Description Structured error response returned by registry endpoints type registryErrorResponse struct { - Code string `json:"code"` + // Code is a machine-readable error code (e.g. "not_found", "registry_auth_required") + Code string `json:"code"` + // Message is a human-readable description of the error Message string `json:"message"` } diff --git a/pkg/api/v1/registry_v01_skills.go b/pkg/api/v1/registry_v01_skills.go index 53973ff599..a08f1bccb5 100644 --- a/pkg/api/v1/registry_v01_skills.go +++ b/pkg/api/v1/registry_v01_skills.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "log/slog" + "math" "net/http" "strconv" "strings" @@ -209,8 +210,15 @@ func parseSkillsPagination(r *http.Request) (page, limit int) { page = v } } + // Cap page so (page-1)*limit cannot overflow int in the caller. + if maxPage := math.MaxInt / limit; page > maxPage { + page = maxPage + } if l := r.URL.Query().Get("limit"); l != "" { - if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= skillsMaxLimit { + if v, err := strconv.Atoi(l); err == nil && v > 0 { + if v > skillsMaxLimit { + v = skillsMaxLimit + } limit = v } } @@ -228,8 +236,6 @@ type skillsV01Response struct { } // skillsV01Metadata holds pagination metadata for the v0.1 skills list response. -// -// @Description Pagination metadata for a skills list response type skillsV01Metadata struct { // Total is the total number of skills matching the query Total int `json:"total"` diff --git a/pkg/api/v1/registry_v01_skills_test.go b/pkg/api/v1/registry_v01_skills_test.go index 80917d39fb..3155d28d0a 100644 --- a/pkg/api/v1/registry_v01_skills_test.go +++ b/pkg/api/v1/registry_v01_skills_test.go @@ -5,6 +5,8 @@ package v1 import ( "encoding/json" + "fmt" + "math" "net/http" "net/http/httptest" "testing" @@ -60,7 +62,9 @@ func TestParseSkillsPagination(t *testing.T) { {"custom limit", "limit=10", 1, 10}, {"both", "page=2&limit=25", 2, 25}, {"invalid page", "page=-1", 1, skillsDefaultLimit}, - {"limit over max", "limit=999", 1, skillsDefaultLimit}, + {"limit over max", "limit=999", 1, skillsMaxLimit}, + {"limit at max", "limit=200", 1, skillsMaxLimit}, + {"page overflow", fmt.Sprintf("page=%d", math.MaxInt), math.MaxInt / skillsDefaultLimit, skillsDefaultLimit}, {"non-numeric", "page=abc&limit=xyz", 1, skillsDefaultLimit}, } From 42f7395688da3e8599ccdeba66d9a345361af5d4 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Tue, 14 Apr 2026 21:15:08 +0100 Subject: [PATCH 033/117] Add integration test for default audience without resource parameter (#4818) The fix in #4805 defaults the aud claim to the sole AllowedAudiences entry when clients omit the RFC 8707 resource parameter, but only included unit tests. Add an end-to-end integration test that verifies the full OAuth flow (authorize -> callback -> token exchange -> JWT validation) produces the correct audience when resource is omitted. Co-authored-by: Claude Opus 4.6 --- pkg/authserver/integration_test.go | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/pkg/authserver/integration_test.go b/pkg/authserver/integration_test.go index 973398ea9b..2ff65d7cc7 100644 --- a/pkg/authserver/integration_test.go +++ b/pkg/authserver/integration_test.go @@ -787,6 +787,49 @@ func TestIntegration_FullPKCEFlow(t *testing.T) { assert.ElementsMatch(t, requestedScopes, scopeStrings, "granted scopes should match requested scopes") } +// TestIntegration_FullPKCEFlow_DefaultAudience verifies that omitting the +// RFC 8707 resource parameter still produces a token with the correct aud +// claim when the server has exactly one allowed audience. +func TestIntegration_FullPKCEFlow_DefaultAudience(t *testing.T) { + t.Parallel() + + m := startMockOIDC(t) + ts := setupTestServerWithMockOIDC(t, m) + verifier := servercrypto.GeneratePKCEVerifier() + challenge := servercrypto.ComputePKCEChallenge(verifier) + + authCode, _ := completeAuthorizationFlow(t, ts.Server.URL, authorizationParams{ + ClientID: testClientID, + RedirectURI: testRedirectURI, + State: "default-aud-state", + Challenge: challenge, + Scope: "openid profile", + ResponseType: "code", + }) + + // Exchange code WITHOUT a resource parameter — the server should default + // to the sole allowed audience (testAudience = "https://mcp.example.com"). + tokenData := exchangeCodeForTokens(t, ts.Server.URL, authCode, verifier, "") + + accessToken, ok := tokenData["access_token"].(string) + require.True(t, ok, "access_token should be a string") + require.NotEmpty(t, accessToken) + + // Verify JWT signature and parse claims + parsedToken, err := jwt.ParseSigned(accessToken, []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err, "should be able to parse JWT") + + var claims map[string]interface{} + err = parsedToken.Claims(ts.PrivateKey.Public(), &claims) + require.NoError(t, err, "JWT signature should be valid") + + // The sole AllowedAudience should have been granted automatically. + aud, ok := claims["aud"].([]interface{}) + require.True(t, ok, "aud claim should be an array") + require.Len(t, aud, 1, "aud should have exactly one audience") + assert.Equal(t, testAudience, aud[0], "audience should default to sole AllowedAudience") +} + // ============================================================================ // OIDC Provider Integration Tests (OIDCProviderImpl via defaultUpstreamFactory) // ============================================================================ From 14a1e6c4d0e27d4ed787842f569b568e2a263a3d Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:25:04 +0100 Subject: [PATCH 034/117] Remove deprecated inline telemetry field from CRDs (#4819) * Remove deprecated inline telemetry field from CRDs The inline `telemetry` field on MCPServerSpec and MCPRemoteProxySpec, along with the `config.telemetry`/`telemetryConfigRef` mutual-exclusivity CEL rule on VirtualMCPServer, was deprecated in favor of TelemetryConfigRef which references a shared MCPTelemetryConfig resource. This removes all backward-compatibility shim code, CEL rules, deprecated utility functions, and the TelemetryConfig/OpenTelemetryConfig CRD types ahead of the v1beta1 API promotion. Existing CRs using the deprecated inline telemetry field will need to migrate to TelemetryConfigRef before upgrading. Co-Authored-By: Claude Opus 4.6 (1M context) * Address PR review feedback for telemetry removal - Add stderr warning in thv export when telemetry config is detected but cannot be exported inline, matching the existing secrets warning - Fix stale comment claiming CEL enforces config.telemetry / telemetryConfigRef mutual exclusivity (CEL rule was removed) - Fix normalizeTelemetry doc comment to mention the config.telemetry fallback path used by standalone CLI deployments - Fix stale call-site comment referencing deprecated inline Telemetry Co-Authored-By: Claude Opus 4.6 (1M context) * Stop reading Config.Telemetry in operator converter (D4) The operator-side converter no longer falls back to Config.Telemetry for VirtualMCPServer. Operator-managed vMCPs must use TelemetryConfigRef to configure telemetry. The Config.Telemetry field remains valid for standalone CLI deployments which read it directly. Co-Authored-By: Claude Opus 4.6 (1M context) * Regenerate CRD API reference docs Removed deprecated TelemetryConfig, OpenTelemetryConfig, and related inline telemetry type documentation from the generated CRD API reference. Co-Authored-By: Claude Opus 4.6 (1M context) * Update e2e telemetry test to use TelemetryConfigRef The operator no longer reads Config.Telemetry for VirtualMCPServer. Convert the e2e test to create an MCPTelemetryConfig resource and reference it via TelemetryConfigRef, matching the required migration path. Co-Authored-By: Claude Opus 4.6 (1M context) * Remove CEL mutual-exclusivity integration test for config.telemetry The CEL rule rejecting both config.telemetry and telemetryConfigRef was removed in this PR. Remove the integration test that validated it. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../api/v1alpha1/mcpremoteproxy_types.go | 9 - .../api/v1alpha1/mcpserver_types.go | 63 ----- .../api/v1alpha1/virtualmcpserver_types.go | 5 - .../api/v1alpha1/zz_generated.deepcopy.go | 65 ----- .../controllers/mcpremoteproxy_deployment.go | 6 - .../mcpremoteproxy_deployment_test.go | 30 --- .../mcpremoteproxy_reconciler_test.go | 23 -- .../controllers/mcpremoteproxy_runconfig.go | 5 +- .../controllers/mcpserver_controller.go | 6 - .../mcpserver_opentelemetry_test.go | 164 ------------- .../controllers/mcpserver_runconfig.go | 4 +- .../pkg/controllerutil/tokenexchange.go | 27 --- cmd/thv-operator/pkg/runconfig/telemetry.go | 20 -- .../pkg/runconfig/telemetry_test.go | 226 ------------------ .../pkg/spectoconfig/telemetry.go | 93 +------ .../pkg/spectoconfig/telemetry_test.go | 166 ------------- cmd/thv-operator/pkg/vmcpconfig/converter.go | 22 +- .../pkg/vmcpconfig/converter_test.go | 212 +--------------- ...pserver_cel_validation_integration_test.go | 62 ----- .../mcpserver_runconfig_integration_test.go | 83 +------ ...server_telemetryconfig_integration_test.go | 29 --- cmd/thv/app/export.go | 6 + ...oolhive.stacklok.dev_mcpremoteproxies.yaml | 82 ------- .../toolhive.stacklok.dev_mcpservers.yaml | 82 ------- ...olhive.stacklok.dev_virtualmcpservers.yaml | 7 - ...oolhive.stacklok.dev_mcpremoteproxies.yaml | 82 ------- .../toolhive.stacklok.dev_mcpservers.yaml | 82 ------- ...olhive.stacklok.dev_virtualmcpservers.yaml | 7 - docs/operator/crd-api.md | 54 +---- pkg/export/k8s.go | 27 +-- pkg/export/k8s_test.go | 42 ---- .../virtualmcp/virtualmcp_telemetry_test.go | 83 ++++--- 32 files changed, 92 insertions(+), 1782 deletions(-) delete mode 100644 cmd/thv-operator/controllers/mcpserver_opentelemetry_test.go diff --git a/cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go b/cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go index 4434b17f68..7a29da0d91 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go @@ -38,7 +38,6 @@ type HeaderFromSecret struct { // MCPRemoteProxySpec defines the desired state of MCPRemoteProxy // // +kubebuilder:validation:XValidation:rule="!(has(self.oidcConfig) && has(self.oidcConfigRef))",message="oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef to reference a shared MCPOIDCConfig" -// +kubebuilder:validation:XValidation:rule="!(has(self.telemetry) && has(self.telemetryConfigRef))",message="telemetry and telemetryConfigRef are mutually exclusive; migrate to telemetryConfigRef" // //nolint:lll // CEL validation rules exceed line length limit type MCPRemoteProxySpec struct { @@ -106,17 +105,9 @@ type MCPRemoteProxySpec struct { // TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. // The referenced MCPTelemetryConfig must exist in the same namespace as this MCPRemoteProxy. // Cross-namespace references are not supported for security and isolation reasons. - // Mutually exclusive with the deprecated inline Telemetry field. // +optional TelemetryConfigRef *MCPTelemetryConfigReference `json:"telemetryConfigRef,omitempty"` - // Telemetry defines inline observability configuration for the proxy. - // Deprecated: Use TelemetryConfigRef to reference a shared MCPTelemetryConfig resource instead. - // This field will be removed in a future release. Setting both telemetry and telemetryConfigRef - // is rejected by CEL validation. - // +optional - Telemetry *TelemetryConfig `json:"telemetry,omitempty"` - // Resources defines the resource requirements for the proxy container // +optional Resources ResourceRequirements `json:"resources,omitempty"` diff --git a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go b/cmd/thv-operator/api/v1alpha1/mcpserver_types.go index 62733750d4..b33ccc288d 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpserver_types.go @@ -176,7 +176,6 @@ const SessionStorageProviderRedis = "redis" // MCPServerSpec defines the desired state of MCPServer // // +kubebuilder:validation:XValidation:rule="!(has(self.oidcConfig) && has(self.oidcConfigRef))",message="oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef to reference a shared MCPOIDCConfig" -// +kubebuilder:validation:XValidation:rule="!(has(self.telemetry) && has(self.telemetryConfigRef))",message="telemetry and telemetryConfigRef are mutually exclusive; migrate to telemetryConfigRef" // +kubebuilder:validation:XValidation:rule="!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == 'redis')",message="rateLimiting requires sessionStorage with provider 'redis'" // +kubebuilder:validation:XValidation:rule="!(has(self.rateLimiting) && has(self.rateLimiting.perUser)) || has(self.oidcConfig) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)",message="rateLimiting.perUser requires authentication (oidcConfig, oidcConfigRef, or externalAuthConfigRef)" // +kubebuilder:validation:XValidation:rule="!has(self.rateLimiting) || !has(self.rateLimiting.tools) || self.rateLimiting.tools.all(t, !has(t.perUser)) || has(self.oidcConfig) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)",message="per-tool perUser rate limiting requires authentication (oidcConfig, oidcConfigRef, or externalAuthConfigRef)" @@ -304,17 +303,9 @@ type MCPServerSpec struct { // TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. // The referenced MCPTelemetryConfig must exist in the same namespace as this MCPServer. // Cross-namespace references are not supported for security and isolation reasons. - // Mutually exclusive with the deprecated inline Telemetry field. // +optional TelemetryConfigRef *MCPTelemetryConfigReference `json:"telemetryConfigRef,omitempty"` - // Telemetry defines inline observability configuration for the MCP server. - // Deprecated: Use TelemetryConfigRef to reference a shared MCPTelemetryConfig resource instead. - // This field will be removed in a future release. Setting both telemetry and telemetryConfigRef - // is rejected by CEL validation. - // +optional - Telemetry *TelemetryConfig `json:"telemetry,omitempty"` - // TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies // When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, // and X-Forwarded-Prefix headers to construct endpoint URLs @@ -951,60 +942,6 @@ type AuditConfig struct { Enabled bool `json:"enabled,omitempty"` } -// TelemetryConfig defines observability configuration for the MCP server -type TelemetryConfig struct { - // OpenTelemetry defines OpenTelemetry configuration - // +optional - OpenTelemetry *OpenTelemetryConfig `json:"openTelemetry,omitempty"` - - // Prometheus defines Prometheus-specific configuration - // +optional - Prometheus *PrometheusConfig `json:"prometheus,omitempty"` -} - -// OpenTelemetryConfig defines pure OpenTelemetry configuration -type OpenTelemetryConfig struct { - // Enabled controls whether OpenTelemetry is enabled - // +kubebuilder:default=false - // +optional - Enabled bool `json:"enabled,omitempty"` - - // Endpoint is the OTLP endpoint URL for tracing and metrics - // +optional - Endpoint string `json:"endpoint,omitempty"` - - // ServiceName is the service name for telemetry - // If not specified, defaults to the MCPServer name - // +optional - ServiceName string `json:"serviceName,omitempty"` - - // Headers contains authentication headers for the OTLP endpoint - // Specified as key=value pairs - // +listType=atomic - // +optional - Headers []string `json:"headers,omitempty"` - - // Insecure indicates whether to use HTTP instead of HTTPS for the OTLP endpoint - // +kubebuilder:default=false - // +optional - Insecure bool `json:"insecure,omitempty"` - - // Metrics defines OpenTelemetry metrics-specific configuration - // +optional - Metrics *OpenTelemetryMetricsConfig `json:"metrics,omitempty"` - - // Tracing defines OpenTelemetry tracing configuration - // +optional - Tracing *OpenTelemetryTracingConfig `json:"tracing,omitempty"` - - // UseLegacyAttributes controls whether legacy attribute names are emitted alongside - // the new MCP OTEL semantic convention names. Defaults to true for backward compatibility. - // This will change to false in a future release and eventually be removed. - // +kubebuilder:default=true - // +optional - UseLegacyAttributes bool `json:"useLegacyAttributes"` -} - // PrometheusConfig defines Prometheus-specific configuration type PrometheusConfig struct { // Enabled controls whether Prometheus metrics endpoint is exposed diff --git a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go index 9f1d7ba1cd..778eeb4a3f 100644 --- a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go +++ b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go @@ -15,8 +15,6 @@ import ( // VirtualMCPServerSpec defines the desired state of VirtualMCPServer // -// +kubebuilder:validation:XValidation:rule="!(has(self.config.telemetry) && has(self.telemetryConfigRef))",message="config.telemetry and telemetryConfigRef are mutually exclusive; migrate to telemetryConfigRef" -// //nolint:lll // CEL validation rules exceed line length limit type VirtualMCPServerSpec struct { // IncomingAuth configures authentication for clients connecting to the Virtual MCP server. @@ -72,15 +70,12 @@ type VirtualMCPServerSpec struct { // Config is the Virtual MCP server configuration. // The audit config from here is also supported, but not required. // Note: config.groupRef is deprecated in favor of spec.groupRef. - // Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference - // a shared MCPTelemetryConfig resource instead. // +optional Config config.Config `json:"config,omitempty"` // TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. // The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer. // Cross-namespace references are not supported for security and isolation reasons. - // Mutually exclusive with the deprecated inline config.telemetry field. // +optional TelemetryConfigRef *MCPTelemetryConfigReference `json:"telemetryConfigRef,omitempty"` diff --git a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go index 24019195af..b17dcc4675 100644 --- a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -1323,11 +1323,6 @@ func (in *MCPRemoteProxySpec) DeepCopyInto(out *MCPRemoteProxySpec) { *out = new(MCPTelemetryConfigReference) **out = **in } - if in.Telemetry != nil { - in, out := &in.Telemetry, &out.Telemetry - *out = new(TelemetryConfig) - (*in).DeepCopyInto(*out) - } out.Resources = in.Resources if in.ServiceAccount != nil { in, out := &in.ServiceAccount, &out.ServiceAccount @@ -1637,11 +1632,6 @@ func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { *out = new(MCPTelemetryConfigReference) **out = **in } - if in.Telemetry != nil { - in, out := &in.Telemetry, &out.Telemetry - *out = new(TelemetryConfig) - (*in).DeepCopyInto(*out) - } if in.GroupRef != nil { in, out := &in.GroupRef, &out.GroupRef *out = new(MCPGroupRef) @@ -2124,36 +2114,6 @@ func (in *OIDCUpstreamConfig) DeepCopy() *OIDCUpstreamConfig { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *OpenTelemetryConfig) DeepCopyInto(out *OpenTelemetryConfig) { - *out = *in - if in.Headers != nil { - in, out := &in.Headers, &out.Headers - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Metrics != nil { - in, out := &in.Metrics, &out.Metrics - *out = new(OpenTelemetryMetricsConfig) - **out = **in - } - if in.Tracing != nil { - in, out := &in.Tracing, &out.Tracing - *out = new(OpenTelemetryTracingConfig) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenTelemetryConfig. -func (in *OpenTelemetryConfig) DeepCopy() *OpenTelemetryConfig { - if in == nil { - return nil - } - out := new(OpenTelemetryConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OpenTelemetryMetricsConfig) DeepCopyInto(out *OpenTelemetryMetricsConfig) { *out = *in @@ -2667,31 +2627,6 @@ func (in *SessionStorageConfig) DeepCopy() *SessionStorageConfig { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TelemetryConfig) DeepCopyInto(out *TelemetryConfig) { - *out = *in - if in.OpenTelemetry != nil { - in, out := &in.OpenTelemetry, &out.OpenTelemetry - *out = new(OpenTelemetryConfig) - (*in).DeepCopyInto(*out) - } - if in.Prometheus != nil { - in, out := &in.Prometheus, &out.Prometheus - *out = new(PrometheusConfig) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TelemetryConfig. -func (in *TelemetryConfig) DeepCopy() *TelemetryConfig { - if in == nil { - return nil - } - out := new(TelemetryConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TokenExchangeConfig) DeepCopyInto(out *TokenExchangeConfig) { *out = *in diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go b/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go index bca8face85..05a67b3942 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go @@ -173,12 +173,6 @@ func (r *MCPRemoteProxyReconciler) buildEnvVarsForProxy( ) []corev1.EnvVar { env := []corev1.EnvVar{} - // Add OpenTelemetry environment variables - if proxy.Spec.Telemetry != nil && proxy.Spec.Telemetry.OpenTelemetry != nil { - otelEnvVars := ctrlutil.GenerateOpenTelemetryEnvVars(proxy.Spec.Telemetry, proxy.Name, proxy.Namespace) - env = append(env, otelEnvVars...) - } - // Add token exchange environment variables // Note: Embedded auth server env vars are added separately in deploymentForMCPRemoteProxy // to avoid duplicate API calls. diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go index f4105195c6..7846db6594 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go @@ -736,36 +736,6 @@ func TestBuildEnvVarsForProxy(t *testing.T) { assert.True(t, found, "TOOLHIVE_RUNTIME should be set") }, }, - { - name: "with telemetry", - proxy: &mcpv1alpha1.MCPRemoteProxy{ - ObjectMeta: metav1.ObjectMeta{ - Name: "telemetry-proxy", - Namespace: "default", - }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - RemoteURL: "https://mcp.example.com", - Telemetry: &mcpv1alpha1.TelemetryConfig{ - OpenTelemetry: &mcpv1alpha1.OpenTelemetryConfig{ - Enabled: true, - ServiceName: "my-proxy", - }, - }, - }, - }, - validate: func(t *testing.T, envVars []corev1.EnvVar) { - t.Helper() - found := false - for _, env := range envVars { - if env.Name == "OTEL_RESOURCE_ATTRIBUTES" { - assert.Contains(t, env.Value, "service.name=my-proxy") - found = true - break - } - } - assert.True(t, found, "OTEL_RESOURCE_ATTRIBUTES should be set") - }, - }, { name: "with token exchange", proxy: &mcpv1alpha1.MCPRemoteProxy{ diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go index 2244704240..875b7c5355 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go @@ -158,12 +158,6 @@ func TestMCPRemoteProxyFullReconciliation(t *testing.T) { Audit: &mcpv1alpha1.AuditConfig{ Enabled: true, }, - Telemetry: &mcpv1alpha1.TelemetryConfig{ - OpenTelemetry: &mcpv1alpha1.OpenTelemetryConfig{ - Enabled: true, - ServiceName: "full-proxy", - }, - }, }, }, toolConfig: &mcpv1alpha1.MCPToolConfig{ @@ -553,23 +547,6 @@ func TestCommonHelpers(t *testing.T) { assert.Equal(t, "test-auth", result.Name) }) - t.Run("GenerateOpenTelemetryEnvVars", func(t *testing.T) { - t.Parallel() - - telemetryConfig := &mcpv1alpha1.TelemetryConfig{ - OpenTelemetry: &mcpv1alpha1.OpenTelemetryConfig{ - Enabled: true, - ServiceName: "test-service", - }, - } - - envVars := ctrlutil.GenerateOpenTelemetryEnvVars(telemetryConfig, "test-resource", "test-ns") - require.Len(t, envVars, 1) - assert.Equal(t, "OTEL_RESOURCE_ATTRIBUTES", envVars[0].Name) - assert.Contains(t, envVars[0].Value, "service.name=test-service") - assert.Contains(t, envVars[0].Value, "service.namespace=test-ns") - }) - t.Run("GenerateAuthzVolumeConfig - ConfigMap", func(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go b/cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go index 34cd5f2fb5..9a02dec2e4 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go @@ -109,7 +109,7 @@ func (r *MCPRemoteProxyReconciler) createRunConfigFromMCPRemoteProxy( options = append(options, runner.WithToolsOverride(toolsOverride)) } - // Add telemetry configuration: prefer TelemetryConfigRef over deprecated inline Telemetry + // Add telemetry configuration from TelemetryConfigRef if err := r.addTelemetryOptions(ctx, proxy, &options); err != nil { return nil, err } @@ -337,7 +337,6 @@ func (r *MCPRemoteProxyReconciler) resolveToolConfig( } // addTelemetryOptions resolves telemetry configuration for the RunConfig. -// Prefers TelemetryConfigRef over the deprecated inline Telemetry field. func (r *MCPRemoteProxyReconciler) addTelemetryOptions( ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy, @@ -353,8 +352,6 @@ func (r *MCPRemoteProxyReconciler) addTelemetryOptions( svcName := proxy.Spec.TelemetryConfigRef.ServiceName runconfig.AddMCPTelemetryConfigRefOptions(options, &telCfg.Spec, svcName, proxy.Name, caPath) } - return nil } - runconfig.AddTelemetryConfigOptions(ctx, options, proxy.Spec.Telemetry, proxy.Name) return nil } diff --git a/cmd/thv-operator/controllers/mcpserver_controller.go b/cmd/thv-operator/controllers/mcpserver_controller.go index 518b0c07c4..f69b8e226c 100644 --- a/cmd/thv-operator/controllers/mcpserver_controller.go +++ b/cmd/thv-operator/controllers/mcpserver_controller.go @@ -1034,9 +1034,6 @@ func (r *MCPServerReconciler) deploymentForMCPServer( } else if telCfg != nil { env = append(env, ctrlutil.GenerateOpenTelemetryEnvVarsFromRef(telCfg, m.Spec.TelemetryConfigRef, m.Name, m.Namespace)...) } - } else if m.Spec.Telemetry != nil && m.Spec.Telemetry.OpenTelemetry != nil { - otelEnvVars := ctrlutil.GenerateOpenTelemetryEnvVars(m.Spec.Telemetry, m.Name, m.Namespace) - env = append(env, otelEnvVars...) } // Add token exchange environment variables @@ -1645,9 +1642,6 @@ func (r *MCPServerReconciler) deploymentNeedsUpdate( ) expectedProxyEnv = append(expectedProxyEnv, otelEnvVars...) } - } else if mcpServer.Spec.Telemetry != nil && mcpServer.Spec.Telemetry.OpenTelemetry != nil { - otelEnvVars := ctrlutil.GenerateOpenTelemetryEnvVars(mcpServer.Spec.Telemetry, mcpServer.Name, mcpServer.Namespace) - expectedProxyEnv = append(expectedProxyEnv, otelEnvVars...) } // Add token exchange environment variables diff --git a/cmd/thv-operator/controllers/mcpserver_opentelemetry_test.go b/cmd/thv-operator/controllers/mcpserver_opentelemetry_test.go deleted file mode 100644 index 55ba3ce76f..0000000000 --- a/cmd/thv-operator/controllers/mcpserver_opentelemetry_test.go +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright 2025 Stacklok, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -package controllers - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" - ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" -) - -func TestOpenTelemetryEnvVars(t *testing.T) { - t.Parallel() - - scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) - - tests := []struct { - name string - otelConfig *mcpv1alpha1.OpenTelemetryConfig - expectedEnv []corev1.EnvVar - }{ - { - name: "nil OpenTelemetry config", - otelConfig: nil, - expectedEnv: nil, - }, - { - name: "basic OpenTelemetry config with service name", - otelConfig: &mcpv1alpha1.OpenTelemetryConfig{ - ServiceName: "custom-service", - }, - expectedEnv: []corev1.EnvVar{ - {Name: "OTEL_RESOURCE_ATTRIBUTES", Value: "service.name=custom-service,service.namespace=default"}, - }, - }, - { - name: "OpenTelemetry with default service name", - otelConfig: &mcpv1alpha1.OpenTelemetryConfig{}, - expectedEnv: []corev1.EnvVar{ - {Name: "OTEL_RESOURCE_ATTRIBUTES", Value: "service.name=test-server,service.namespace=default"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - mcpServer := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "default", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Telemetry: func() *mcpv1alpha1.TelemetryConfig { - if tt.otelConfig == nil { - return nil - } - return &mcpv1alpha1.TelemetryConfig{ - OpenTelemetry: tt.otelConfig, - } - }(), - }, - } - - envVars := ctrlutil.GenerateOpenTelemetryEnvVars(mcpServer.Spec.Telemetry, mcpServer.Name, mcpServer.Namespace) - assert.Equal(t, tt.expectedEnv, envVars) - }) - } -} - -func TestServiceNameDefaulting(t *testing.T) { - t.Parallel() - - scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) - - tests := []struct { - name string - serverName string - serverNamespace string - providedServiceName string - expectedServiceName string - }{ - { - name: "default service name", - serverName: "my-mcp-server", - serverNamespace: "production", - providedServiceName: "", - expectedServiceName: "my-mcp-server", - }, - { - name: "custom service name", - serverName: "my-mcp-server", - serverNamespace: "production", - providedServiceName: "custom-service", - expectedServiceName: "custom-service", - }, - { - name: "namespace included in resource attributes", - serverName: "test-server", - serverNamespace: "test-namespace", - providedServiceName: "test-service", - expectedServiceName: "test-service", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - mcpServer := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: tt.serverName, - Namespace: tt.serverNamespace, - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Telemetry: &mcpv1alpha1.TelemetryConfig{ - OpenTelemetry: &mcpv1alpha1.OpenTelemetryConfig{ - ServiceName: tt.providedServiceName, - Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{ - Enabled: true, - }, - }, - }, - }, - } - - envVars := ctrlutil.GenerateOpenTelemetryEnvVars(mcpServer.Spec.Telemetry, mcpServer.Name, mcpServer.Namespace) - - // Check OTEL_RESOURCE_ATTRIBUTES contains correct service name and namespace - var resourceAttrs string - for _, env := range envVars { - if env.Name == "OTEL_RESOURCE_ATTRIBUTES" { - resourceAttrs = env.Value - break - } - } - - expectedResourceAttrs := "service.name=" + tt.expectedServiceName + ",service.namespace=" + tt.serverNamespace - assert.Equal(t, expectedResourceAttrs, resourceAttrs, "Resource attributes should contain correct service name and namespace") - }) - } -} diff --git a/cmd/thv-operator/controllers/mcpserver_runconfig.go b/cmd/thv-operator/controllers/mcpserver_runconfig.go index 92c1f98d1b..01a8bb40ef 100644 --- a/cmd/thv-operator/controllers/mcpserver_runconfig.go +++ b/cmd/thv-operator/controllers/mcpserver_runconfig.go @@ -185,7 +185,7 @@ func (r *MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1alpha1.MCPSer ctx, cancel := context.WithTimeout(context.Background(), defaultAPITimeout) defer cancel() - // Add telemetry configuration: prefer TelemetryConfigRef over deprecated inline Telemetry + // Add telemetry configuration from TelemetryConfigRef if m.Spec.TelemetryConfigRef != nil { telCfg, err := getTelemetryConfigForMCPServer(ctx, r.Client, m) if err != nil { @@ -195,8 +195,6 @@ func (r *MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1alpha1.MCPSer caPath := ctrlutil.TelemetryCABundleFilePath(telCfg) runconfig.AddMCPTelemetryConfigRefOptions(&options, &telCfg.Spec, m.Spec.TelemetryConfigRef.ServiceName, m.Name, caPath) } - } else { - runconfig.AddTelemetryConfigOptions(ctx, &options, m.Spec.Telemetry, m.Name) } // Add authorization configuration if specified diff --git a/cmd/thv-operator/pkg/controllerutil/tokenexchange.go b/cmd/thv-operator/pkg/controllerutil/tokenexchange.go index e3b4f3b1cf..d0f715f132 100644 --- a/cmd/thv-operator/pkg/controllerutil/tokenexchange.go +++ b/cmd/thv-operator/pkg/controllerutil/tokenexchange.go @@ -19,33 +19,6 @@ import ( "github.com/stacklok/toolhive/pkg/runner" ) -// GenerateOpenTelemetryEnvVars generates OpenTelemetry environment variables -func GenerateOpenTelemetryEnvVars( - telemetryConfig *mcpv1alpha1.TelemetryConfig, - resourceName string, - namespace string, -) []corev1.EnvVar { - var envVars []corev1.EnvVar - - if telemetryConfig == nil || telemetryConfig.OpenTelemetry == nil { - return envVars - } - - otel := telemetryConfig.OpenTelemetry - - serviceName := otel.ServiceName - if serviceName == "" { - serviceName = resourceName - } - - envVars = append(envVars, corev1.EnvVar{ - Name: "OTEL_RESOURCE_ATTRIBUTES", - Value: fmt.Sprintf("service.name=%s,service.namespace=%s", serviceName, namespace), - }) - - return envVars -} - // GenerateTokenExchangeEnvVars generates environment variables for token exchange func GenerateTokenExchangeEnvVars( ctx context.Context, diff --git a/cmd/thv-operator/pkg/runconfig/telemetry.go b/cmd/thv-operator/pkg/runconfig/telemetry.go index 0d748464d2..59ac283e1e 100644 --- a/cmd/thv-operator/pkg/runconfig/telemetry.go +++ b/cmd/thv-operator/pkg/runconfig/telemetry.go @@ -5,31 +5,11 @@ package runconfig import ( - "context" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/spectoconfig" "github.com/stacklok/toolhive/pkg/runner" ) -// AddTelemetryConfigOptions adds telemetry configuration options to the builder options. -// Deprecated: Use AddMCPTelemetryConfigRefOptions for MCPTelemetryConfig-based configuration. -func AddTelemetryConfigOptions( - ctx context.Context, - options *[]runner.RunConfigBuilderOption, - telemetryConfig *mcpv1alpha1.TelemetryConfig, - mcpServerName string, -) { - if telemetryConfig == nil || options == nil { - return - } - - config := spectoconfig.ConvertTelemetryConfig(ctx, telemetryConfig, mcpServerName) - - // Add telemetry config to options - *options = append(*options, runner.WithTelemetryConfig(config)) -} - // AddMCPTelemetryConfigRefOptions converts an MCPTelemetryConfig spec with per-server overrides // into a runner option. This is the preferred path for MCPServer.Spec.TelemetryConfigRef. // caBundleFilePath is the computed mount path for the CA bundle (empty if none configured). diff --git a/cmd/thv-operator/pkg/runconfig/telemetry_test.go b/cmd/thv-operator/pkg/runconfig/telemetry_test.go index c121538ef5..44faca6a9f 100644 --- a/cmd/thv-operator/pkg/runconfig/telemetry_test.go +++ b/cmd/thv-operator/pkg/runconfig/telemetry_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" "github.com/stacklok/toolhive/pkg/runner" @@ -20,231 +19,6 @@ const ( stdioTransport = "stdio" ) -// TestAddTelemetryConfigOptions tests the addition of telemetry configuration options to the RunConfigBuilder -func TestAddTelemetryConfigOptions(t *testing.T) { - t.Parallel() - tests := []struct { - name string - mcpServer *mcpv1alpha1.MCPServer - expected func(t *testing.T, config *runner.RunConfig) - }{ - { - name: "with empty telemetry configuration", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "disabled-telemetry-server", - Namespace: "test-ns", - }, - }, - //nolint:thelper // We want to see the error at the specific line - expected: func(t *testing.T, config *runner.RunConfig) { - assert.Nil(t, config.TelemetryConfig) - }, - }, - { - name: "with telemetry configuration", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "telemetry-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: testImage, - Transport: stdioTransport, - ProxyPort: 8080, - Telemetry: &mcpv1alpha1.TelemetryConfig{ - OpenTelemetry: &mcpv1alpha1.OpenTelemetryConfig{ - Enabled: true, - Endpoint: "http://otel-collector:4317", - ServiceName: "custom-service-name", - Insecure: true, - Headers: []string{"Authorization=Bearer token123", "X-API-Key=abc"}, - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{ - Enabled: true, - SamplingRate: "0.25", - }, - Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{ - Enabled: true, - }, - }, - Prometheus: &mcpv1alpha1.PrometheusConfig{ - Enabled: true, - }, - }, - }, - }, - //nolint:thelper // We want to see the error at the specific line - expected: func(t *testing.T, config *runner.RunConfig) { - assert.Equal(t, "telemetry-server", config.Name) - - // Verify telemetry config is set - assert.NotNil(t, config.TelemetryConfig) - - // Check OpenTelemetry settings (endpoint should have http:// prefix stripped) - assert.Equal(t, "otel-collector:4317", config.TelemetryConfig.Endpoint) - assert.Equal(t, "custom-service-name", config.TelemetryConfig.ServiceName) - assert.True(t, config.TelemetryConfig.Insecure) - assert.True(t, config.TelemetryConfig.TracingEnabled) - assert.True(t, config.TelemetryConfig.MetricsEnabled) - assert.Equal(t, "0.25", config.TelemetryConfig.SamplingRate) - assert.Equal(t, map[string]string{"Authorization": "Bearer token123", "X-API-Key": "abc"}, config.TelemetryConfig.Headers) - - // Check Prometheus settings - assert.True(t, config.TelemetryConfig.EnablePrometheusMetricsPath) - }, - }, - { - name: "with minimal telemetry configuration", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "minimal-telemetry-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: testImage, - Transport: stdioTransport, - ProxyPort: 8080, - Telemetry: &mcpv1alpha1.TelemetryConfig{ - OpenTelemetry: &mcpv1alpha1.OpenTelemetryConfig{ - Enabled: true, - Endpoint: "https://secure-otel:4318", - // ServiceName not specified - should default to MCPServer name - }, - }, - }, - }, - //nolint:thelper // We want to see the error at the specific line - expected: func(t *testing.T, config *runner.RunConfig) { - assert.Equal(t, "minimal-telemetry-server", config.Name) - - // Verify telemetry config is set - assert.NotNil(t, config.TelemetryConfig) - - // Check that service name defaults to MCPServer name - assert.Equal(t, "minimal-telemetry-server", config.TelemetryConfig.ServiceName) - assert.Equal(t, "secure-otel:4318", config.TelemetryConfig.Endpoint) - assert.False(t, config.TelemetryConfig.Insecure) // Default should be false - assert.Equal(t, "0.05", config.TelemetryConfig.SamplingRate) // Default sampling rate - }, - }, - { - name: "with prometheus only telemetry", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "prometheus-only-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: testImage, - Transport: stdioTransport, - ProxyPort: 8080, - Telemetry: &mcpv1alpha1.TelemetryConfig{ - Prometheus: &mcpv1alpha1.PrometheusConfig{ - Enabled: true, - }, - }, - }, - }, - //nolint:thelper // We want to see the error at the specific line - expected: func(t *testing.T, config *runner.RunConfig) { - assert.Equal(t, "prometheus-only-server", config.Name) - - // Verify telemetry config is set - assert.NotNil(t, config.TelemetryConfig) - - // Only Prometheus should be enabled - assert.True(t, config.TelemetryConfig.EnablePrometheusMetricsPath) - assert.False(t, config.TelemetryConfig.TracingEnabled) - assert.False(t, config.TelemetryConfig.MetricsEnabled) - assert.Equal(t, "", config.TelemetryConfig.Endpoint) - }, - }, - { - name: "with invalid sampling rate - uses default", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "invalid-sampling-rate-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: testImage, - Transport: stdioTransport, - ProxyPort: 8080, - Telemetry: &mcpv1alpha1.TelemetryConfig{ - OpenTelemetry: &mcpv1alpha1.OpenTelemetryConfig{ - Enabled: true, - Endpoint: "otel-collector:4317", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{ - Enabled: true, - SamplingRate: "invalid-rate", // Invalid value - }, - }, - }, - }, - }, - //nolint:thelper // We want to see the error at the specific line - expected: func(t *testing.T, config *runner.RunConfig) { - assert.Equal(t, "invalid-sampling-rate-server", config.Name) - - // Verify telemetry config is set - assert.NotNil(t, config.TelemetryConfig) - - // Check that invalid sampling rate defaults to 0.05 - assert.Equal(t, "0.05", config.TelemetryConfig.SamplingRate) - assert.True(t, config.TelemetryConfig.TracingEnabled) - assert.Equal(t, "otel-collector:4317", config.TelemetryConfig.Endpoint) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - options := []runner.RunConfigBuilderOption{ - runner.WithName(tt.mcpServer.Name), - runner.WithImage(tt.mcpServer.Spec.Image), - } - ctx := context.Background() - AddTelemetryConfigOptions(ctx, &options, tt.mcpServer.Spec.Telemetry, tt.mcpServer.Name) - - rc, err := runner.NewOperatorRunConfigBuilder(context.Background(), nil, nil, nil, options...) - assert.NoError(t, err) - - tt.expected(t, rc) - }) - } -} - -// TestAddTelemetryConfigOptions_NilOptions tests that the function handles nil options gracefully -func TestAddTelemetryConfigOptions_NilOptions(t *testing.T) { - t.Parallel() - - ctx := context.Background() - telemetryConfig := &mcpv1alpha1.TelemetryConfig{ - OpenTelemetry: &mcpv1alpha1.OpenTelemetryConfig{ - Enabled: true, - Endpoint: "otel-collector:4317", - ServiceName: "test-service", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{ - Enabled: true, - SamplingRate: "0.1", - }, - Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{ - Enabled: true, - }, - }, - Prometheus: &mcpv1alpha1.PrometheusConfig{ - Enabled: true, - }, - } - - // Test with nil options pointer - should not panic - assert.NotPanics(t, func() { - AddTelemetryConfigOptions(ctx, nil, telemetryConfig, "test-server") - }, "AddTelemetryConfigOptions should not panic with nil options") -} - func TestAddMCPTelemetryConfigRefOptions(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/pkg/spectoconfig/telemetry.go b/cmd/thv-operator/pkg/spectoconfig/telemetry.go index 59425d869e..e5971598c8 100644 --- a/cmd/thv-operator/pkg/spectoconfig/telemetry.go +++ b/cmd/thv-operator/pkg/spectoconfig/telemetry.go @@ -5,102 +5,13 @@ package spectoconfig import ( - "context" "strconv" "strings" - "sigs.k8s.io/controller-runtime/pkg/log" - "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" "github.com/stacklok/toolhive/pkg/telemetry" ) -// ConvertTelemetryConfig converts the CRD TelemetryConfig to a telemetry.Config. -// It may return nil if no telemetry is configured. -func ConvertTelemetryConfig( - ctx context.Context, - telemetryConfig *v1alpha1.TelemetryConfig, - mcpServerName string, -) *telemetry.Config { - if telemetryConfig == nil { - return nil - } - - // Default values - // Note: if defaults here are also duplicated on the config struct's annotations. - var otelEndpoint string - var otelEnablePrometheusMetricsPath bool - var otelTracingEnabled bool - var otelMetricsEnabled bool - var otelServiceName = mcpServerName // Default to mcpServerName, may be overridden below - var otelSamplingRate = 0.05 // Default sampling rate - var otelHeaders []string - var otelInsecure bool - var otelEnvironmentVariables []string - otelUseLegacyAttributes := true // Default to true for backward compatibility - - // Process OpenTelemetry configuration - if telemetryConfig.OpenTelemetry != nil && telemetryConfig.OpenTelemetry.Enabled { - otel := telemetryConfig.OpenTelemetry - - // Note: Endpoint normalization (prefix stripping) and ServiceVersion defaulting - // are handled by NormalizeTelemetryConfig below - otelEndpoint = otel.Endpoint - otelInsecure = otel.Insecure - otelHeaders = otel.Headers - - // Override default service name if explicitly specified in OTLP config - if otel.ServiceName != "" { - otelServiceName = otel.ServiceName - } - - // Handle tracing configuration - if otel.Tracing != nil { - otelTracingEnabled = otel.Tracing.Enabled - if otel.Tracing.SamplingRate != "" { - // Parse sampling rate string to float64 - if rate, err := strconv.ParseFloat(otel.Tracing.SamplingRate, 64); err == nil { - otelSamplingRate = clampSamplingRate(rate) - } else { - logger := log.FromContext(ctx) - logger.Error(err, "Failed to parse sampling rate, using default", - "samplingRate", otel.Tracing.SamplingRate, - "default", otelSamplingRate, - "mcpServer", mcpServerName) - } - } - } - - // Handle metrics configuration - if otel.Metrics != nil { - otelMetricsEnabled = otel.Metrics.Enabled - } - - otelUseLegacyAttributes = otel.UseLegacyAttributes - } - - // Process Prometheus configuration - if telemetryConfig.Prometheus != nil { - otelEnablePrometheusMetricsPath = telemetryConfig.Prometheus.Enabled - } - - config := telemetry.MaybeMakeConfig( - otelEndpoint, - otelEnablePrometheusMetricsPath, - otelTracingEnabled, - otelMetricsEnabled, - otelServiceName, - otelSamplingRate, - otelHeaders, - otelInsecure, - otelEnvironmentVariables, - otelUseLegacyAttributes, - ) - - // Apply normalization (endpoint prefix stripping, ServiceName/ServiceVersion defaults) - return NormalizeTelemetryConfig(config, mcpServerName) -} - // NormalizeMCPTelemetryConfig converts an MCPTelemetryConfigSpec to a normalized telemetry.Config. // It maps the nested CRD structure (openTelemetry/prometheus) to a flat telemetry.Config, // applies the per-server ServiceName override from the reference, then delegates to @@ -117,7 +28,7 @@ func NormalizeMCPTelemetryConfig( config := &telemetry.Config{} // Map nested OpenTelemetry fields to flat telemetry.Config. - // Only configure OTLP when Enabled is true, matching ConvertTelemetryConfig behavior. + // Only configure OTLP when Enabled is true. if spec.OpenTelemetry != nil && spec.OpenTelemetry.Enabled { otel := spec.OpenTelemetry config.Endpoint = otel.Endpoint @@ -162,7 +73,7 @@ func NormalizeMCPTelemetryConfig( // avoiding stale versions persisted in configs. See #2296. // // This function is used by both the VirtualMCPServer converter (for spec.config.telemetry) -// and indirectly by ConvertTelemetryConfig (for CRD-style configs). +// and indirectly by NormalizeMCPTelemetryConfig (for MCPTelemetryConfig-based configs). func NormalizeTelemetryConfig(config *telemetry.Config, defaultServiceName string) *telemetry.Config { if config == nil { return nil diff --git a/cmd/thv-operator/pkg/spectoconfig/telemetry_test.go b/cmd/thv-operator/pkg/spectoconfig/telemetry_test.go index b7e29369dd..cd6d908efb 100644 --- a/cmd/thv-operator/pkg/spectoconfig/telemetry_test.go +++ b/cmd/thv-operator/pkg/spectoconfig/telemetry_test.go @@ -4,7 +4,6 @@ package spectoconfig import ( - "context" "testing" "github.com/stretchr/testify/assert" @@ -348,171 +347,6 @@ func TestNormalizeMCPTelemetryConfig_DoesNotModifyInput(t *testing.T) { assert.Equal(t, "override-name", result.ServiceName) } -func TestConvertTelemetryConfig_UsesNormalization(t *testing.T) { - t.Parallel() - - // This test verifies that ConvertTelemetryConfig uses NormalizeTelemetryConfig - // to apply endpoint prefix stripping and service name defaults. - // ServiceVersion is intentionally left empty — it is resolved at runtime - // in telemetry.NewProvider() to always reflect the running binary version. - tests := []struct { - name string - input *v1alpha1.TelemetryConfig - serverName string - expected *telemetry.Config - }{ - { - name: "applies endpoint normalization and service defaults", - input: &v1alpha1.TelemetryConfig{ - OpenTelemetry: &v1alpha1.OpenTelemetryConfig{ - Enabled: true, - Endpoint: "https://otlp-collector:4317", - ServiceName: "", // Empty - should default to serverName - Tracing: &v1alpha1.OpenTelemetryTracingConfig{ - Enabled: true, - SamplingRate: "0.1", - }, - }, - }, - serverName: "my-mcp-server", - expected: &telemetry.Config{ - Endpoint: "otlp-collector:4317", // Prefix stripped - ServiceName: "my-mcp-server", // Defaulted - TracingEnabled: true, - SamplingRate: "0.1", - }, - }, - { - name: "preserves explicit service name and version", - input: &v1alpha1.TelemetryConfig{ - OpenTelemetry: &v1alpha1.OpenTelemetryConfig{ - Enabled: true, - Endpoint: "http://localhost:4317", - ServiceName: "custom-service", - }, - }, - serverName: "default-server", - expected: &telemetry.Config{ - Endpoint: "localhost:4317", // Prefix stripped - ServiceName: "custom-service", // Preserved - }, - }, - { - name: "handles prometheus-only config", - input: &v1alpha1.TelemetryConfig{ - Prometheus: &v1alpha1.PrometheusConfig{ - Enabled: true, - }, - }, - serverName: "prom-server", - expected: &telemetry.Config{ - EnablePrometheusMetricsPath: true, - ServiceName: "prom-server", // Defaulted - UseLegacyAttributes: true, // Default when OTEL block absent - }, - }, - { - name: "reads UseLegacyAttributes from CR spec", - input: &v1alpha1.TelemetryConfig{ - OpenTelemetry: &v1alpha1.OpenTelemetryConfig{ - Enabled: true, - Endpoint: "https://otlp:4317", - UseLegacyAttributes: false, - Tracing: &v1alpha1.OpenTelemetryTracingConfig{ - Enabled: true, - }, - }, - }, - serverName: "legacy-test", - expected: &telemetry.Config{ - Endpoint: "otlp:4317", - ServiceName: "legacy-test", - TracingEnabled: true, - UseLegacyAttributes: false, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - result := ConvertTelemetryConfig(ctx, tt.input, tt.serverName) - - require.NotNil(t, result) - assert.Equal(t, tt.expected.Endpoint, result.Endpoint) - assert.Equal(t, tt.expected.ServiceName, result.ServiceName) - assert.Equal(t, tt.expected.ServiceVersion, result.ServiceVersion) - assert.Equal(t, tt.expected.TracingEnabled, result.TracingEnabled) - assert.Equal(t, tt.expected.EnablePrometheusMetricsPath, result.EnablePrometheusMetricsPath) - assert.Equal(t, tt.expected.UseLegacyAttributes, result.UseLegacyAttributes) - }) - } -} - -func TestConvertTelemetryConfig_ClampsSamplingRate(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - samplingRate string - expected string - }{ - { - name: "value above 1.0 is clamped to 1", - samplingRate: "42", - expected: "1", - }, - { - name: "negative value is clamped to 0", - samplingRate: "-1", - expected: "0", - }, - { - name: "value of 1.5 is clamped to 1", - samplingRate: "1.5", - expected: "1", - }, - { - name: "valid value 0.5 is preserved", - samplingRate: "0.5", - expected: "0.5", - }, - { - name: "boundary value 0 is preserved", - samplingRate: "0", - expected: "0", - }, - { - name: "boundary value 1 is preserved", - samplingRate: "1", - expected: "1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - input := &v1alpha1.TelemetryConfig{ - OpenTelemetry: &v1alpha1.OpenTelemetryConfig{ - Enabled: true, - Endpoint: "otel-collector:4317", - Tracing: &v1alpha1.OpenTelemetryTracingConfig{ - Enabled: true, - SamplingRate: tt.samplingRate, - }, - }, - } - result := ConvertTelemetryConfig(ctx, input, "test-server") - require.NotNil(t, result) - assert.Equal(t, tt.expected, result.SamplingRate) - }) - } -} - func TestNormalizeMCPTelemetryConfig_ClampsSamplingRate(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter.go b/cmd/thv-operator/pkg/vmcpconfig/converter.go index 812f78abd3..3f08459de5 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go @@ -124,7 +124,7 @@ func (c *Converter) Convert( config.Operational = vmcp.Spec.Config.Operational // Normalize telemetry config: prefer TelemetryConfigRef (shared MCPTelemetryConfig resource), - // fall back to inline config.telemetry. These are mutually exclusive (enforced by CEL validation). + // The inline config.telemetry field is no longer read by the operator. normalizedTelemetry := c.normalizeTelemetry(ctx, vmcp, telemetryCfg) config.Telemetry = normalizedTelemetry @@ -327,12 +327,12 @@ func mapResolvedOIDCToVmcpConfigFromRef( return config } -// normalizeTelemetry resolves and normalizes the telemetry config from either -// a pre-fetched MCPTelemetryConfig or the inline config.telemetry field. -// telemetryCfg is the already-validated MCPTelemetryConfig passed in by the controller -// (nil when TelemetryConfigRef is not set or the resource was not found). +// normalizeTelemetry resolves and normalizes the telemetry config from a +// pre-fetched MCPTelemetryConfig. Returns nil when TelemetryConfigRef is not set. +// The Config.Telemetry field is still valid for standalone CLI deployments but is +// no longer read by the operator — use TelemetryConfigRef instead. func (*Converter) normalizeTelemetry( - ctx context.Context, + _ context.Context, vmcp *mcpv1alpha1.VirtualMCPServer, telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, ) *telemetry.Config { @@ -340,15 +340,7 @@ func (*Converter) normalizeTelemetry( return spectoconfig.NormalizeMCPTelemetryConfig( &telemetryCfg.Spec, vmcp.Spec.TelemetryConfigRef.ServiceName, vmcp.Name) } - // Deprecated inline path: config.telemetry is deprecated in favor of spec.telemetryConfigRef. - // Log at debug level to avoid log flooding on every reconcile. - if vmcp.Spec.Config.Telemetry != nil { - log.FromContext(ctx).V(1).Info( - "config.telemetry is deprecated; migrate to spec.telemetryConfigRef", - "vmcp", vmcp.Name, - ) - } - return spectoconfig.NormalizeTelemetryConfig(vmcp.Spec.Config.Telemetry, vmcp.Name) + return nil } // convertSessionStorage populates SessionStorage from the VirtualMCPServer spec. diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go index 83d4d7e6d9..cb07eed0c7 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go @@ -1427,32 +1427,11 @@ func TestConvert_MCPToolConfigFailClosed(t *testing.T) { } } -// TestConverter_TelemetryConfigPreserved tests that all telemetry config fields are preserved during conversion. -// This is a regression test for https://github.com/stacklok/toolhive/issues/XXX where -// CustomAttributes, ServiceVersion, and EnvironmentVariables were being dropped. -func TestConverter_TelemetryConfigPreserved(t *testing.T) { +// TestConverter_InlineTelemetryIgnored verifies that the operator-side converter +// ignores Config.Telemetry (the standalone CLI field) and only uses TelemetryConfigRef. +func TestConverter_InlineTelemetryIgnored(t *testing.T) { t.Parallel() - inputTelemetry := &telemetry.Config{ - Endpoint: "otlp-collector:4317", - EnablePrometheusMetricsPath: true, - ServiceName: "custom-service-name", - ServiceVersion: "v1.2.3", - TracingEnabled: true, - MetricsEnabled: true, - SamplingRate: "0.1", - CustomAttributes: map[string]string{ - "environment": "production", - "region": "us-west-2", - "cluster_name": "main-cluster", - }, - EnvironmentVariables: []string{"PATH", "HOME", "USER"}, - Headers: map[string]string{ - "Authorization": "Bearer token123", - }, - Insecure: true, - } - vmcp := &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", @@ -1463,8 +1442,11 @@ func TestConverter_TelemetryConfigPreserved(t *testing.T) { Type: "anonymous", }, Config: vmcpconfig.Config{ - Group: "test-group", - Telemetry: inputTelemetry, + Group: "test-group", + Telemetry: &telemetry.Config{ + Endpoint: "otlp-collector:4317", + ServiceName: "should-be-ignored", + }, }, }, } @@ -1475,113 +1457,7 @@ func TestConverter_TelemetryConfigPreserved(t *testing.T) { config, _, err := converter.Convert(ctx, vmcp, nil) require.NoError(t, err) require.NotNil(t, config) - - // Verify telemetry config is preserved exactly (all fields) - require.Equal(t, inputTelemetry, config.Telemetry, "All telemetry config fields should be preserved") -} - -// TestConverter_TelemetryDefaults tests the default behavior for telemetry config. -// This documents the behavior that was previously enforced by spectoconfig.ConvertTelemetryConfig: -// - ServiceName defaults to VirtualMCPServer name when not specified -// - ServiceVersion defaults to ServiceName when not specified -func TestConverter_TelemetryDefaults(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - inputTelemetry *telemetry.Config - expectedTelemetry *telemetry.Config - }{ - { - name: "defaults ServiceName to vmcp name, ServiceVersion left for runtime", - inputTelemetry: &telemetry.Config{ - Endpoint: "localhost:4317", - EnablePrometheusMetricsPath: true, - ServiceName: "", - ServiceVersion: "", - }, - expectedTelemetry: &telemetry.Config{ - Endpoint: "localhost:4317", - EnablePrometheusMetricsPath: true, - ServiceName: "my-vmcp-server", - }, - }, - { - name: "ServiceVersion left for runtime when ServiceName is specified", - inputTelemetry: &telemetry.Config{ - Endpoint: "localhost:4317", - EnablePrometheusMetricsPath: true, - ServiceName: "custom-service", - ServiceVersion: "", - }, - expectedTelemetry: &telemetry.Config{ - Endpoint: "localhost:4317", - EnablePrometheusMetricsPath: true, - ServiceName: "custom-service", - }, - }, - { - name: "preserves both when explicitly set", - inputTelemetry: &telemetry.Config{ - Endpoint: "localhost:4317", - EnablePrometheusMetricsPath: true, - ServiceName: "my-service", - ServiceVersion: "v2.0.0", - }, - expectedTelemetry: &telemetry.Config{ - Endpoint: "localhost:4317", - EnablePrometheusMetricsPath: true, - ServiceName: "my-service", - ServiceVersion: "v2.0.0", - }, - }, - { - name: "defaults ServiceName when only ServiceVersion is set", - inputTelemetry: &telemetry.Config{ - Endpoint: "localhost:4317", - EnablePrometheusMetricsPath: true, - ServiceName: "", - ServiceVersion: "v1.0.0", - }, - expectedTelemetry: &telemetry.Config{ - Endpoint: "localhost:4317", - EnablePrometheusMetricsPath: true, - ServiceName: "my-vmcp-server", - ServiceVersion: "v1.0.0", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - vmcp := &mcpv1alpha1.VirtualMCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-vmcp-server", - Namespace: "default", - }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ - Type: "anonymous", - }, - Config: vmcpconfig.Config{ - Group: "test-group", - Telemetry: tt.inputTelemetry, - }, - }, - } - - converter := newTestConverter(t, newNoOpMockResolver(t)) - ctx := log.IntoContext(context.Background(), logr.Discard()) - - config, _, err := converter.Convert(ctx, vmcp, nil) - require.NoError(t, err) - require.NotNil(t, config) - - require.Equal(t, tt.expectedTelemetry, config.Telemetry, "Telemetry config should match expected defaults") - }) - } + assert.Nil(t, config.Telemetry, "Config.Telemetry should be ignored by the operator; use TelemetryConfigRef") } // TestConverter_TelemetryNil tests that nil telemetry config is handled correctly. @@ -1613,76 +1489,6 @@ func TestConverter_TelemetryNil(t *testing.T) { assert.Nil(t, config.Telemetry, "Telemetry should be nil when not configured") } -// TestConverter_TelemetryEndpointPrefixStripping tests that http:// and https:// prefixes -// are stripped from the endpoint, as the OTLP client expects host:port format. -// This matches the behavior of spectoconfig.ConvertTelemetryConfig. -func TestConverter_TelemetryEndpointPrefixStripping(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - inputEndpoint string - expectedEndpoint string - }{ - { - name: "strips https:// prefix", - inputEndpoint: "https://otlp-collector:4317", - expectedEndpoint: "otlp-collector:4317", - }, - { - name: "strips http:// prefix", - inputEndpoint: "http://localhost:4317", - expectedEndpoint: "localhost:4317", - }, - { - name: "preserves endpoint without prefix", - inputEndpoint: "otlp-collector:4317", - expectedEndpoint: "otlp-collector:4317", - }, - { - name: "preserves localhost without prefix", - inputEndpoint: "localhost:4317", - expectedEndpoint: "localhost:4317", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - vmcp := &mcpv1alpha1.VirtualMCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-vmcp", - Namespace: "default", - }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ - Type: "anonymous", - }, - Config: vmcpconfig.Config{ - Group: "test-group", - Telemetry: &telemetry.Config{ - Endpoint: tt.inputEndpoint, - EnablePrometheusMetricsPath: true, - }, - }, - }, - } - - converter := newTestConverter(t, newNoOpMockResolver(t)) - ctx := log.IntoContext(context.Background(), logr.Discard()) - - config, _, err := converter.Convert(ctx, vmcp, nil) - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Telemetry) - - assert.Equal(t, tt.expectedEndpoint, config.Telemetry.Endpoint, - "Endpoint should have http:// or https:// prefix stripped") - }) - } -} - func TestConverter_SessionStorage(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/test-integration/mcp-server/mcpserver_cel_validation_integration_test.go b/cmd/thv-operator/test-integration/mcp-server/mcpserver_cel_validation_integration_test.go index b96e527714..5da5d1be79 100644 --- a/cmd/thv-operator/test-integration/mcp-server/mcpserver_cel_validation_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/mcpserver_cel_validation_integration_test.go @@ -331,66 +331,4 @@ var _ = Describe("CEL Validation for OIDCConfigRef and AuthzConfigRef", Label("k }) }) - Context("Telemetry and TelemetryConfigRef mutual exclusion", func() { - It("should reject when both telemetry and telemetryConfigRef are set", func() { - server := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "telemetry-mutual-exclusion", - Namespace: "default", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: "example/mcp-server:latest", - Telemetry: &mcpv1alpha1.TelemetryConfig{ - OpenTelemetry: &mcpv1alpha1.OpenTelemetryConfig{ - Enabled: true, - Endpoint: "otel-collector:4317", - }, - }, - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ - Name: "shared-config", - }, - }, - } - err := k8sClient.Create(ctx, server) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("telemetry and telemetryConfigRef are mutually exclusive")) - }) - - It("should accept when only telemetryConfigRef is set", func() { - server := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "telemetry-ref-only", - Namespace: "default", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: "example/mcp-server:latest", - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ - Name: "shared-config", - }, - }, - } - err := k8sClient.Create(ctx, server) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should accept when only inline telemetry is set", func() { - server := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "telemetry-inline-only", - Namespace: "default", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: "example/mcp-server:latest", - Telemetry: &mcpv1alpha1.TelemetryConfig{ - OpenTelemetry: &mcpv1alpha1.OpenTelemetryConfig{ - Enabled: true, - Endpoint: "otel-collector:4317", - }, - }, - }, - } - err := k8sClient.Create(ctx, server) - Expect(err).NotTo(HaveOccurred()) - }) - }) }) diff --git a/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go b/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go index edcb62a849..fc1b2d0c5c 100644 --- a/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go @@ -453,79 +453,7 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { }) Context("When creating MCPServer with complex configurations", func() { - It("Should handle MCPServer with telemetry configuration", func() { - namespace := "telemetry-test-ns" - mcpServerName := "telemetry-server" - configMapName := mcpServerName + "-runconfig" - - // Create namespace - ns := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: namespace, - }, - } - _ = k8sClient.Create(ctx, ns) - - // Create MCPServer with telemetry configuration - mcpServer := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: mcpServerName, - Namespace: namespace, - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: "telemetry/mcp-server:latest", - Transport: "stdio", - ProxyPort: 8080, - Telemetry: &mcpv1alpha1.TelemetryConfig{ - OpenTelemetry: &mcpv1alpha1.OpenTelemetryConfig{ - Enabled: true, - Endpoint: "http://otel-collector:4317", - ServiceName: "test-service", - Insecure: true, - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{ - Enabled: true, - SamplingRate: "0.1", - }, - Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{ - Enabled: true, - }, - }, - Prometheus: &mcpv1alpha1.PrometheusConfig{ - Enabled: true, - }, - }, - }, - } - - Expect(k8sClient.Create(ctx, mcpServer)).Should(Succeed()) - defer k8sClient.Delete(ctx, mcpServer) - - // Wait for ConfigMap to be created - configMap := &corev1.ConfigMap{} - Eventually(func() error { - return k8sClient.Get(ctx, types.NamespacedName{ - Name: configMapName, - Namespace: namespace, - }, configMap) - }, timeout, interval).Should(Succeed()) - - // Parse RunConfig and verify telemetry configuration - var runConfig runner.RunConfig - err := json.Unmarshal([]byte(configMap.Data["runconfig.json"]), &runConfig) - Expect(err).NotTo(HaveOccurred()) - - // Verify telemetry configuration - Expect(runConfig.TelemetryConfig).NotTo(BeNil()) - Expect(runConfig.TelemetryConfig.Endpoint).To(Equal("otel-collector:4317")) - Expect(runConfig.TelemetryConfig.ServiceName).To(Equal("test-service")) - Expect(runConfig.TelemetryConfig.Insecure).To(BeTrue()) - Expect(runConfig.TelemetryConfig.TracingEnabled).To(BeTrue()) - Expect(runConfig.TelemetryConfig.MetricsEnabled).To(BeTrue()) - Expect(runConfig.TelemetryConfig.SamplingRate).To(Equal("0.1")) - Expect(runConfig.TelemetryConfig.EnablePrometheusMetricsPath).To(BeTrue()) - }) - - It("Should handle MCPServer with telemetryConfigRef producing same RunConfig as inline telemetry", func() { + It("Should handle MCPServer with telemetryConfigRef", func() { namespace := "telemetry-ref-test-ns" mcpServerName := "telemetry-ref-server" configMapName := mcpServerName + "-runconfig" @@ -538,8 +466,7 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { } _ = k8sClient.Create(ctx, ns) - // Create the MCPTelemetryConfig resource with the same settings - // as the inline telemetry test above + // Create the MCPTelemetryConfig resource telCfg := &mcpv1alpha1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "shared-otel-config", @@ -568,8 +495,7 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { return err == nil && fetched.Status.ConfigHash != "" }, timeout, interval).Should(BeTrue()) - // Create MCPServer with telemetryConfigRef (NOT inline telemetry) - // Use ServiceName override to match what the inline test sets + // Create MCPServer with telemetryConfigRef mcpServer := &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, @@ -598,8 +524,7 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { }, configMap) }, timeout, interval).Should(Succeed()) - // Parse RunConfig and verify telemetry configuration matches the - // inline telemetry test expectations + // Parse RunConfig and verify telemetry configuration var runConfig runner.RunConfig err := json.Unmarshal([]byte(configMap.Data["runconfig.json"]), &runConfig) Expect(err).NotTo(HaveOccurred()) diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go index 1622395f67..7bfac36288 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go @@ -17,7 +17,6 @@ import ( "sigs.k8s.io/yaml" mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" - telemetryconfig "github.com/stacklok/toolhive/pkg/telemetry" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -367,32 +366,4 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", }) }) - Context("CEL validation rejects both config.telemetry and telemetryConfigRef", func() { - It("should reject creation when both config.telemetry and telemetryConfigRef are set", func() { - vmcp := &mcpv1alpha1.VirtualMCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-vmcp-telemetry-mutual-exclusion", - Namespace: namespace, - }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{ - Group: "some-group", - Telemetry: &telemetryconfig.Config{ - Endpoint: "https://otel-collector:4317", - TracingEnabled: true, - }, - }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ - Type: "anonymous", - }, - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ - Name: "some-telemetry-config", - }, - }, - } - err := k8sClient.Create(ctx, vmcp) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("telemetryConfigRef")) - }) - }) }) diff --git a/cmd/thv/app/export.go b/cmd/thv/app/export.go index b0d8161161..40dd5c01bd 100644 --- a/cmd/thv/app/export.go +++ b/cmd/thv/app/export.go @@ -96,6 +96,12 @@ func exportCmdFunc(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "Secrets used: %v\n", runConfig.Secrets) } + // Warn if telemetry config is present but cannot be exported inline + if runConfig.TelemetryConfig != nil { + fmt.Fprintf(os.Stderr, "Warning: Telemetry configuration detected but not exported.\n") + fmt.Fprintf(os.Stderr, "Create an MCPTelemetryConfig resource and add a telemetryConfigRef to the exported MCPServer.\n") + } + if err := export.WriteK8sManifest(runConfig, outputFile); err != nil { return fmt.Errorf("failed to write Kubernetes manifest: %w", err) } diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml index 842fa1974e..caa998f2e5 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -635,90 +635,11 @@ spec: - ClientIP - None type: string - telemetry: - description: |- - Telemetry defines inline observability configuration for the proxy. - Deprecated: Use TelemetryConfigRef to reference a shared MCPTelemetryConfig resource instead. - This field will be removed in a future release. Setting both telemetry and telemetryConfigRef - is rejected by CEL validation. - properties: - openTelemetry: - description: OpenTelemetry defines OpenTelemetry configuration - properties: - enabled: - default: false - description: Enabled controls whether OpenTelemetry is enabled - type: boolean - endpoint: - description: Endpoint is the OTLP endpoint URL for tracing - and metrics - type: string - headers: - description: |- - Headers contains authentication headers for the OTLP endpoint - Specified as key=value pairs - items: - type: string - type: array - x-kubernetes-list-type: atomic - insecure: - default: false - description: Insecure indicates whether to use HTTP instead - of HTTPS for the OTLP endpoint - type: boolean - metrics: - description: Metrics defines OpenTelemetry metrics-specific - configuration - properties: - enabled: - default: false - description: Enabled controls whether OTLP metrics are - sent - type: boolean - type: object - serviceName: - description: |- - ServiceName is the service name for telemetry - If not specified, defaults to the MCPServer name - type: string - tracing: - description: Tracing defines OpenTelemetry tracing configuration - properties: - enabled: - default: false - description: Enabled controls whether OTLP tracing is - sent - type: boolean - samplingRate: - default: "0.05" - description: SamplingRate is the trace sampling rate (0.0-1.0) - pattern: ^(0(\.\d+)?|1(\.0+)?)$ - type: string - type: object - useLegacyAttributes: - default: true - description: |- - UseLegacyAttributes controls whether legacy attribute names are emitted alongside - the new MCP OTEL semantic convention names. Defaults to true for backward compatibility. - This will change to false in a future release and eventually be removed. - type: boolean - type: object - prometheus: - description: Prometheus defines Prometheus-specific configuration - properties: - enabled: - default: false - description: Enabled controls whether Prometheus metrics endpoint - is exposed - type: boolean - type: object - type: object telemetryConfigRef: description: |- TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. The referenced MCPTelemetryConfig must exist in the same namespace as this MCPRemoteProxy. Cross-namespace references are not supported for security and isolation reasons. - Mutually exclusive with the deprecated inline Telemetry field. properties: name: description: Name is the name of the MCPTelemetryConfig resource @@ -770,9 +691,6 @@ spec: - message: oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef to reference a shared MCPOIDCConfig rule: '!(has(self.oidcConfig) && has(self.oidcConfigRef))' - - message: telemetry and telemetryConfigRef are mutually exclusive; migrate - to telemetryConfigRef - rule: '!(has(self.telemetry) && has(self.telemetryConfigRef))' status: description: MCPRemoteProxyStatus defines the observed state of MCPRemoteProxy properties: diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml index 74c0f4817e..4b1ca998ba 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml @@ -877,90 +877,11 @@ spec: x-kubernetes-validations: - message: address is required rule: 'self.provider == ''redis'' ? has(self.address) : true' - telemetry: - description: |- - Telemetry defines inline observability configuration for the MCP server. - Deprecated: Use TelemetryConfigRef to reference a shared MCPTelemetryConfig resource instead. - This field will be removed in a future release. Setting both telemetry and telemetryConfigRef - is rejected by CEL validation. - properties: - openTelemetry: - description: OpenTelemetry defines OpenTelemetry configuration - properties: - enabled: - default: false - description: Enabled controls whether OpenTelemetry is enabled - type: boolean - endpoint: - description: Endpoint is the OTLP endpoint URL for tracing - and metrics - type: string - headers: - description: |- - Headers contains authentication headers for the OTLP endpoint - Specified as key=value pairs - items: - type: string - type: array - x-kubernetes-list-type: atomic - insecure: - default: false - description: Insecure indicates whether to use HTTP instead - of HTTPS for the OTLP endpoint - type: boolean - metrics: - description: Metrics defines OpenTelemetry metrics-specific - configuration - properties: - enabled: - default: false - description: Enabled controls whether OTLP metrics are - sent - type: boolean - type: object - serviceName: - description: |- - ServiceName is the service name for telemetry - If not specified, defaults to the MCPServer name - type: string - tracing: - description: Tracing defines OpenTelemetry tracing configuration - properties: - enabled: - default: false - description: Enabled controls whether OTLP tracing is - sent - type: boolean - samplingRate: - default: "0.05" - description: SamplingRate is the trace sampling rate (0.0-1.0) - pattern: ^(0(\.\d+)?|1(\.0+)?)$ - type: string - type: object - useLegacyAttributes: - default: true - description: |- - UseLegacyAttributes controls whether legacy attribute names are emitted alongside - the new MCP OTEL semantic convention names. Defaults to true for backward compatibility. - This will change to false in a future release and eventually be removed. - type: boolean - type: object - prometheus: - description: Prometheus defines Prometheus-specific configuration - properties: - enabled: - default: false - description: Enabled controls whether Prometheus metrics endpoint - is exposed - type: boolean - type: object - type: object telemetryConfigRef: description: |- TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. The referenced MCPTelemetryConfig must exist in the same namespace as this MCPServer. Cross-namespace references are not supported for security and isolation reasons. - Mutually exclusive with the deprecated inline Telemetry field. properties: name: description: Name is the name of the MCPTelemetryConfig resource @@ -1041,9 +962,6 @@ spec: - message: oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef to reference a shared MCPOIDCConfig rule: '!(has(self.oidcConfig) && has(self.oidcConfigRef))' - - message: telemetry and telemetryConfigRef are mutually exclusive; migrate - to telemetryConfigRef - rule: '!(has(self.telemetry) && has(self.telemetryConfigRef))' - message: rateLimiting requires sessionStorage with provider 'redis' rule: '!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == ''redis'')' diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 246544f0d2..813925b1be 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -641,8 +641,6 @@ spec: Config is the Virtual MCP server configuration. The audit config from here is also supported, but not required. Note: config.groupRef is deprecated in favor of spec.groupRef. - Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference - a shared MCPTelemetryConfig resource instead. properties: aggregation: description: |- @@ -2311,7 +2309,6 @@ spec: TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer. Cross-namespace references are not supported for security and isolation reasons. - Mutually exclusive with the deprecated inline config.telemetry field. properties: name: description: Name is the name of the MCPTelemetryConfig resource @@ -2330,10 +2327,6 @@ spec: required: - incomingAuth type: object - x-kubernetes-validations: - - message: config.telemetry and telemetryConfigRef are mutually exclusive; - migrate to telemetryConfigRef - rule: '!(has(self.config.telemetry) && has(self.telemetryConfigRef))' status: description: VirtualMCPServerStatus defines the observed state of VirtualMCPServer properties: diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml index cbdb9c5183..d9d7292262 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -638,90 +638,11 @@ spec: - ClientIP - None type: string - telemetry: - description: |- - Telemetry defines inline observability configuration for the proxy. - Deprecated: Use TelemetryConfigRef to reference a shared MCPTelemetryConfig resource instead. - This field will be removed in a future release. Setting both telemetry and telemetryConfigRef - is rejected by CEL validation. - properties: - openTelemetry: - description: OpenTelemetry defines OpenTelemetry configuration - properties: - enabled: - default: false - description: Enabled controls whether OpenTelemetry is enabled - type: boolean - endpoint: - description: Endpoint is the OTLP endpoint URL for tracing - and metrics - type: string - headers: - description: |- - Headers contains authentication headers for the OTLP endpoint - Specified as key=value pairs - items: - type: string - type: array - x-kubernetes-list-type: atomic - insecure: - default: false - description: Insecure indicates whether to use HTTP instead - of HTTPS for the OTLP endpoint - type: boolean - metrics: - description: Metrics defines OpenTelemetry metrics-specific - configuration - properties: - enabled: - default: false - description: Enabled controls whether OTLP metrics are - sent - type: boolean - type: object - serviceName: - description: |- - ServiceName is the service name for telemetry - If not specified, defaults to the MCPServer name - type: string - tracing: - description: Tracing defines OpenTelemetry tracing configuration - properties: - enabled: - default: false - description: Enabled controls whether OTLP tracing is - sent - type: boolean - samplingRate: - default: "0.05" - description: SamplingRate is the trace sampling rate (0.0-1.0) - pattern: ^(0(\.\d+)?|1(\.0+)?)$ - type: string - type: object - useLegacyAttributes: - default: true - description: |- - UseLegacyAttributes controls whether legacy attribute names are emitted alongside - the new MCP OTEL semantic convention names. Defaults to true for backward compatibility. - This will change to false in a future release and eventually be removed. - type: boolean - type: object - prometheus: - description: Prometheus defines Prometheus-specific configuration - properties: - enabled: - default: false - description: Enabled controls whether Prometheus metrics endpoint - is exposed - type: boolean - type: object - type: object telemetryConfigRef: description: |- TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. The referenced MCPTelemetryConfig must exist in the same namespace as this MCPRemoteProxy. Cross-namespace references are not supported for security and isolation reasons. - Mutually exclusive with the deprecated inline Telemetry field. properties: name: description: Name is the name of the MCPTelemetryConfig resource @@ -773,9 +694,6 @@ spec: - message: oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef to reference a shared MCPOIDCConfig rule: '!(has(self.oidcConfig) && has(self.oidcConfigRef))' - - message: telemetry and telemetryConfigRef are mutually exclusive; migrate - to telemetryConfigRef - rule: '!(has(self.telemetry) && has(self.telemetryConfigRef))' status: description: MCPRemoteProxyStatus defines the observed state of MCPRemoteProxy properties: diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml index c4dceb11b3..9d4b9c7518 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml @@ -880,90 +880,11 @@ spec: x-kubernetes-validations: - message: address is required rule: 'self.provider == ''redis'' ? has(self.address) : true' - telemetry: - description: |- - Telemetry defines inline observability configuration for the MCP server. - Deprecated: Use TelemetryConfigRef to reference a shared MCPTelemetryConfig resource instead. - This field will be removed in a future release. Setting both telemetry and telemetryConfigRef - is rejected by CEL validation. - properties: - openTelemetry: - description: OpenTelemetry defines OpenTelemetry configuration - properties: - enabled: - default: false - description: Enabled controls whether OpenTelemetry is enabled - type: boolean - endpoint: - description: Endpoint is the OTLP endpoint URL for tracing - and metrics - type: string - headers: - description: |- - Headers contains authentication headers for the OTLP endpoint - Specified as key=value pairs - items: - type: string - type: array - x-kubernetes-list-type: atomic - insecure: - default: false - description: Insecure indicates whether to use HTTP instead - of HTTPS for the OTLP endpoint - type: boolean - metrics: - description: Metrics defines OpenTelemetry metrics-specific - configuration - properties: - enabled: - default: false - description: Enabled controls whether OTLP metrics are - sent - type: boolean - type: object - serviceName: - description: |- - ServiceName is the service name for telemetry - If not specified, defaults to the MCPServer name - type: string - tracing: - description: Tracing defines OpenTelemetry tracing configuration - properties: - enabled: - default: false - description: Enabled controls whether OTLP tracing is - sent - type: boolean - samplingRate: - default: "0.05" - description: SamplingRate is the trace sampling rate (0.0-1.0) - pattern: ^(0(\.\d+)?|1(\.0+)?)$ - type: string - type: object - useLegacyAttributes: - default: true - description: |- - UseLegacyAttributes controls whether legacy attribute names are emitted alongside - the new MCP OTEL semantic convention names. Defaults to true for backward compatibility. - This will change to false in a future release and eventually be removed. - type: boolean - type: object - prometheus: - description: Prometheus defines Prometheus-specific configuration - properties: - enabled: - default: false - description: Enabled controls whether Prometheus metrics endpoint - is exposed - type: boolean - type: object - type: object telemetryConfigRef: description: |- TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. The referenced MCPTelemetryConfig must exist in the same namespace as this MCPServer. Cross-namespace references are not supported for security and isolation reasons. - Mutually exclusive with the deprecated inline Telemetry field. properties: name: description: Name is the name of the MCPTelemetryConfig resource @@ -1044,9 +965,6 @@ spec: - message: oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef to reference a shared MCPOIDCConfig rule: '!(has(self.oidcConfig) && has(self.oidcConfigRef))' - - message: telemetry and telemetryConfigRef are mutually exclusive; migrate - to telemetryConfigRef - rule: '!(has(self.telemetry) && has(self.telemetryConfigRef))' - message: rateLimiting requires sessionStorage with provider 'redis' rule: '!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == ''redis'')' diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index b57df2b347..8b00009e41 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -644,8 +644,6 @@ spec: Config is the Virtual MCP server configuration. The audit config from here is also supported, but not required. Note: config.groupRef is deprecated in favor of spec.groupRef. - Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference - a shared MCPTelemetryConfig resource instead. properties: aggregation: description: |- @@ -2314,7 +2312,6 @@ spec: TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer. Cross-namespace references are not supported for security and isolation reasons. - Mutually exclusive with the deprecated inline config.telemetry field. properties: name: description: Name is the name of the MCPTelemetryConfig resource @@ -2333,10 +2330,6 @@ spec: required: - incomingAuth type: object - x-kubernetes-validations: - - message: config.telemetry and telemetryConfigRef are mutually exclusive; - migrate to telemetryConfigRef - rule: '!(has(self.config.telemetry) && has(self.telemetryConfigRef))' status: description: VirtualMCPServerStatus defines the observed state of VirtualMCPServer properties: diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 8a8e3a583e..6e09a71d07 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -1926,8 +1926,7 @@ _Appears in:_ | `authzConfig` _[api.v1alpha1.AuthzConfigRef](#apiv1alpha1authzconfigref)_ | AuthzConfig defines authorization policy configuration for the proxy | | Optional: \{\}
| | `audit` _[api.v1alpha1.AuditConfig](#apiv1alpha1auditconfig)_ | Audit defines audit logging configuration for the proxy | | Optional: \{\}
| | `toolConfigRef` _[api.v1alpha1.ToolConfigRef](#apiv1alpha1toolconfigref)_ | ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming.
The referenced MCPToolConfig must exist in the same namespace as this MCPRemoteProxy.
Cross-namespace references are not supported for security and isolation reasons.
If specified, this allows filtering and overriding tools from the remote MCP server. | | Optional: \{\}
| -| `telemetryConfigRef` _[api.v1alpha1.MCPTelemetryConfigReference](#apiv1alpha1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this MCPRemoteProxy.
Cross-namespace references are not supported for security and isolation reasons.
Mutually exclusive with the deprecated inline Telemetry field. | | Optional: \{\}
| -| `telemetry` _[api.v1alpha1.TelemetryConfig](#apiv1alpha1telemetryconfig)_ | Telemetry defines inline observability configuration for the proxy.
Deprecated: Use TelemetryConfigRef to reference a shared MCPTelemetryConfig resource instead.
This field will be removed in a future release. Setting both telemetry and telemetryConfigRef
is rejected by CEL validation. | | Optional: \{\}
| +| `telemetryConfigRef` _[api.v1alpha1.MCPTelemetryConfigReference](#apiv1alpha1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this MCPRemoteProxy.
Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\}
| | `resources` _[api.v1alpha1.ResourceRequirements](#apiv1alpha1resourcerequirements)_ | Resources defines the resource requirements for the proxy container | | Optional: \{\}
| | `serviceAccount` _string_ | ServiceAccount is the name of an already existing service account to use by the proxy.
If not specified, a ServiceAccount will be created automatically and used by the proxy. | | Optional: \{\}
| | `trustProxyHeaders` _boolean_ | TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies
When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port,
and X-Forwarded-Prefix headers to construct endpoint URLs | false | Optional: \{\}
| @@ -2164,8 +2163,7 @@ _Appears in:_ | `toolConfigRef` _[api.v1alpha1.ToolConfigRef](#apiv1alpha1toolconfigref)_ | ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming.
The referenced MCPToolConfig must exist in the same namespace as this MCPServer.
Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\}
| | `externalAuthConfigRef` _[api.v1alpha1.ExternalAuthConfigRef](#apiv1alpha1externalauthconfigref)_ | ExternalAuthConfigRef references a MCPExternalAuthConfig resource for external authentication.
The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPServer. | | Optional: \{\}
| | `authServerRef` _[api.v1alpha1.AuthServerRef](#apiv1alpha1authserverref)_ | AuthServerRef optionally references a resource that configures an embedded
OAuth 2.0/OIDC authorization server to authenticate MCP clients.
Currently the only supported kind is MCPExternalAuthConfig (type: embeddedAuthServer). | | Optional: \{\}
| -| `telemetryConfigRef` _[api.v1alpha1.MCPTelemetryConfigReference](#apiv1alpha1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this MCPServer.
Cross-namespace references are not supported for security and isolation reasons.
Mutually exclusive with the deprecated inline Telemetry field. | | Optional: \{\}
| -| `telemetry` _[api.v1alpha1.TelemetryConfig](#apiv1alpha1telemetryconfig)_ | Telemetry defines inline observability configuration for the MCP server.
Deprecated: Use TelemetryConfigRef to reference a shared MCPTelemetryConfig resource instead.
This field will be removed in a future release. Setting both telemetry and telemetryConfigRef
is rejected by CEL validation. | | Optional: \{\}
| +| `telemetryConfigRef` _[api.v1alpha1.MCPTelemetryConfigReference](#apiv1alpha1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this MCPServer.
Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\}
| | `trustProxyHeaders` _boolean_ | TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies
When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port,
and X-Forwarded-Prefix headers to construct endpoint URLs | false | Optional: \{\}
| | `endpointPrefix` _string_ | EndpointPrefix is the path prefix to prepend to SSE endpoint URLs.
This is used to handle path-based ingress routing scenarios where the ingress
strips a path prefix before forwarding to the backend. | | Optional: \{\}
| | `groupRef` _[api.v1alpha1.MCPGroupRef](#apiv1alpha1mcpgroupref)_ | GroupRef references the MCPGroup this server belongs to.
The referenced MCPGroup must be in the same namespace. | | Optional: \{\}
| @@ -2523,29 +2521,6 @@ _Appears in:_ | `userInfoOverride` _[api.v1alpha1.UserInfoConfig](#apiv1alpha1userinfoconfig)_ | UserInfoOverride allows customizing UserInfo fetching behavior for OIDC providers.
By default, the UserInfo endpoint is discovered automatically via OIDC discovery.
Use this to override the endpoint URL, HTTP method, or field mappings for providers
that return non-standard claim names in their UserInfo response. | | Optional: \{\}
| -#### api.v1alpha1.OpenTelemetryConfig - - - -OpenTelemetryConfig defines pure OpenTelemetry configuration - - - -_Appears in:_ -- [api.v1alpha1.TelemetryConfig](#apiv1alpha1telemetryconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `enabled` _boolean_ | Enabled controls whether OpenTelemetry is enabled | false | Optional: \{\}
| -| `endpoint` _string_ | Endpoint is the OTLP endpoint URL for tracing and metrics | | Optional: \{\}
| -| `serviceName` _string_ | ServiceName is the service name for telemetry
If not specified, defaults to the MCPServer name | | Optional: \{\}
| -| `headers` _string array_ | Headers contains authentication headers for the OTLP endpoint
Specified as key=value pairs | | Optional: \{\}
| -| `insecure` _boolean_ | Insecure indicates whether to use HTTP instead of HTTPS for the OTLP endpoint | false | Optional: \{\}
| -| `metrics` _[api.v1alpha1.OpenTelemetryMetricsConfig](#apiv1alpha1opentelemetrymetricsconfig)_ | Metrics defines OpenTelemetry metrics-specific configuration | | Optional: \{\}
| -| `tracing` _[api.v1alpha1.OpenTelemetryTracingConfig](#apiv1alpha1opentelemetrytracingconfig)_ | Tracing defines OpenTelemetry tracing configuration | | Optional: \{\}
| -| `useLegacyAttributes` _boolean_ | UseLegacyAttributes controls whether legacy attribute names are emitted alongside
the new MCP OTEL semantic convention names. Defaults to true for backward compatibility.
This will change to false in a future release and eventually be removed. | true | Optional: \{\}
| - - #### api.v1alpha1.OpenTelemetryMetricsConfig @@ -2556,7 +2531,6 @@ OpenTelemetryMetricsConfig defines OpenTelemetry metrics configuration _Appears in:_ - [api.v1alpha1.MCPTelemetryOTelConfig](#apiv1alpha1mcptelemetryotelconfig) -- [api.v1alpha1.OpenTelemetryConfig](#apiv1alpha1opentelemetryconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2573,7 +2547,6 @@ OpenTelemetryTracingConfig defines OpenTelemetry tracing configuration _Appears in:_ - [api.v1alpha1.MCPTelemetryOTelConfig](#apiv1alpha1mcptelemetryotelconfig) -- [api.v1alpha1.OpenTelemetryConfig](#apiv1alpha1opentelemetryconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2647,7 +2620,6 @@ PrometheusConfig defines Prometheus-specific configuration _Appears in:_ - [api.v1alpha1.MCPTelemetryConfigSpec](#apiv1alpha1mcptelemetryconfigspec) -- [api.v1alpha1.TelemetryConfig](#apiv1alpha1telemetryconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2992,24 +2964,6 @@ _Appears in:_ | `passwordRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | PasswordRef is a reference to a Secret key containing the Redis password | | Optional: \{\}
| -#### api.v1alpha1.TelemetryConfig - - - -TelemetryConfig defines observability configuration for the MCP server - - - -_Appears in:_ -- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `openTelemetry` _[api.v1alpha1.OpenTelemetryConfig](#apiv1alpha1opentelemetryconfig)_ | OpenTelemetry defines OpenTelemetry configuration | | Optional: \{\}
| -| `prometheus` _[api.v1alpha1.PrometheusConfig](#apiv1alpha1prometheusconfig)_ | Prometheus defines Prometheus-specific configuration | | Optional: \{\}
| - - #### api.v1alpha1.TokenExchangeConfig @@ -3442,8 +3396,8 @@ _Appears in:_ | `serviceAccount` _string_ | ServiceAccount is the name of an already existing service account to use by the Virtual MCP server.
If not specified, a ServiceAccount will be created automatically and used by the Virtual MCP server. | | Optional: \{\}
| | `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the Virtual MCP server
This allows for customizing the pod configuration beyond what is provided by the other fields.
Note that to modify the specific container the Virtual MCP server runs in, you must specify
the 'vmcp' container name in the PodTemplateSpec.
This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object
Optional: \{\}
| | `groupRef` _[api.v1alpha1.MCPGroupRef](#apiv1alpha1mcpgroupref)_ | GroupRef references the MCPGroup that defines backend workloads.
The referenced MCPGroup must exist in the same namespace.
This field takes precedence over config.groupRef and should be preferred. | | Optional: \{\}
| -| `config` _[vmcp.config.Config](#vmcpconfigconfig)_ | Config is the Virtual MCP server configuration.
The audit config from here is also supported, but not required.
Note: config.groupRef is deprecated in favor of spec.groupRef.
Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference
a shared MCPTelemetryConfig resource instead. | | Type: object
Optional: \{\}
| -| `telemetryConfigRef` _[api.v1alpha1.MCPTelemetryConfigReference](#apiv1alpha1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer.
Cross-namespace references are not supported for security and isolation reasons.
Mutually exclusive with the deprecated inline config.telemetry field. | | Optional: \{\}
| +| `config` _[vmcp.config.Config](#vmcpconfigconfig)_ | Config is the Virtual MCP server configuration.
The audit config from here is also supported, but not required.
Note: config.groupRef is deprecated in favor of spec.groupRef. | | Type: object
Optional: \{\}
| +| `telemetryConfigRef` _[api.v1alpha1.MCPTelemetryConfigReference](#apiv1alpha1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer.
Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\}
| | `embeddingServerRef` _[api.v1alpha1.EmbeddingServerRef](#apiv1alpha1embeddingserverref)_ | EmbeddingServerRef references an existing EmbeddingServer resource by name.
When the optimizer is enabled, this field is required to point to a ready EmbeddingServer
that provides embedding capabilities.
The referenced EmbeddingServer must exist in the same namespace and be ready. | | Optional: \{\}
| | `authServerConfig` _[api.v1alpha1.EmbeddedAuthServerConfig](#apiv1alpha1embeddedauthserverconfig)_ | AuthServerConfig configures an embedded OAuth authorization server.
When set, the vMCP server acts as an OIDC issuer, drives users through
upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the
IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfig
so that tokens it issues are accepted by the vMCP's incoming auth middleware.
When nil, IncomingAuth uses an external IDP and behavior is unchanged. | | Optional: \{\}
| | `replicas` _integer_ | Replicas is the desired number of vMCP pod replicas.
VirtualMCPServer creates a single Deployment for the vMCP aggregator process,
so there is only one replicas field (unlike MCPServer which has separate
Replicas and BackendReplicas for its two Deployments).
When nil, the operator does not set Deployment.Spec.Replicas, leaving replica
management to an HPA or other external controller. | | Minimum: 0
Optional: \{\}
| diff --git a/pkg/export/k8s.go b/pkg/export/k8s.go index e57cfda9aa..1b10af79b8 100644 --- a/pkg/export/k8s.go +++ b/pkg/export/k8s.go @@ -166,30 +166,9 @@ func runConfigToMCPServer(config *runner.RunConfig) (*v1alpha1.MCPServer, error) } } - // Convert telemetry config - if config.TelemetryConfig != nil { - mcpServer.Spec.Telemetry = &v1alpha1.TelemetryConfig{} - - if config.TelemetryConfig.Endpoint != "" { - mcpServer.Spec.Telemetry.OpenTelemetry = &v1alpha1.OpenTelemetryConfig{ - Enabled: true, - Endpoint: config.TelemetryConfig.Endpoint, - Insecure: config.TelemetryConfig.Insecure, - } - - if config.TelemetryConfig.ServiceName != "" { - mcpServer.Spec.Telemetry.OpenTelemetry.ServiceName = config.TelemetryConfig.ServiceName - } - } - - // Convert Prometheus metrics path setting - if config.TelemetryConfig.EnablePrometheusMetricsPath { - if mcpServer.Spec.Telemetry.Prometheus == nil { - mcpServer.Spec.Telemetry.Prometheus = &v1alpha1.PrometheusConfig{} - } - mcpServer.Spec.Telemetry.Prometheus.Enabled = true - } - } + // Note: Telemetry configuration requires a separate MCPTelemetryConfig resource + // and a telemetryConfigRef on the MCPServer. This export does not generate + // the MCPTelemetryConfig resource — create it manually and reference it. // Note: ToolsFilter is not exported to CRD; use MCPToolConfig resource with toolConfigRef instead diff --git a/pkg/export/k8s_test.go b/pkg/export/k8s_test.go index b5d8d26017..de7cb56a68 100644 --- a/pkg/export/k8s_test.go +++ b/pkg/export/k8s_test.go @@ -19,7 +19,6 @@ import ( "github.com/stacklok/toolhive/pkg/authz" "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" "github.com/stacklok/toolhive/pkg/runner" - "github.com/stacklok/toolhive/pkg/telemetry" "github.com/stacklok/toolhive/pkg/transport/types" ) @@ -214,47 +213,6 @@ func TestWriteK8sManifest(t *testing.T) { assert.True(t, mcpServer.Spec.Audit.Enabled) }, }, - { - name: "config with telemetry", - config: &runner.RunConfig{ - Image: "ghcr.io/stacklok/mcp-server:latest", - Name: "test", - BaseName: "test", - Transport: types.TransportTypeStdio, - TelemetryConfig: &telemetry.Config{ - Endpoint: "http://otel-collector:4318", - ServiceName: "my-service", - Insecure: true, - }, - }, - validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { - t.Helper() - require.NotNil(t, mcpServer.Spec.Telemetry) - require.NotNil(t, mcpServer.Spec.Telemetry.OpenTelemetry) - assert.True(t, mcpServer.Spec.Telemetry.OpenTelemetry.Enabled) - assert.Equal(t, "http://otel-collector:4318", mcpServer.Spec.Telemetry.OpenTelemetry.Endpoint) - assert.Equal(t, "my-service", mcpServer.Spec.Telemetry.OpenTelemetry.ServiceName) - assert.True(t, mcpServer.Spec.Telemetry.OpenTelemetry.Insecure) - }, - }, - { - name: "config with prometheus metrics", - config: &runner.RunConfig{ - Image: "ghcr.io/stacklok/mcp-server:latest", - Name: "test", - BaseName: "test", - Transport: types.TransportTypeStdio, - TelemetryConfig: &telemetry.Config{ - EnablePrometheusMetricsPath: true, - }, - }, - validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { - t.Helper() - require.NotNil(t, mcpServer.Spec.Telemetry) - require.NotNil(t, mcpServer.Spec.Telemetry.Prometheus) - assert.True(t, mcpServer.Spec.Telemetry.Prometheus.Enabled) - }, - }, { name: "config with tools filter is not exported to CRD", config: &runner.RunConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go index 625b1ca1f2..5a66540b30 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go @@ -15,7 +15,6 @@ import ( "sigs.k8s.io/yaml" mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" - "github.com/stacklok/toolhive/pkg/telemetry" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -69,7 +68,40 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { return nil }, timeout, pollingInterval).Should(Succeed()) - By("Creating VirtualMCPServer with telemetry config") + By("Creating MCPTelemetryConfig for shared telemetry") + telCfg := &mcpv1alpha1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-telemetry-config", + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + Enabled: true, + Endpoint: "localhost:4317", + Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{Enabled: true}, + ResourceAttributes: map[string]string{ + "environment": "e2e-test", + "test_id": "telemetry_config_test", + "cluster_name": "kind-test-cluster", + }, + }, + Prometheus: &mcpv1alpha1.PrometheusConfig{Enabled: true}, + }, + } + Expect(k8sClient.Create(ctx, telCfg)).To(Succeed()) + + // Wait for MCPTelemetryConfig to be reconciled (hash set) + Eventually(func() bool { + fetched := &mcpv1alpha1.MCPTelemetryConfig{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: telCfg.Name, + Namespace: telCfg.Namespace, + }, fetched) + return err == nil && fetched.Status.ConfigHash != "" + }, timeout, pollingInterval).Should(BeTrue()) + + By("Creating VirtualMCPServer with telemetryConfigRef") vmcp := &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, @@ -80,22 +112,12 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, + TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + Name: "e2e-telemetry-config", + ServiceName: "custom-service-name", + }, Config: vmcpconfig.Config{ Group: mcpGroupName, - Telemetry: &telemetry.Config{ - Endpoint: "localhost:4317", - EnablePrometheusMetricsPath: true, - TracingEnabled: true, // Enable tracing to satisfy OTLP endpoint requirement - MetricsEnabled: true, // Enable metrics to satisfy OTLP endpoint requirement - ServiceName: "custom-service-name", - ServiceVersion: "v1.2.3", - CustomAttributes: map[string]string{ - "environment": "e2e-test", - "test_id": "telemetry_config_test", - "cluster_name": "kind-test-cluster", - }, - EnvironmentVariables: []string{"PATH", "HOME"}, - }, }, }, } @@ -136,6 +158,14 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { } Expect(k8sClient.Delete(ctx, backend)).To(Succeed()) + By("Cleaning up MCPTelemetryConfig") + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-telemetry-config", + Namespace: testNamespace, + }, + }) + By("Cleaning up MCPGroup") group := &mcpv1alpha1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ @@ -167,18 +197,20 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { var config vmcpconfig.Config Expect(yaml.Unmarshal([]byte(configYAML), &config)).To(Succeed()) - By("Validating telemetry configuration") + By("Validating telemetry configuration from MCPTelemetryConfig") Expect(config.Telemetry).NotTo(BeNil(), "Telemetry config should not be nil") - // Verify all telemetry fields are preserved Expect(config.Telemetry.EnablePrometheusMetricsPath).To(BeTrue(), - "EnablePrometheusMetricsPath should be preserved") + "EnablePrometheusMetricsPath should be set from MCPTelemetryConfig") Expect(config.Telemetry.ServiceName).To(Equal("custom-service-name"), - "ServiceName should be preserved") + "ServiceName should come from TelemetryConfigRef override") + + Expect(config.Telemetry.TracingEnabled).To(BeTrue(), + "TracingEnabled should be set from MCPTelemetryConfig") - Expect(config.Telemetry.ServiceVersion).To(Equal("v1.2.3"), - "ServiceVersion should be preserved") + Expect(config.Telemetry.MetricsEnabled).To(BeTrue(), + "MetricsEnabled should be set from MCPTelemetryConfig") Expect(config.Telemetry.CustomAttributes).NotTo(BeNil(), "CustomAttributes should not be nil") @@ -189,11 +221,6 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { Expect(config.Telemetry.CustomAttributes).To(HaveKeyWithValue("cluster_name", "kind-test-cluster"), "CustomAttributes should contain 'cluster_name'") - Expect(config.Telemetry.EnvironmentVariables).NotTo(BeEmpty(), - "EnvironmentVariables should not be empty") - Expect(config.Telemetry.EnvironmentVariables).To(ContainElements("PATH", "HOME"), - "EnvironmentVariables should be preserved") - - GinkgoWriter.Println("✓ All telemetry configuration fields preserved in ConfigMap") + GinkgoWriter.Println("✓ All telemetry configuration fields resolved from MCPTelemetryConfig") }) }) From d27e676780d8dca6adb19458ce1b4f5ce9805cd2 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:07:00 +0100 Subject: [PATCH 035/117] Remove deprecated inline oidcConfig from MCPServer and MCPRemoteProxy (#4820) * Remove deprecated inline oidcConfig field from MCPServer and MCPRemoteProxy The inline `oidcConfig` field on MCPServerSpec and MCPRemoteProxySpec was deprecated in favor of `oidcConfigRef`, which references a shared MCPOIDCConfig resource. This removes the field, its CEL mutual-exclusivity rules, all backward-compatibility controller code, and the AddOIDCConfigOptions utility function. Rate-limiting CEL rules are updated to only reference oidcConfigRef (not the removed oidcConfig). The OIDCConfigurable interface and Resolve() method are retained for now as they're still used by the VirtualMCPServer inline OIDC path (to be removed in a follow-up PR). Co-Authored-By: Claude Opus 4.6 (1M context) * Address PR review feedback for oidcConfig removal - Add OIDC client secret env var injection for MCPRemoteProxy when using OIDCConfigRef with inline MCPOIDCConfig (mirrors MCPServer behavior) - Add nil check on resolved OIDC config in resolveAndAddOIDCConfig to prevent nil pointer dereference when ResolveFromConfigRef returns nil - Update stale rate-limit condition message to not reference removed oidcConfig field - Add stderr warning in thv export when OIDC config is detected but cannot be exported inline - Remove broken integration test that asserted OIDC config on an MCPServer with no OIDC configuration Co-Authored-By: Claude Opus 4.6 (1M context) * Remove deprecated inline oidcConfig examples and update docs Remove 5 example YAML files that used the removed inline oidcConfig field. The recommended oidcConfigRef-based examples already exist (mcpserver_with_oidcconfig_ref.yaml, mcpremoteproxy_with_oidcconfig_ref.yaml). Update the bearer token example to use oidcConfigRef with a separate MCPOIDCConfig resource. Remove deprecated inline OIDC section from architecture docs and update MCPRemoteProxy field documentation to reflect the field removal. Co-Authored-By: Claude Opus 4.6 (1M context) * Regenerate CRD API reference docs Remove deprecated oidcConfig field documentation from MCPServerSpec and MCPRemoteProxySpec. Update OIDCConfigRef appears-in list. Co-Authored-By: Claude Opus 4.6 (1M context) * Address second round of review feedback - Update deploy/keycloak/mcpserver-with-auth.yaml to use MCPOIDCConfig with oidcConfigRef instead of removed inline oidcConfig - Separate error handling in buildOIDCClientSecretEnvVars to log API errors from GetOIDCConfigForServer instead of silently swallowing them - Remove stale mutual exclusivity statement from architecture docs Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../api/v1alpha1/mcpremoteproxy_types.go | 15 - .../api/v1alpha1/mcpserver_types.go | 18 +- .../api/v1alpha1/zz_generated.deepcopy.go | 10 - .../controllers/mcpremoteproxy_controller.go | 56 -- .../mcpremoteproxy_controller_test.go | 28 - .../controllers/mcpremoteproxy_deployment.go | 32 +- .../mcpremoteproxy_deployment_test.go | 7 - .../mcpremoteproxy_reconciler_test.go | 163 +---- .../controllers/mcpremoteproxy_runconfig.go | 73 +- .../mcpremoteproxy_runconfig_test.go | 106 +-- .../controllers/mcpserver_controller.go | 69 +- .../mcpserver_externalauth_runconfig_test.go | 28 +- .../controllers/mcpserver_replicas_test.go | 36 +- .../controllers/mcpserver_runconfig.go | 13 - .../controllers/mcpserver_runconfig_test.go | 121 ---- cmd/thv-operator/pkg/controllerutil/oidc.go | 39 -- cmd/thv-operator/pkg/oidc/resolver_test.go | 663 ++++++++---------- ...cconfig_mcpremoteproxy_integration_test.go | 24 +- ...remoteproxy_validation_integration_test.go | 131 ---- .../mcp-remote-proxy/remoteproxy_helpers.go | 41 +- ...pserver_cel_validation_integration_test.go | 231 +----- .../mcpserver_runconfig_integration_test.go | 107 --- .../mcpserver_sessionstorage_cel_test.go | 8 +- cmd/thv/app/export.go | 6 + ...oolhive.stacklok.dev_mcpremoteproxies.yaml | 229 ------ .../toolhive.stacklok.dev_mcpservers.yaml | 241 +------ ...oolhive.stacklok.dev_mcpremoteproxies.yaml | 229 ------ .../toolhive.stacklok.dev_mcpservers.yaml | 241 +------ deploy/keycloak/mcpserver-with-auth.yaml | 32 +- docs/arch/09-operator-architecture.md | 34 +- docs/operator/crd-api.md | 6 +- .../mcpremoteproxy_with_bearer_token.yaml | 20 +- ...emoteproxy_with_inline_oidc_secretref.yaml | 39 -- .../mcpserver_with_configmap_oidc.yaml | 31 - .../mcpserver_with_inline_oidc.yaml | 34 - .../mcpserver_with_inline_oidc_secretref.yaml | 40 -- .../mcpserver_with_kubernetes_oidc.yaml | 25 - pkg/export/k8s.go | 17 +- pkg/export/k8s_test.go | 24 - .../acceptance_tests/ratelimit_test.go | 35 +- 40 files changed, 512 insertions(+), 2790 deletions(-) delete mode 100644 examples/operator/mcp-servers/mcpremoteproxy_with_inline_oidc_secretref.yaml delete mode 100644 examples/operator/mcp-servers/mcpserver_with_configmap_oidc.yaml delete mode 100644 examples/operator/mcp-servers/mcpserver_with_inline_oidc.yaml delete mode 100644 examples/operator/mcp-servers/mcpserver_with_inline_oidc_secretref.yaml delete mode 100644 examples/operator/mcp-servers/mcpserver_with_kubernetes_oidc.yaml diff --git a/cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go b/cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go index 7a29da0d91..cc126eab8e 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go @@ -36,10 +36,6 @@ type HeaderFromSecret struct { } // MCPRemoteProxySpec defines the desired state of MCPRemoteProxy -// -// +kubebuilder:validation:XValidation:rule="!(has(self.oidcConfig) && has(self.oidcConfigRef))",message="oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef to reference a shared MCPOIDCConfig" -// -//nolint:lll // CEL validation rules exceed line length limit type MCPRemoteProxySpec struct { // RemoteURL is the URL of the remote MCP server to proxy // +kubebuilder:validation:Required @@ -57,12 +53,6 @@ type MCPRemoteProxySpec struct { // +kubebuilder:default=streamable-http Transport string `json:"transport,omitempty"` - // OIDCConfig defines OIDC authentication configuration for the proxy. - // Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead. - // This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. - // +optional - OIDCConfig *OIDCConfigRef `json:"oidcConfig,omitempty"` - // OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. // The referenced MCPOIDCConfig must exist in the same namespace as this MCPRemoteProxy. // Per-server overrides (audience, scopes) are specified here; shared provider config @@ -397,11 +387,6 @@ func (m *MCPRemoteProxy) GetNamespace() string { return m.Namespace } -// GetOIDCConfig returns the OIDC configuration reference -func (m *MCPRemoteProxy) GetOIDCConfig() *OIDCConfigRef { - return m.Spec.OIDCConfig -} - // GetProxyPort returns the proxy port of the MCPRemoteProxy func (m *MCPRemoteProxy) GetProxyPort() int32 { if m.Spec.ProxyPort > 0 { diff --git a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go b/cmd/thv-operator/api/v1alpha1/mcpserver_types.go index b33ccc288d..f5f3b6262d 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpserver_types.go @@ -175,10 +175,9 @@ const SessionStorageProviderRedis = "redis" // MCPServerSpec defines the desired state of MCPServer // -// +kubebuilder:validation:XValidation:rule="!(has(self.oidcConfig) && has(self.oidcConfigRef))",message="oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef to reference a shared MCPOIDCConfig" // +kubebuilder:validation:XValidation:rule="!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == 'redis')",message="rateLimiting requires sessionStorage with provider 'redis'" -// +kubebuilder:validation:XValidation:rule="!(has(self.rateLimiting) && has(self.rateLimiting.perUser)) || has(self.oidcConfig) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)",message="rateLimiting.perUser requires authentication (oidcConfig, oidcConfigRef, or externalAuthConfigRef)" -// +kubebuilder:validation:XValidation:rule="!has(self.rateLimiting) || !has(self.rateLimiting.tools) || self.rateLimiting.tools.all(t, !has(t.perUser)) || has(self.oidcConfig) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)",message="per-tool perUser rate limiting requires authentication (oidcConfig, oidcConfigRef, or externalAuthConfigRef)" +// +kubebuilder:validation:XValidation:rule="!(has(self.rateLimiting) && has(self.rateLimiting.perUser)) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)",message="rateLimiting.perUser requires authentication (oidcConfigRef or externalAuthConfigRef)" +// +kubebuilder:validation:XValidation:rule="!has(self.rateLimiting) || !has(self.rateLimiting.tools) || self.rateLimiting.tools.all(t, !has(t.perUser)) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)",message="per-tool perUser rate limiting requires authentication (oidcConfigRef or externalAuthConfigRef)" // //nolint:lll // CEL validation rules exceed line length limit type MCPServerSpec struct { @@ -262,16 +261,10 @@ type MCPServerSpec struct { // +optional ResourceOverrides *ResourceOverrides `json:"resourceOverrides,omitempty"` - // OIDCConfig defines OIDC authentication configuration for the MCP server. - // Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead. - // This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. - // +optional - OIDCConfig *OIDCConfigRef `json:"oidcConfig,omitempty"` - // OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. // The referenced MCPOIDCConfig must exist in the same namespace as this MCPServer. // Per-server overrides (audience, scopes) are specified here; shared provider config - // lives in the MCPOIDCConfig resource. Mutually exclusive with oidcConfig. + // lives in the MCPOIDCConfig resource. // +optional OIDCConfigRef *MCPOIDCConfigReference `json:"oidcConfigRef,omitempty"` @@ -1080,11 +1073,6 @@ func (m *MCPServer) GetNamespace() string { return m.Namespace } -// GetOIDCConfig returns the OIDC configuration reference -func (m *MCPServer) GetOIDCConfig() *OIDCConfigRef { - return m.Spec.OIDCConfig -} - // GetProxyPort returns the proxy port of the MCPServer func (m *MCPServer) GetProxyPort() int32 { if m.Spec.ProxyPort > 0 { diff --git a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go index b17dcc4675..7c914505c6 100644 --- a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -1278,11 +1278,6 @@ func (in *MCPRemoteProxyList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPRemoteProxySpec) DeepCopyInto(out *MCPRemoteProxySpec) { *out = *in - if in.OIDCConfig != nil { - in, out := &in.OIDCConfig, &out.OIDCConfig - *out = new(OIDCConfigRef) - (*in).DeepCopyInto(*out) - } if in.OIDCConfigRef != nil { in, out := &in.OIDCConfigRef, &out.OIDCConfigRef *out = new(MCPOIDCConfigReference) @@ -1592,11 +1587,6 @@ func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { *out = new(ResourceOverrides) (*in).DeepCopyInto(*out) } - if in.OIDCConfig != nil { - in, out := &in.OIDCConfig, &out.OIDCConfig - *out = new(OIDCConfigRef) - (*in).DeepCopyInto(*out) - } if in.OIDCConfigRef != nil { in, out := &in.OIDCConfigRef, &out.OIDCConfigRef *out = new(MCPOIDCConfigReference) diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go index 94d6bacf3a..c73e902f41 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go @@ -11,7 +11,6 @@ import ( "fmt" "maps" "reflect" - "strings" "time" appsv1 "k8s.io/api/apps/v1" @@ -391,20 +390,6 @@ func (r *MCPRemoteProxyReconciler) validateSpec(ctx context.Context, proxy *mcpv return r.failValidation(proxy, mcpv1alpha1.ConditionReasonRemoteURLInvalid, err) } - // Validate OIDC issuer URL scheme - if err := r.validateOIDCIssuerURL(proxy); err != nil { - reason := mcpv1alpha1.ConditionReasonOIDCIssuerInvalid - if strings.Contains(err.Error(), "HTTP scheme") { - reason = mcpv1alpha1.ConditionReasonOIDCIssuerInsecure - } - return r.failValidation(proxy, reason, err) - } - - // Validate JWKS URL format - if err := r.validateJWKSURL(proxy); err != nil { - return r.failValidation(proxy, mcpv1alpha1.ConditionReasonJWKSURLInvalid, err) - } - // Validate inline Cedar policy syntax if err := r.validateAuthzPolicySyntax(proxy); err != nil { return r.failValidation(proxy, mcpv1alpha1.ConditionReasonAuthzPolicySyntaxInvalid, err) @@ -453,47 +438,6 @@ func setConfigurationInvalidCondition(proxy *mcpv1alpha1.MCPRemoteProxy, reason, }) } -// validateOIDCIssuerURL validates the OIDC issuer URL scheme. -func (*MCPRemoteProxyReconciler) validateOIDCIssuerURL(proxy *mcpv1alpha1.MCPRemoteProxy) error { - oidcConfig := proxy.Spec.OIDCConfig - if oidcConfig == nil { - return nil - } - - switch oidcConfig.Type { - case mcpv1alpha1.OIDCConfigTypeInline: - if oidcConfig.Inline != nil { - return validation.ValidateOIDCIssuerURL(oidcConfig.Inline.Issuer, oidcConfig.Inline.InsecureAllowHTTP) - } - case mcpv1alpha1.OIDCConfigTypeKubernetes: - if oidcConfig.Kubernetes != nil && oidcConfig.Kubernetes.Issuer != "" { - // Kubernetes OIDC issuers must always use HTTPS - return validation.ValidateOIDCIssuerURL(oidcConfig.Kubernetes.Issuer, false) - } - } - return nil -} - -// validateJWKSURL validates the JWKS URL scheme in the OIDC config. -func (*MCPRemoteProxyReconciler) validateJWKSURL(proxy *mcpv1alpha1.MCPRemoteProxy) error { - oidcConfig := proxy.Spec.OIDCConfig - if oidcConfig == nil { - return nil - } - - switch oidcConfig.Type { - case mcpv1alpha1.OIDCConfigTypeInline: - if oidcConfig.Inline != nil { - return validation.ValidateJWKSURL(oidcConfig.Inline.JWKSURL) - } - case mcpv1alpha1.OIDCConfigTypeKubernetes: - if oidcConfig.Kubernetes != nil { - return validation.ValidateJWKSURL(oidcConfig.Kubernetes.JWKSURL) - } - } - return nil -} - // validateAuthzPolicySyntax validates inline Cedar authorization policy syntax. func (*MCPRemoteProxyReconciler) validateAuthzPolicySyntax( proxy *mcpv1alpha1.MCPRemoteProxy, diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go index b833347395..09e8c0b300 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go @@ -57,13 +57,6 @@ func TestMCPRemoteProxyValidateSpec(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.salesforce.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://login.salesforce.com", - Audience: "mcp.salesforce.com", - }, - }, }, }, expectError: false, @@ -77,13 +70,6 @@ func TestMCPRemoteProxyValidateSpec(t *testing.T) { }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, }, }, expectError: true, @@ -101,13 +87,6 @@ func TestMCPRemoteProxyValidateSpec(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.company.com", - Audience: "mcp-proxy", - }, - }, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "exchange-config", }, @@ -159,13 +138,6 @@ func TestMCPRemoteProxyReconcile_CreateResources(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.salesforce.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://login.salesforce.com", - Audience: "mcp.salesforce.com", - }, - }, }, } diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go b/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go index 05a67b3942..ef81f83044 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go @@ -171,7 +171,7 @@ func (r *MCPRemoteProxyReconciler) addTelemetryCABundleVolumes( func (r *MCPRemoteProxyReconciler) buildEnvVarsForProxy( ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy, ) []corev1.EnvVar { - env := []corev1.EnvVar{} + env := r.buildOIDCClientSecretEnvVars(ctx, proxy) // Add token exchange environment variables // Note: Embedded auth server env vars are added separately in deploymentForMCPRemoteProxy @@ -207,9 +207,6 @@ func (r *MCPRemoteProxyReconciler) buildEnvVarsForProxy( } } - // Add OIDC client secret environment variable if using inline config with secretRef - env = append(env, r.buildOIDCClientSecretEnvVars(ctx, proxy)...) - // Add header forward secret environment variables if proxy.Spec.HeaderForward != nil && len(proxy.Spec.HeaderForward.AddHeadersFromSecret) > 0 { // Set secrets provider to environment so runner uses environment variables for secrets. @@ -237,26 +234,35 @@ func (r *MCPRemoteProxyReconciler) buildEnvVarsForProxy( return ctrlutil.EnsureRequiredEnvVars(ctx, env) } -// buildOIDCClientSecretEnvVars returns OIDC client secret env vars when inline OIDC config -// with a client secret ref is used. Returns nil when OIDCConfig is nil or not inline. +// buildOIDCClientSecretEnvVars returns OIDC client secret env vars when the proxy +// references an MCPOIDCConfig with an inline client secret. Returns nil otherwise. func (r *MCPRemoteProxyReconciler) buildOIDCClientSecretEnvVars( ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy, ) []corev1.EnvVar { - if proxy.Spec.OIDCConfig == nil || proxy.Spec.OIDCConfig.Type != "inline" || proxy.Spec.OIDCConfig.Inline == nil { + if proxy.Spec.OIDCConfigRef == nil { + return nil + } + oidcCfg, err := ctrlutil.GetOIDCConfigForServer(ctx, r.Client, proxy.Namespace, proxy.Spec.OIDCConfigRef) + if err != nil { + log.FromContext(ctx).Error(err, "Failed to fetch MCPOIDCConfig for client secret") return nil } - oidcClientSecretEnvVar, err := ctrlutil.GenerateOIDCClientSecretEnvVar( - ctx, r.Client, proxy.Namespace, proxy.Spec.OIDCConfig.Inline.ClientSecretRef, + if oidcCfg == nil || + oidcCfg.Spec.Type != mcpv1alpha1.MCPOIDCConfigTypeInline || + oidcCfg.Spec.Inline == nil { + return nil + } + envVar, err := ctrlutil.GenerateOIDCClientSecretEnvVar( + ctx, r.Client, proxy.Namespace, oidcCfg.Spec.Inline.ClientSecretRef, ) if err != nil { - ctxLogger := log.FromContext(ctx) - ctxLogger.Error(err, "Failed to generate OIDC client secret environment variable") + log.FromContext(ctx).Error(err, "Failed to generate OIDC client secret environment variable") return nil } - if oidcClientSecretEnvVar == nil { + if envVar == nil { return nil } - return []corev1.EnvVar{*oidcClientSecretEnvVar} + return []corev1.EnvVar{*envVar} } // buildHeaderForwardSecretEnvVars builds environment variables for header forward secrets. diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go index 7846db6594..fdbf86583b 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go @@ -862,13 +862,6 @@ func TestBuildEnvVarsForProxy(t *testing.T) { }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "bearer-config", }, diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go index 875b7c5355..81720a9fdd 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go @@ -61,13 +61,6 @@ func TestMCPRemoteProxyFullReconciliation(t *testing.T) { RemoteURL: "https://mcp.salesforce.com", ProxyPort: 8080, Transport: "streamable-http", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://login.salesforce.com", - Audience: "mcp.salesforce.com", - }, - }, }, }, validateResult: func(t *testing.T, proxy *mcpv1alpha1.MCPRemoteProxy, c client.Client) { @@ -134,13 +127,6 @@ func TestMCPRemoteProxyFullReconciliation(t *testing.T) { RemoteURL: "https://mcp.example.com", ProxyPort: 9090, Transport: "sse", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.company.com", - Audience: "mcp-proxy", - }, - }, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "token-exchange", }, @@ -242,13 +228,6 @@ func TestMCPRemoteProxyFullReconciliation(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "non-existent", }, @@ -357,13 +336,6 @@ func TestMCPRemoteProxyConfigChangePropagation(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ Name: "dynamic-tools", }, @@ -438,13 +410,6 @@ func TestMCPRemoteProxyStatusProgression(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, }, } @@ -594,13 +559,6 @@ func TestEnsureAuthzConfigMapShared(t *testing.T) { }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, }, } @@ -658,13 +616,6 @@ func TestRBACClientIntegration(t *testing.T) { }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, }, } @@ -785,58 +736,11 @@ func TestValidateSpecConfigurationConditions(t *testing.T) { conditionStatus metav1.ConditionStatus }{ { - name: "HTTP OIDC issuer is rejected", - proxy: &mcpv1alpha1.MCPRemoteProxy{ - ObjectMeta: metav1.ObjectMeta{Name: "http-oidc-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - RemoteURL: "https://mcp.example.com", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "http://insecure-idp.example.com", - Audience: "test", - }, - }, - }, - }, - expectError: true, - errContains: "HTTP scheme", - expectCondition: mcpv1alpha1.ConditionReasonOIDCIssuerInsecure, - conditionStatus: metav1.ConditionFalse, - }, - { - name: "HTTP OIDC issuer with insecureAllowHTTP is accepted", - proxy: &mcpv1alpha1.MCPRemoteProxy{ - ObjectMeta: metav1.ObjectMeta{Name: "http-insecure-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - RemoteURL: "https://mcp.example.com", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "http://dev-idp.example.com", - Audience: "test", - InsecureAllowHTTP: true, - }, - }, - }, - }, - expectError: false, - expectCondition: mcpv1alpha1.ConditionReasonConfigurationValid, - conditionStatus: metav1.ConditionTrue, - }, - { - name: "HTTPS OIDC issuer is accepted", + name: "valid proxy with no OIDC config", proxy: &mcpv1alpha1.MCPRemoteProxy{ - ObjectMeta: metav1.ObjectMeta{Name: "https-oidc-proxy", Namespace: "default"}, + ObjectMeta: metav1.ObjectMeta{Name: "no-oidc-proxy", Namespace: "default"}, Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test", - }, - }, }, }, expectError: false, @@ -849,13 +753,6 @@ func TestValidateSpecConfigurationConditions(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "invalid-cedar-proxy", Namespace: "default"}, Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test", - }, - }, AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ Type: mcpv1alpha1.AuthzConfigTypeInline, Inline: &mcpv1alpha1.InlineAuthzConfig{ @@ -875,13 +772,6 @@ func TestValidateSpecConfigurationConditions(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "missing-configmap-proxy", Namespace: "default"}, Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test", - }, - }, AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ Type: mcpv1alpha1.AuthzConfigTypeConfigMap, ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ @@ -901,13 +791,6 @@ func TestValidateSpecConfigurationConditions(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "missing-header-secret-proxy", Namespace: "default"}, Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test", - }, - }, HeaderForward: &mcpv1alpha1.HeaderForwardConfig{ AddHeadersFromSecret: []mcpv1alpha1.HeaderFromSecret{ { @@ -932,13 +815,6 @@ func TestValidateSpecConfigurationConditions(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "bad-scheme-proxy", Namespace: "default"}, Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "ftp://bad-scheme.example.com", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test", - }, - }, }, }, expectError: true, @@ -946,27 +822,6 @@ func TestValidateSpecConfigurationConditions(t *testing.T) { expectCondition: mcpv1alpha1.ConditionReasonRemoteURLInvalid, conditionStatus: metav1.ConditionFalse, }, - { - name: "HTTP JWKS URL is rejected", - proxy: &mcpv1alpha1.MCPRemoteProxy{ - ObjectMeta: metav1.ObjectMeta{Name: "http-jwks-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - RemoteURL: "https://mcp.example.com", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test", - JWKSURL: "http://jwks.example.com", - }, - }, - }, - }, - expectError: true, - errContains: "HTTPS", - expectCondition: mcpv1alpha1.ConditionReasonJWKSURLInvalid, - conditionStatus: metav1.ConditionFalse, - }, } for _, tt := range tests { @@ -1041,13 +896,6 @@ func TestValidateAndHandleConfigs(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ Name: "valid-tools", }, @@ -1077,13 +925,6 @@ func TestValidateAndHandleConfigs(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ Name: "non-existent", }, diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go b/cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go index 9a02dec2e4..3e6cb5e876 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go @@ -124,8 +124,7 @@ func (r *MCPRemoteProxyReconciler) createRunConfigFromMCPRemoteProxy( return nil, fmt.Errorf("failed to process AuthzConfig: %w", err) } - // Add OIDC configuration (required for proxy mode) - // Supports both legacy inline OIDCConfig and new MCPOIDCConfigRef paths + // Add OIDC configuration if referenced via MCPOIDCConfigRef resolvedOIDCConfig, err := r.resolveAndAddOIDCConfig(apiCtx, proxy, &options) if err != nil { return nil, err @@ -177,52 +176,46 @@ func (r *MCPRemoteProxyReconciler) createRunConfigFromMCPRemoteProxy( return runConfig, nil } -// resolveAndAddOIDCConfig resolves OIDC configuration from either the shared MCPOIDCConfigRef -// or the legacy inline OIDCConfig, adds the appropriate runner options, and returns the resolved config. +// resolveAndAddOIDCConfig resolves OIDC configuration from the shared MCPOIDCConfigRef, +// adds the appropriate runner options, and returns the resolved config. func (r *MCPRemoteProxyReconciler) resolveAndAddOIDCConfig( ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy, options *[]runner.RunConfigBuilderOption, ) (*oidc.OIDCConfig, error) { - resolver := oidc.NewResolver(r.Client) + if proxy.Spec.OIDCConfigRef == nil { + return nil, nil + } - if proxy.Spec.OIDCConfigRef != nil { - // Resolve from shared MCPOIDCConfig reference - oidcCfg, err := ctrlutil.GetOIDCConfigForServer(ctx, r.Client, proxy.Namespace, proxy.Spec.OIDCConfigRef) - if err != nil { - return nil, fmt.Errorf("failed to get MCPOIDCConfig: %w", err) - } - resolved, err := resolver.ResolveFromConfigRef( - ctx, proxy.Spec.OIDCConfigRef, oidcCfg, proxy.Name, proxy.Namespace, proxy.GetProxyPort(), - ) - if err != nil { - return nil, fmt.Errorf("failed to resolve OIDC config from MCPOIDCConfig ref: %w", err) - } - *options = append(*options, runner.WithOIDCConfig( - resolved.Issuer, - resolved.Audience, - resolved.JWKSURL, - resolved.IntrospectionURL, - resolved.ClientID, - resolved.ClientSecret, - resolved.ThvCABundlePath, - resolved.JWKSAuthTokenPath, - resolved.ResourceURL, - resolved.JWKSAllowPrivateIP, - resolved.InsecureAllowHTTP, - resolved.Scopes, - )) - return resolved, nil - } - - // Use legacy inline OIDCConfig - if err := ctrlutil.AddOIDCConfigOptions(ctx, r.Client, proxy, options); err != nil { - return nil, fmt.Errorf("failed to process OIDCConfig: %w", err) - } - resolved, err := resolver.Resolve(ctx, proxy) + // Resolve from shared MCPOIDCConfig reference + oidcCfg, err := ctrlutil.GetOIDCConfigForServer(ctx, r.Client, proxy.Namespace, proxy.Spec.OIDCConfigRef) if err != nil { - return nil, fmt.Errorf("failed to resolve OIDC config: %w", err) + return nil, fmt.Errorf("failed to get MCPOIDCConfig: %w", err) } + resolver := oidc.NewResolver(r.Client) + resolved, err := resolver.ResolveFromConfigRef( + ctx, proxy.Spec.OIDCConfigRef, oidcCfg, proxy.Name, proxy.Namespace, proxy.GetProxyPort(), + ) + if err != nil { + return nil, fmt.Errorf("failed to resolve OIDC config from MCPOIDCConfig ref: %w", err) + } + if resolved == nil { + return nil, nil + } + *options = append(*options, runner.WithOIDCConfig( + resolved.Issuer, + resolved.Audience, + resolved.JWKSURL, + resolved.IntrospectionURL, + resolved.ClientID, + resolved.ClientSecret, + resolved.ThvCABundlePath, + resolved.JWKSAuthTokenPath, + resolved.ResourceURL, + resolved.JWKSAllowPrivateIP, + resolved.InsecureAllowHTTP, + resolved.Scopes, + )) return resolved, nil } diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go index 24d4f8f249..388fada8e7 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go @@ -55,13 +55,6 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.salesforce.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://login.salesforce.com", - Audience: "mcp.salesforce.com", - }, - }, }, }, expectError: false, @@ -72,9 +65,7 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { assert.Empty(t, config.Image, "Image should be empty for remote proxy") assert.Equal(t, transporttypes.TransportTypeStreamableHTTP, config.Transport, "Should default to streamable-http") assert.Equal(t, 8080, config.Port) - assert.NotNil(t, config.OIDCConfig) - assert.Equal(t, "https://login.salesforce.com", config.OIDCConfig.Issuer) - assert.Equal(t, "mcp.salesforce.com", config.OIDCConfig.Audience) + assert.Nil(t, config.OIDCConfig, "OIDCConfig should be nil when no OIDCConfigRef is set") }, }, { @@ -87,13 +78,6 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ Name: "filter-config", }, @@ -135,13 +119,6 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ Type: mcpv1alpha1.AuthzConfigTypeInline, Inline: &mcpv1alpha1.InlineAuthzConfig{ @@ -175,15 +152,8 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.MCPRemoteProxySpec{ - RemoteURL: "https://mcp.example.com", - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, + RemoteURL: "https://mcp.example.com", + ProxyPort: 8080, TrustProxyHeaders: true, }, }, @@ -204,13 +174,6 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, HeaderForward: &mcpv1alpha1.HeaderForwardConfig{ AddPlaintextHeaders: map[string]string{ "X-Tenant-ID": "tenant-123", @@ -239,13 +202,6 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, HeaderForward: &mcpv1alpha1.HeaderForwardConfig{ AddHeadersFromSecret: []mcpv1alpha1.HeaderFromSecret{ { @@ -288,13 +244,6 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, HeaderForward: &mcpv1alpha1.HeaderForwardConfig{ AddPlaintextHeaders: map[string]string{ "X-Tenant-ID": "tenant-456", @@ -382,13 +331,6 @@ func TestCreateRunConfigFromMCPRemoteProxy_WithTokenExchange(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.salesforce.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.company.com", - Audience: "mcp-proxy", - }, - }, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "salesforce-exchange", }, @@ -458,13 +400,6 @@ func TestCreateRunConfigFromMCPRemoteProxy_WithTokenExchange(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "non-existent", }, @@ -534,13 +469,6 @@ func TestCreateRunConfigFromMCPRemoteProxy_WithBearerToken(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com/api", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "api-bearer-auth", }, @@ -591,13 +519,6 @@ func TestCreateRunConfigFromMCPRemoteProxy_WithBearerToken(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "broken-bearer", }, @@ -627,13 +548,6 @@ func TestCreateRunConfigFromMCPRemoteProxy_WithBearerToken(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "missing-secret-bearer", }, @@ -666,13 +580,6 @@ func TestCreateRunConfigFromMCPRemoteProxy_WithBearerToken(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "missing-key-bearer", }, @@ -878,13 +785,6 @@ func TestEnsureRunConfigConfigMapForRemoteProxy(t *testing.T) { Spec: mcpv1alpha1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, }, }, existingCM: nil, diff --git a/cmd/thv-operator/controllers/mcpserver_controller.go b/cmd/thv-operator/controllers/mcpserver_controller.go index f69b8e226c..44068ccf43 100644 --- a/cmd/thv-operator/controllers/mcpserver_controller.go +++ b/cmd/thv-operator/controllers/mcpserver_controller.go @@ -536,24 +536,6 @@ func (r *MCPServerReconciler) validateGroupRef(ctx context.Context, mcpServer *m } -// getCABundleRef extracts the CABundleRef from the OIDC configuration based on type -func getCABundleRef(oidcConfig *mcpv1alpha1.OIDCConfigRef) *mcpv1alpha1.CABundleSource { - if oidcConfig == nil { - return nil - } - switch oidcConfig.Type { - case mcpv1alpha1.OIDCConfigTypeInline: - if oidcConfig.Inline != nil { - return oidcConfig.Inline.CABundleRef - } - case mcpv1alpha1.OIDCConfigTypeConfigMap: - if oidcConfig.ConfigMap != nil { - return oidcConfig.ConfigMap.CABundleRef - } - } - return nil -} - // setCABundleRefCondition sets the CA bundle validation status condition func setCABundleRefCondition(mcpServer *mcpv1alpha1.MCPServer, status metav1.ConditionStatus, reason, message string) { meta.SetStatusCondition(&mcpServer.Status.Conditions, metav1.Condition{ @@ -566,12 +548,12 @@ func setCABundleRefCondition(mcpServer *mcpv1alpha1.MCPServer, status metav1.Con } // validateCABundleRef validates the CABundleRef ConfigMap reference if specified. -// Checks both the legacy OIDCConfig path and the new MCPOIDCConfig path. +// Checks the MCPOIDCConfig path for CA bundle references. func (r *MCPServerReconciler) validateCABundleRef(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) { - caBundleRef := getCABundleRef(mcpServer.Spec.OIDCConfig) + var caBundleRef *mcpv1alpha1.CABundleSource - // Also check MCPOIDCConfig inline CA bundle if using the new reference path - if caBundleRef == nil && mcpServer.Spec.OIDCConfigRef != nil { + // Check MCPOIDCConfig inline CA bundle if using the reference path + if mcpServer.Spec.OIDCConfigRef != nil { oidcCfg, err := ctrlutil.GetOIDCConfigForServer(ctx, r.Client, mcpServer.Namespace, mcpServer.Spec.OIDCConfigRef) if err == nil && oidcCfg != nil && oidcCfg.Spec.Type == mcpv1alpha1.MCPOIDCConfigTypeInline && @@ -1049,19 +1031,8 @@ func (r *MCPServerReconciler) deploymentForMCPServer( } } - // Add OIDC client secret environment variable if using inline config with secretRef - // Supports both legacy OIDCConfig and new MCPOIDCConfigRef paths - if m.Spec.OIDCConfig != nil && m.Spec.OIDCConfig.Inline != nil { - oidcClientSecretEnvVar, err := ctrlutil.GenerateOIDCClientSecretEnvVar( - ctx, r.Client, m.Namespace, m.Spec.OIDCConfig.Inline.ClientSecretRef, - ) - if err != nil { - ctxLogger := log.FromContext(ctx) - ctxLogger.Error(err, "Failed to generate OIDC client secret environment variable") - } else if oidcClientSecretEnvVar != nil { - env = append(env, *oidcClientSecretEnvVar) - } - } else if m.Spec.OIDCConfigRef != nil { + // Add OIDC client secret environment variable if using MCPOIDCConfigRef with inline config + if m.Spec.OIDCConfigRef != nil { // Check MCPOIDCConfig inline config for client secret oidcCfg, err := ctrlutil.GetOIDCConfigForServer(ctx, r.Client, m.Namespace, m.Spec.OIDCConfigRef) if err == nil && oidcCfg != nil && @@ -1134,12 +1105,8 @@ func (r *MCPServerReconciler) deploymentForMCPServer( volumes = append(volumes, *authzVolume) } - // Add OIDC CA bundle volume if configured (supports both legacy and MCPOIDCConfig paths) - if m.Spec.OIDCConfig != nil { - caVolumes, caMounts := ctrlutil.AddOIDCCABundleVolumes(m.Spec.OIDCConfig) - volumes = append(volumes, caVolumes...) - volumeMounts = append(volumeMounts, caMounts...) - } else if m.Spec.OIDCConfigRef != nil { + // Add OIDC CA bundle volume if configured via MCPOIDCConfigRef + if m.Spec.OIDCConfigRef != nil { oidcCfg, err := ctrlutil.GetOIDCConfigForServer(ctx, r.Client, m.Namespace, m.Spec.OIDCConfigRef) if err == nil && oidcCfg != nil { caVolumes, caMounts := ctrlutil.AddOIDCConfigRefCABundleVolumes(oidcCfg) @@ -1657,19 +1624,8 @@ func (r *MCPServerReconciler) deploymentNeedsUpdate( expectedProxyEnv = append(expectedProxyEnv, tokenExchangeEnvVars...) } - // Add OIDC client secret environment variable if using inline config with secretRef - // Supports both legacy OIDCConfig and new MCPOIDCConfigRef paths - if mcpServer.Spec.OIDCConfig != nil && mcpServer.Spec.OIDCConfig.Inline != nil { - oidcClientSecretEnvVar, err := ctrlutil.GenerateOIDCClientSecretEnvVar( - ctx, r.Client, mcpServer.Namespace, mcpServer.Spec.OIDCConfig.Inline.ClientSecretRef, - ) - if err != nil { - return true - } - if oidcClientSecretEnvVar != nil { - expectedProxyEnv = append(expectedProxyEnv, *oidcClientSecretEnvVar) - } - } else if mcpServer.Spec.OIDCConfigRef != nil { + // Add OIDC client secret environment variable if using MCPOIDCConfigRef with inline config + if mcpServer.Spec.OIDCConfigRef != nil { oidcCfg, err := ctrlutil.GetOIDCConfigForServer(ctx, r.Client, mcpServer.Namespace, mcpServer.Spec.OIDCConfigRef) if err != nil { return true @@ -2364,8 +2320,7 @@ func (r *MCPServerReconciler) validateRateLimitConfig(ctx context.Context, mcpSe return } - authEnabled := mcpServer.Spec.OIDCConfig != nil || - mcpServer.Spec.OIDCConfigRef != nil || + authEnabled := mcpServer.Spec.OIDCConfigRef != nil || mcpServer.Spec.ExternalAuthConfigRef != nil hasPerUser := rl.PerUser != nil @@ -2381,7 +2336,7 @@ func (r *MCPServerReconciler) validateRateLimitConfig(ctx context.Context, mcpSe if hasPerUser && !authEnabled { setRateLimitConfigCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonRateLimitPerUserRequiresAuth, - "perUser rate limiting requires authentication to be enabled (oidcConfig, oidcConfigRef, or externalAuthConfigRef)") + "perUser rate limiting requires authentication to be enabled (oidcConfigRef or externalAuthConfigRef)") } else { setRateLimitConfigCondition(mcpServer, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonRateLimitConfigValid, diff --git a/cmd/thv-operator/controllers/mcpserver_externalauth_runconfig_test.go b/cmd/thv-operator/controllers/mcpserver_externalauth_runconfig_test.go index 537dc4bcab..65c23611a1 100644 --- a/cmd/thv-operator/controllers/mcpserver_externalauth_runconfig_test.go +++ b/cmd/thv-operator/controllers/mcpserver_externalauth_runconfig_test.go @@ -752,15 +752,10 @@ func TestCreateRunConfigFromMCPServer_WithExternalAuth(t *testing.T) { ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ Name: "embedded-auth-config", }, - // OIDCConfig is required for embedded auth server - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - ResourceURL: "http://embedded-auth-server.default.svc.cluster.local:8080", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://kubernetes.default.svc", - Audience: "toolhive", - Scopes: []string{"openid", "offline_access", "mcp:tools"}, - }, + OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + Name: "embedded-oidc", + Audience: "toolhive", + Scopes: []string{"openid", "offline_access", "mcp:tools"}, }, }, }, @@ -842,6 +837,21 @@ func TestCreateRunConfigFromMCPServer_WithExternalAuth(t *testing.T) { if tt.clientSecret != nil { objects = append(objects, tt.clientSecret) } + // Add MCPOIDCConfig if the MCPServer references one + if tt.mcpServer.Spec.OIDCConfigRef != nil { + objects = append(objects, &mcpv1alpha1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: tt.mcpServer.Spec.OIDCConfigRef.Name, + Namespace: tt.mcpServer.Namespace, + }, + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: "https://kubernetes.default.svc", + }, + }, + }) + } fakeClient := fake.NewClientBuilder(). WithScheme(scheme). diff --git a/cmd/thv-operator/controllers/mcpserver_replicas_test.go b/cmd/thv-operator/controllers/mcpserver_replicas_test.go index 08d9326e98..1035c4f513 100644 --- a/cmd/thv-operator/controllers/mcpserver_replicas_test.go +++ b/cmd/thv-operator/controllers/mcpserver_replicas_test.go @@ -1009,7 +1009,7 @@ func TestRateLimitConfigValidation(t *testing.T) { Provider: mcpv1alpha1.SessionStorageProviderRedis, Address: "redis:6379", }, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{Type: "kubernetes"}, + OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test"}, RateLimiting: &mcpv1alpha1.RateLimitConfig{ PerUser: &mcpv1alpha1.RateLimitBucket{ MaxTokens: 100, @@ -1103,11 +1103,39 @@ func TestRateLimitConfigValidation(t *testing.T) { } testScheme := createTestScheme() - fakeClient := fake.NewClientBuilder(). + clientBuilder := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). - Build() + WithStatusSubresource(&mcpv1alpha1.MCPServer{}) + + // Add referenced MCPOIDCConfig to fake client if spec references one + if mcpServer.Spec.OIDCConfigRef != nil { + oidcCfg := &mcpv1alpha1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: mcpServer.Spec.OIDCConfigRef.Name, + Namespace: namespace, + }, + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: "https://auth.example.com", + }, + }, + } + oidcCfg.Status.Conditions = []metav1.Condition{ + { + Type: mcpv1alpha1.ConditionTypeValid, + Status: metav1.ConditionTrue, + Reason: "Valid", + LastTransitionTime: metav1.Now(), + }, + } + clientBuilder = clientBuilder. + WithObjects(oidcCfg). + WithStatusSubresource(&mcpv1alpha1.MCPOIDCConfig{}) + } + + fakeClient := clientBuilder.Build() reconciler := newTestMCPServerReconciler(fakeClient, testScheme, kubernetes.PlatformKubernetes) diff --git a/cmd/thv-operator/controllers/mcpserver_runconfig.go b/cmd/thv-operator/controllers/mcpserver_runconfig.go index 01a8bb40ef..3be6df9a9b 100644 --- a/cmd/thv-operator/controllers/mcpserver_runconfig.go +++ b/cmd/thv-operator/controllers/mcpserver_runconfig.go @@ -235,19 +235,6 @@ func (r *MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1alpha1.MCPSer resolvedOIDCConfig.Scopes, )) } - } else { - // Legacy path: resolve from inline OIDCConfig - if err := ctrlutil.AddOIDCConfigOptions(ctx, r.Client, m, &options); err != nil { - return nil, fmt.Errorf("failed to process OIDCConfig: %w", err) - } - if m.Spec.OIDCConfig != nil { - resolver := oidc.NewResolver(r.Client) - var err error - resolvedOIDCConfig, err = resolver.Resolve(ctx, m) - if err != nil { - return nil, fmt.Errorf("failed to resolve OIDC config: %w", err) - } - } } // Add external auth configuration if specified (updated call) diff --git a/cmd/thv-operator/controllers/mcpserver_runconfig_test.go b/cmd/thv-operator/controllers/mcpserver_runconfig_test.go index 0750c29474..4199f804ae 100644 --- a/cmd/thv-operator/controllers/mcpserver_runconfig_test.go +++ b/cmd/thv-operator/controllers/mcpserver_runconfig_test.go @@ -465,77 +465,6 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { assert.Equal(t, "[]", cedarCfg.Options.EntitiesJSON) }, }, - { - name: "with inline OIDC authentication configuration", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "oidc-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: testImage, - Transport: stdioTransport, - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "toolhive-api", - JWKSURL: "https://auth.example.com/.well-known/jwks.json", - IntrospectionURL: "https://auth.example.com/oauth/introspect", - ClientID: "toolhive-client", - JWKSAuthTokenPath: "/etc/auth/token", - JWKSAllowPrivateIP: true, - }, - }, - }, - }, - //nolint:thelper // We want to see the error at the specific line - expected: func(t *testing.T, config *runner.RunConfig) { - assert.Equal(t, "oidc-server", config.Name) - // Verify OIDC config is set - assert.NotNil(t, config.OIDCConfig) - assert.Equal(t, "https://auth.example.com", config.OIDCConfig.Issuer) - assert.Equal(t, "toolhive-api", config.OIDCConfig.Audience) - assert.Equal(t, "https://auth.example.com/.well-known/jwks.json", config.OIDCConfig.JWKSURL) - assert.Equal(t, "https://auth.example.com/oauth/introspect", config.OIDCConfig.IntrospectionURL) - assert.Equal(t, "toolhive-client", config.OIDCConfig.ClientID) - assert.Equal(t, "/etc/auth/token", config.OIDCConfig.AuthTokenFile) - assert.True(t, config.OIDCConfig.AllowPrivateIP) - }, - }, - { - name: "with inline OIDC using CABundleRef", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "oidc-cabundle-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: testImage, - Transport: stdioTransport, - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "toolhive-api", - CABundleRef: &mcpv1alpha1.CABundleSource{ - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-ca-bundle"}, - }, - }, - }, - }, - }, - }, - //nolint:thelper // We want to see the error at the specific line - expected: func(t *testing.T, config *runner.RunConfig) { - assert.Equal(t, "oidc-cabundle-server", config.Name) - assert.NotNil(t, config.OIDCConfig) - assert.Equal(t, "/config/certs/my-ca-bundle/ca.crt", config.OIDCConfig.CACertPath) - }, - }, } for _, tt := range tests { @@ -882,56 +811,6 @@ func TestEnsureRunConfigConfigMap(t *testing.T) { assert.Equal(t, `[{"uid": {"type": "User", "id": "user1"}, "attrs": {}}]`, cedarCfg.Options.EntitiesJSON) }, }, - { - name: "configmap with inline OIDC authentication configuration", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "oidc-test", - Namespace: "toolhive-system", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: "ghcr.io/example/server:v1.0.0", - Transport: "stdio", - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "toolhive-api", - JWKSURL: "https://auth.example.com/.well-known/jwks.json", - IntrospectionURL: "https://auth.example.com/oauth/introspect", - ClientID: "toolhive-client", - JWKSAuthTokenPath: "/etc/auth/token", - JWKSAllowPrivateIP: true, - }, - }, - }, - }, - existingCM: nil, - expectError: false, - validateContent: func(t *testing.T, cm *corev1.ConfigMap) { - t.Helper() - assert.Equal(t, "oidc-test-runconfig", cm.Name) - assert.Equal(t, "toolhive-system", cm.Namespace) - assert.Contains(t, cm.Data, "runconfig.json") - // Parse and validate OIDC authentication configuration in runconfig.json - var runConfig runner.RunConfig - err := json.Unmarshal([]byte(cm.Data["runconfig.json"]), &runConfig) - require.NoError(t, err) - // Verify basic fields - assert.Equal(t, "oidc-test", runConfig.Name) - assert.Equal(t, "ghcr.io/example/server:v1.0.0", runConfig.Image) - // Verify OIDC authentication configuration is properly serialized - assert.NotNil(t, runConfig.OIDCConfig, "OIDCConfig should be present in runconfig.json") - assert.Equal(t, "https://auth.example.com", runConfig.OIDCConfig.Issuer) - assert.Equal(t, "toolhive-api", runConfig.OIDCConfig.Audience) - assert.Equal(t, "https://auth.example.com/.well-known/jwks.json", runConfig.OIDCConfig.JWKSURL) - assert.Equal(t, "https://auth.example.com/oauth/introspect", runConfig.OIDCConfig.IntrospectionURL) - assert.Equal(t, "toolhive-client", runConfig.OIDCConfig.ClientID) - assert.Equal(t, "/etc/auth/token", runConfig.OIDCConfig.AuthTokenFile) - assert.True(t, runConfig.OIDCConfig.AllowPrivateIP) - }, - }, { name: "configmap with audit configuration enabled", mcpServer: &mcpv1alpha1.MCPServer{ diff --git a/cmd/thv-operator/pkg/controllerutil/oidc.go b/cmd/thv-operator/pkg/controllerutil/oidc.go index d3f6dd9933..6ada841fea 100644 --- a/cmd/thv-operator/pkg/controllerutil/oidc.go +++ b/cmd/thv-operator/pkg/controllerutil/oidc.go @@ -12,47 +12,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" - "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" - "github.com/stacklok/toolhive/pkg/runner" ) -// AddOIDCConfigOptions adds OIDC configuration options to builder options -func AddOIDCConfigOptions( - ctx context.Context, - c client.Client, - res oidc.OIDCConfigurable, - options *[]runner.RunConfigBuilderOption, -) error { - // Use the OIDC resolver to get configuration - resolver := oidc.NewResolver(c) - oidcConfig, err := resolver.Resolve(ctx, res) - if err != nil { - return fmt.Errorf("failed to resolve OIDC configuration: %w", err) - } - - if oidcConfig == nil { - return nil - } - - // Add OIDC config to options - *options = append(*options, runner.WithOIDCConfig( - oidcConfig.Issuer, - oidcConfig.Audience, - oidcConfig.JWKSURL, - oidcConfig.IntrospectionURL, - oidcConfig.ClientID, - oidcConfig.ClientSecret, - oidcConfig.ThvCABundlePath, - oidcConfig.JWKSAuthTokenPath, - oidcConfig.ResourceURL, - oidcConfig.JWKSAllowPrivateIP, - oidcConfig.InsecureAllowHTTP, - oidcConfig.Scopes, - )) - - return nil -} - // GetOIDCConfigForServer fetches the MCPOIDCConfig referenced by an MCPServer. // Returns nil if the ref is nil or the resource is not found. func GetOIDCConfigForServer( diff --git a/cmd/thv-operator/pkg/oidc/resolver_test.go b/cmd/thv-operator/pkg/oidc/resolver_test.go index 2e79d3a018..080808dc9c 100644 --- a/cmd/thv-operator/pkg/oidc/resolver_test.go +++ b/cmd/thv-operator/pkg/oidc/resolver_test.go @@ -17,27 +17,36 @@ import ( mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" ) +// testResource is a minimal OIDCConfigurable implementation for testing. +type testResource struct { + name string + namespace string + proxyPort int32 + oidcConfig *mcpv1alpha1.OIDCConfigRef +} + +func (t *testResource) GetName() string { return t.name } +func (t *testResource) GetNamespace() string { return t.namespace } +func (t *testResource) GetProxyPort() int32 { return t.proxyPort } +func (t *testResource) GetOIDCConfig() *mcpv1alpha1.OIDCConfigRef { return t.oidcConfig } + func TestResolve_KubernetesType(t *testing.T) { t.Parallel() tests := []struct { - name string - mcpServer *mcpv1alpha1.MCPServer - expected *OIDCConfig + name string + resource *testResource + expected *OIDCConfig }{ { name: "kubernetes with defaults", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeKubernetes, - Kubernetes: nil, // nil config should use defaults - }, + resource: &testResource{ + name: "test-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeKubernetes, + Kubernetes: nil, // nil config should use defaults }, }, expected: &OIDCConfig{ @@ -51,23 +60,19 @@ func TestResolve_KubernetesType(t *testing.T) { }, { name: "kubernetes with custom values", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "custom-server", - Namespace: "custom-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 9090, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeKubernetes, - ResourceURL: "https://custom-resource.example.com", - Kubernetes: &mcpv1alpha1.KubernetesOIDCConfig{ - Issuer: "https://custom-issuer.example.com", - Audience: "custom-audience", - JWKSURL: "https://custom-issuer.example.com/.well-known/jwks.json", - IntrospectionURL: "https://custom-issuer.example.com/introspect", - UseClusterAuth: func(b bool) *bool { return &b }(true), - }, + resource: &testResource{ + name: "custom-server", + namespace: "custom-ns", + proxyPort: 9090, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeKubernetes, + ResourceURL: "https://custom-resource.example.com", + Kubernetes: &mcpv1alpha1.KubernetesOIDCConfig{ + Issuer: "https://custom-issuer.example.com", + Audience: "custom-audience", + JWKSURL: "https://custom-issuer.example.com/.well-known/jwks.json", + IntrospectionURL: "https://custom-issuer.example.com/introspect", + UseClusterAuth: func(b bool) *bool { return &b }(true), }, }, }, @@ -84,18 +89,14 @@ func TestResolve_KubernetesType(t *testing.T) { }, { name: "kubernetes with UseClusterAuth false", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "no-cluster-auth-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeKubernetes, - Kubernetes: &mcpv1alpha1.KubernetesOIDCConfig{ - UseClusterAuth: func(b bool) *bool { return &b }(false), - }, + resource: &testResource{ + name: "no-cluster-auth-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeKubernetes, + Kubernetes: &mcpv1alpha1.KubernetesOIDCConfig{ + UseClusterAuth: func(b bool) *bool { return &b }(false), }, }, }, @@ -111,14 +112,10 @@ func TestResolve_KubernetesType(t *testing.T) { }, { name: "nil oidc config returns nil", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - OIDCConfig: nil, - }, + resource: &testResource{ + name: "test-server", + namespace: "test-ns", + oidcConfig: nil, }, expected: nil, }, @@ -128,7 +125,7 @@ func TestResolve_KubernetesType(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() resolver := NewResolver(nil) - config, err := resolver.Resolve(context.Background(), tt.mcpServer) + config, err := resolver.Resolve(context.Background(), tt.resource) require.NoError(t, err) assert.Equal(t, tt.expected, config) }) @@ -140,25 +137,21 @@ func TestResolve_ConfigMapType(t *testing.T) { tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer + resource *testResource configMap *corev1.ConfigMap expected *OIDCConfig expectErr bool }{ { name: "configmap with all values", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "oidc-config", - }, + resource: &testResource{ + name: "test-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ + Name: "oidc-config", }, }, }, @@ -196,18 +189,14 @@ func TestResolve_ConfigMapType(t *testing.T) { }, { name: "configmap with partial values", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "partial-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ResourceURL: "https://custom-resource.example.com", - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "partial-config", - }, + resource: &testResource{ + name: "partial-server", + namespace: "test-ns", + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeConfigMap, + ResourceURL: "https://custom-resource.example.com", + ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ + Name: "partial-config", }, }, }, @@ -231,18 +220,14 @@ func TestResolve_ConfigMapType(t *testing.T) { }, { name: "configmap with jwksAllowPrivateIP independent of protectedResourceAllowPrivateIP", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "independent-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "independent-config", - }, + resource: &testResource{ + name: "independent-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ + Name: "independent-config", }, }, }, @@ -268,18 +253,14 @@ func TestResolve_ConfigMapType(t *testing.T) { }, { name: "configmap with insecureAllowHTTP enabled", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "insecure-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "insecure-config", - }, + resource: &testResource{ + name: "insecure-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ + Name: "insecure-config", }, }, }, @@ -305,18 +286,14 @@ func TestResolve_ConfigMapType(t *testing.T) { }, { name: "configmap with scopes", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "scopes-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "scopes-config", - }, + resource: &testResource{ + name: "scopes-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ + Name: "scopes-config", }, }, }, @@ -343,18 +320,14 @@ func TestResolve_ConfigMapType(t *testing.T) { }, { name: "configmap with scopes containing whitespace", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "whitespace-scopes-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "whitespace-scopes-config", - }, + resource: &testResource{ + name: "whitespace-scopes-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ + Name: "whitespace-scopes-config", }, }, }, @@ -378,18 +351,14 @@ func TestResolve_ConfigMapType(t *testing.T) { }, { name: "configmap with empty scopes", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "empty-scopes-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "empty-scopes-config", - }, + resource: &testResource{ + name: "empty-scopes-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ + Name: "empty-scopes-config", }, }, }, @@ -413,17 +382,13 @@ func TestResolve_ConfigMapType(t *testing.T) { }, { name: "configmap not found", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "missing-config", - }, + resource: &testResource{ + name: "test-server", + namespace: "test-ns", + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ + Name: "missing-config", }, }, }, @@ -431,37 +396,29 @@ func TestResolve_ConfigMapType(t *testing.T) { }, { name: "nil configmap ref returns nil", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: nil, - }, + resource: &testResource{ + name: "test-server", + namespace: "test-ns", + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeConfigMap, + ConfigMap: nil, }, }, expected: nil, }, { name: "configmap with caBundleRef", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "oidc-config", - CABundleRef: &mcpv1alpha1.CABundleSource{ - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "ca-bundle"}, - }, + resource: &testResource{ + name: "test-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ + Name: "oidc-config", + CABundleRef: &mcpv1alpha1.CABundleSource{ + ConfigMapRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "ca-bundle"}, }, }, }, @@ -506,7 +463,7 @@ func TestResolve_ConfigMapType(t *testing.T) { Build() resolver := NewResolver(fakeClient) - config, err := resolver.Resolve(context.Background(), tt.mcpServer) + config, err := resolver.Resolve(context.Background(), tt.resource) if tt.expectErr { assert.Error(t, err) @@ -522,40 +479,36 @@ func TestResolve_InlineType(t *testing.T) { t.Parallel() tests := []struct { - name string - mcpServer *mcpv1alpha1.MCPServer - expected *OIDCConfig + name string + resource *testResource + expected *OIDCConfig }{ { name: "inline with all values", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://inline.example.com", - Audience: "inline-audience", - JWKSURL: "https://inline.example.com/.well-known/jwks.json", - IntrospectionURL: "https://inline.example.com/introspect", - ClientID: "inline-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ - Name: "inline-secret", - Key: "client-secret", - }, - CABundleRef: &mcpv1alpha1.CABundleSource{ - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "inline-ca"}, - }, + resource: &testResource{ + name: "test-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCConfig{ + Issuer: "https://inline.example.com", + Audience: "inline-audience", + JWKSURL: "https://inline.example.com/.well-known/jwks.json", + IntrospectionURL: "https://inline.example.com/introspect", + ClientID: "inline-client", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: "inline-secret", + Key: "client-secret", + }, + CABundleRef: &mcpv1alpha1.CABundleSource{ + ConfigMapRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "inline-ca"}, }, - JWKSAuthTokenPath: "/etc/auth/inline-token", - JWKSAllowPrivateIP: true, - ProtectedResourceAllowPrivateIP: true, }, + JWKSAuthTokenPath: "/etc/auth/inline-token", + JWKSAllowPrivateIP: true, + ProtectedResourceAllowPrivateIP: true, }, }, }, @@ -574,20 +527,16 @@ func TestResolve_InlineType(t *testing.T) { }, { name: "inline with custom resource URL", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "custom-server", - Namespace: "custom-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 9090, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - ResourceURL: "https://custom-resource.example.com", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://inline.example.com", - Audience: "inline-audience", - }, + resource: &testResource{ + name: "custom-server", + namespace: "custom-ns", + proxyPort: 9090, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeInline, + ResourceURL: "https://custom-resource.example.com", + Inline: &mcpv1alpha1.InlineOIDCConfig{ + Issuer: "https://inline.example.com", + Audience: "inline-audience", }, }, }, @@ -599,22 +548,18 @@ func TestResolve_InlineType(t *testing.T) { }, { name: "inline with insecureAllowHTTP enabled", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "insecure-inline-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "http://localhost:8080/realms/test", - Audience: "test-audience", - JWKSURL: "http://localhost:8080/realms/test/protocol/openid-connect/certs", - JWKSAllowPrivateIP: true, - InsecureAllowHTTP: true, - }, + resource: &testResource{ + name: "insecure-inline-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCConfig{ + Issuer: "http://localhost:8080/realms/test", + Audience: "test-audience", + JWKSURL: "http://localhost:8080/realms/test/protocol/openid-connect/certs", + JWKSAllowPrivateIP: true, + InsecureAllowHTTP: true, }, }, }, @@ -629,21 +574,17 @@ func TestResolve_InlineType(t *testing.T) { }, { name: "inline with protectedResourceAllowPrivateIP independent of jwksAllowPrivateIP", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "protected-resource-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - ProtectedResourceAllowPrivateIP: true, - JWKSAllowPrivateIP: false, - }, + resource: &testResource{ + name: "protected-resource-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCConfig{ + Issuer: "https://auth.example.com", + Audience: "test-audience", + ProtectedResourceAllowPrivateIP: true, + JWKSAllowPrivateIP: false, }, }, }, @@ -657,20 +598,16 @@ func TestResolve_InlineType(t *testing.T) { }, { name: "inline with scopes", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "scopes-inline-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - Scopes: []string{"openid", "profile", "email"}, - }, + resource: &testResource{ + name: "scopes-inline-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCConfig{ + Issuer: "https://auth.example.com", + Audience: "test-audience", + Scopes: []string{"openid", "profile", "email"}, }, }, }, @@ -683,16 +620,12 @@ func TestResolve_InlineType(t *testing.T) { }, { name: "nil inline config returns nil", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: nil, - }, + resource: &testResource{ + name: "test-server", + namespace: "test-ns", + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeInline, + Inline: nil, }, }, expected: nil, @@ -703,7 +636,7 @@ func TestResolve_InlineType(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() resolver := NewResolver(nil) - config, err := resolver.Resolve(context.Background(), tt.mcpServer) + config, err := resolver.Resolve(context.Background(), tt.resource) require.NoError(t, err) assert.Equal(t, tt.expected, config) }) @@ -713,20 +646,16 @@ func TestResolve_InlineType(t *testing.T) { func TestResolve_UnknownType(t *testing.T) { t.Parallel() - mcpServer := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "unknown-type", - }, + res := &testResource{ + name: "test-server", + namespace: "test-ns", + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: "unknown-type", }, } resolver := NewResolver(nil) - config, err := resolver.Resolve(context.Background(), mcpServer) + config, err := resolver.Resolve(context.Background(), res) assert.Error(t, err) assert.Contains(t, err.Error(), "unknown OIDC config type") @@ -736,23 +665,19 @@ func TestResolve_UnknownType(t *testing.T) { func TestResolve_NoClientForConfigMap(t *testing.T) { t.Parallel() - mcpServer := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "test-config", - }, + res := &testResource{ + name: "test-server", + namespace: "test-ns", + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeConfigMap, + ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ + Name: "test-config", }, }, } resolver := NewResolver(nil) // No client provided - config, err := resolver.Resolve(context.Background(), mcpServer) + config, err := resolver.Resolve(context.Background(), res) assert.Error(t, err) assert.Contains(t, err.Error(), "kubernetes client is required") @@ -763,29 +688,25 @@ func TestResolve_InlineWithClientSecretRef(t *testing.T) { t.Parallel() tests := []struct { - name string - mcpServer *mcpv1alpha1.MCPServer - expected *OIDCConfig + name string + resource *testResource + expected *OIDCConfig }{ { name: "clientSecretRef set - clientSecret should be empty", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - Audience: "test-audience", - ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ - Name: "oidc-secret", - Key: "client-secret", - }, + resource: &testResource{ + name: "test-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCConfig{ + Issuer: "https://example.com", + Audience: "test-audience", + ClientID: "test-client", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: "oidc-secret", + Key: "client-secret", }, }, }, @@ -800,23 +721,19 @@ func TestResolve_InlineWithClientSecretRef(t *testing.T) { }, { name: "clientSecretRef with clientID - clientSecret not in resolved config", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - Audience: "test-audience", - ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ - Name: "oidc-secret", - Key: "client-secret", - }, + resource: &testResource{ + name: "test-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCConfig{ + Issuer: "https://example.com", + Audience: "test-audience", + ClientID: "test-client", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: "oidc-secret", + Key: "client-secret", }, }, }, @@ -830,20 +747,16 @@ func TestResolve_InlineWithClientSecretRef(t *testing.T) { }, { name: "no clientSecretRef - clientSecret empty in resolved config", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - Audience: "test-audience", - ClientID: "test-client", - }, + resource: &testResource{ + name: "test-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCConfig{ + Issuer: "https://example.com", + Audience: "test-audience", + ClientID: "test-client", }, }, }, @@ -860,7 +773,7 @@ func TestResolve_InlineWithClientSecretRef(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() resolver := NewResolver(nil) - config, err := resolver.Resolve(context.Background(), tt.mcpServer) + config, err := resolver.Resolve(context.Background(), tt.resource) require.NoError(t, err) assert.Equal(t, tt.expected, config) }) @@ -872,29 +785,25 @@ func TestResolve_InlineWithCABundleRef(t *testing.T) { tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer + resource *testResource expected *OIDCConfig expectErr bool errMsg string }{ { name: "valid caBundleRef", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - Audience: "test-audience", - CABundleRef: &mcpv1alpha1.CABundleSource{ - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "ca-bundle"}, - }, + resource: &testResource{ + name: "test-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCConfig{ + Issuer: "https://example.com", + Audience: "test-audience", + CABundleRef: &mcpv1alpha1.CABundleSource{ + ConfigMapRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "ca-bundle"}, }, }, }, @@ -909,20 +818,16 @@ func TestResolve_InlineWithCABundleRef(t *testing.T) { }, { name: "caBundleRef without configMapRef fails", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - Audience: "test-audience", - CABundleRef: &mcpv1alpha1.CABundleSource{}, - }, + resource: &testResource{ + name: "test-server", + namespace: "test-ns", + proxyPort: 8080, + oidcConfig: &mcpv1alpha1.OIDCConfigRef{ + Type: mcpv1alpha1.OIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCConfig{ + Issuer: "https://example.com", + Audience: "test-audience", + CABundleRef: &mcpv1alpha1.CABundleSource{}, }, }, }, @@ -935,7 +840,7 @@ func TestResolve_InlineWithCABundleRef(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() resolver := NewResolver(nil) - config, err := resolver.Resolve(context.Background(), tt.mcpServer) + config, err := resolver.Resolve(context.Background(), tt.resource) if tt.expectErr { require.Error(t, err) diff --git a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_mcpremoteproxy_integration_test.go b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_mcpremoteproxy_integration_test.go index 1b95278ab3..18ec9ca781 100644 --- a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_mcpremoteproxy_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_mcpremoteproxy_integration_test.go @@ -20,9 +20,8 @@ const ( testRemoteURL = "https://remote.example.com/mcp" ) -// newTestMCPRemoteProxy creates an MCPRemoteProxy with either an OIDCConfigRef pointing -// to a shared MCPOIDCConfig (when oidcConfigRefName is non-empty) or a legacy inline -// OIDCConfig. The two fields are mutually exclusive per CEL validation. +// newTestMCPRemoteProxy creates an MCPRemoteProxy with an optional OIDCConfigRef pointing +// to a shared MCPOIDCConfig (when oidcConfigRefName is non-empty). func newTestMCPRemoteProxy(name, namespace string, oidcConfigRefName string) *mcpv1alpha1.MCPRemoteProxy { proxy := &mcpv1alpha1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ @@ -42,15 +41,6 @@ func newTestMCPRemoteProxy(name, namespace string, oidcConfigRefName string) *mc Audience: "test-proxy-audience", Scopes: []string{"openid"}, } - } else { - proxy.Spec.OIDCConfig = &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - ClientID: "test-client", - }, - } } return proxy @@ -611,16 +601,8 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te Namespace: namespace, }, updated)).Should(Succeed()) - // Switch from OIDCConfigRef to inline OIDCConfig (mutually exclusive) + // Remove the OIDCConfigRef updated.Spec.OIDCConfigRef = nil - updated.Spec.OIDCConfig = &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - ClientID: "test-client", - }, - } Expect(k8sClient.Update(ctx, updated)).Should(Succeed()) // MCPOIDCConfig should no longer list MCPRemoteProxy in ReferencingWorkloads diff --git a/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_validation_integration_test.go b/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_validation_integration_test.go index a06573cdf6..e5de56483c 100644 --- a/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_validation_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_validation_integration_test.go @@ -9,7 +9,6 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" @@ -35,54 +34,6 @@ var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remote deleteTestNamespace(testCtx, testNamespace) }) - Context("OIDC Issuer URL Validation", func() { - It("should set ConfigurationValid=False and Failed phase when OIDC issuer uses HTTP", func() { - By("creating an MCPRemoteProxy with HTTP OIDC issuer") - proxy := proxyHelper.NewRemoteProxyBuilder("test-http-oidc"). - WithInlineOIDCConfig("http://insecure-idp.example.com", "test-audience", false). - Create(proxyHelper) - - By("waiting for the proxy to reach Failed phase") - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhaseFailed, MediumTimeout) - - By("verifying the ConfigurationValid condition is False with OIDCIssuerInsecure reason") - statusHelper.WaitForConditionReason( - proxy.Name, - mcpv1alpha1.ConditionTypeConfigurationValid, - mcpv1alpha1.ConditionReasonOIDCIssuerInsecure, - MediumTimeout, - ) - - condition, err := proxyHelper.GetRemoteProxyCondition( - proxy.Name, mcpv1alpha1.ConditionTypeConfigurationValid, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(condition.Status).To(Equal(metav1.ConditionFalse)) - Expect(condition.Message).To(ContainSubstring("HTTP scheme")) - }) - - It("should set ConfigurationValid=True when OIDC issuer uses HTTPS", func() { - By("creating an MCPRemoteProxy with HTTPS OIDC issuer") - proxy := proxyHelper.NewRemoteProxyBuilder("test-https-oidc"). - WithInlineOIDCConfig("https://secure-idp.example.com", "test-audience", false). - Create(proxyHelper) - - By("waiting for the ConfigurationValid condition to be True") - statusHelper.WaitForCondition( - proxy.Name, - mcpv1alpha1.ConditionTypeConfigurationValid, - metav1.ConditionTrue, - MediumTimeout, - ) - - condition, err := proxyHelper.GetRemoteProxyCondition( - proxy.Name, mcpv1alpha1.ConditionTypeConfigurationValid, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(condition.Reason).To(Equal(mcpv1alpha1.ConditionReasonConfigurationValid)) - }) - }) - Context("Remote URL Format Validation", func() { It("should reject creation when remote URL has invalid scheme via CRD validation", func() { By("attempting to create an MCPRemoteProxy with ftp:// remote URL") @@ -97,29 +48,6 @@ var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remote }) }) - Context("JWKS URL Validation", func() { - It("should set ConfigurationValid=False when JWKS URL uses HTTP", func() { - By("creating an MCPRemoteProxy with HTTP JWKS URL") - proxy := proxyHelper.NewRemoteProxyBuilder("test-http-jwks"). - WithInlineOIDCConfigAndJWKS( - "https://auth.example.com", "test-audience", - "http://jwks.example.com/.well-known/jwks.json", - ). - Create(proxyHelper) - - By("waiting for the proxy to reach Failed phase") - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhaseFailed, MediumTimeout) - - By("verifying the ConfigurationValid condition") - statusHelper.WaitForConditionReason( - proxy.Name, - mcpv1alpha1.ConditionTypeConfigurationValid, - mcpv1alpha1.ConditionReasonJWKSURLInvalid, - MediumTimeout, - ) - }) - }) - Context("Cedar Policy Syntax Validation", func() { It("should set ConfigurationValid=False when Cedar policy has invalid syntax", func() { By("creating an MCPRemoteProxy with invalid Cedar policy") @@ -179,37 +107,6 @@ var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remote }) Context("Kubernetes Events", func() { - It("should emit a Warning event when OIDC issuer uses HTTP", func() { - By("creating an MCPRemoteProxy with HTTP OIDC issuer") - proxy := proxyHelper.NewRemoteProxyBuilder("test-event-http-oidc"). - WithInlineOIDCConfig("http://insecure-idp.example.com", "test-audience", false). - Create(proxyHelper) - - By("waiting for the proxy to reach Failed phase") - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhaseFailed, MediumTimeout) - - By("verifying a Warning event was emitted with OIDCIssuerInsecure reason") - Eventually(func() bool { - eventList := &corev1.EventList{} - err := k8sClient.List(testCtx, eventList, - client.InNamespace(testNamespace), - ) - if err != nil { - return false - } - for _, event := range eventList.Items { - if event.InvolvedObject.Name == proxy.Name && - event.Type == corev1.EventTypeWarning && - event.Reason == mcpv1alpha1.ConditionReasonOIDCIssuerInsecure { - Expect(event.Message).To(ContainSubstring("HTTP scheme")) - return true - } - } - return false - }, MediumTimeout, DefaultPollingInterval).Should(BeTrue(), - "expected a Warning event with reason OIDCIssuerInsecure") - }) - It("should emit a Warning event when Cedar policy has invalid syntax", func() { By("creating an MCPRemoteProxy with invalid Cedar policy") proxy := proxyHelper.NewRemoteProxyBuilder("test-event-bad-cedar"). @@ -294,34 +191,6 @@ var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remote "expected a Warning event with reason HeaderSecretNotFound") }) - It("should not emit a Warning event when OIDC issuer uses HTTPS", func() { - By("creating an MCPRemoteProxy with HTTPS OIDC issuer") - proxy := proxyHelper.NewRemoteProxyBuilder("test-event-https-oidc"). - WithInlineOIDCConfig("https://secure-idp.example.com", "test-audience", false). - Create(proxyHelper) - - By("waiting for the ConfigurationValid condition to be True") - statusHelper.WaitForCondition( - proxy.Name, - mcpv1alpha1.ConditionTypeConfigurationValid, - metav1.ConditionTrue, - MediumTimeout, - ) - - By("verifying no Warning event with OIDCIssuerInsecure reason was emitted") - eventList := &corev1.EventList{} - Expect(k8sClient.List(testCtx, eventList, - client.InNamespace(testNamespace), - )).To(Succeed()) - - for _, event := range eventList.Items { - if event.InvolvedObject.Name != proxy.Name { - continue - } - Expect(event.Reason).NotTo(Equal(mcpv1alpha1.ConditionReasonOIDCIssuerInsecure), - "should not have emitted an OIDCIssuerInsecure warning event") - } - }) }) }) diff --git a/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go b/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go index 700a42628e..8badc0a56a 100644 --- a/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go +++ b/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go @@ -87,15 +87,6 @@ func (h *MCPRemoteProxyTestHelper) NewRemoteProxyBuilder(name string) *RemotePro RemoteURL: "https://remote.example.com/mcp", ProxyPort: 8080, Transport: "streamable-http", - // Default OIDC config for tests - override with WithInlineOIDCConfig if testing OIDC - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - ClientID: "test-client", - }, - }, }, }, } @@ -124,10 +115,8 @@ func (rb *RemoteProxyBuilder) WithAuthServerRef(name string) *RemoteProxyBuilder return rb } -// WithOIDCConfigRef sets the OIDCConfigRef for the proxy and clears -// the inline OIDCConfig since they are mutually exclusive. +// WithOIDCConfigRef sets the OIDCConfigRef for the proxy. func (rb *RemoteProxyBuilder) WithOIDCConfigRef(name, audience string) *RemoteProxyBuilder { - rb.proxy.Spec.OIDCConfig = nil rb.proxy.Spec.OIDCConfigRef = &mcpv1alpha1.MCPOIDCConfigReference{ Name: name, Audience: audience, @@ -149,40 +138,12 @@ func (rb *RemoteProxyBuilder) WithGroupRef(name string) *RemoteProxyBuilder { return rb } -// WithInlineOIDCConfig sets an inline OIDC config for the proxy -func (rb *RemoteProxyBuilder) WithInlineOIDCConfig(issuer, audience string, insecureAllowHTTP bool) *RemoteProxyBuilder { - rb.proxy.Spec.OIDCConfig = &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: issuer, - Audience: audience, - InsecureAllowHTTP: insecureAllowHTTP, - }, - } - return rb -} - // WithRemoteURL overrides the default remote URL func (rb *RemoteProxyBuilder) WithRemoteURL(url string) *RemoteProxyBuilder { rb.proxy.Spec.RemoteURL = url return rb } -// WithInlineOIDCConfigAndJWKS sets an inline OIDC config with a custom JWKS URL -func (rb *RemoteProxyBuilder) WithInlineOIDCConfigAndJWKS( - issuer, audience, jwksURL string, -) *RemoteProxyBuilder { - rb.proxy.Spec.OIDCConfig = &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: issuer, - Audience: audience, - JWKSURL: jwksURL, - }, - } - return rb -} - // WithInlineAuthzConfig sets an inline authz config with Cedar policies func (rb *RemoteProxyBuilder) WithInlineAuthzConfig(policies []string) *RemoteProxyBuilder { rb.proxy.Spec.AuthzConfig = &mcpv1alpha1.AuthzConfigRef{ diff --git a/cmd/thv-operator/test-integration/mcp-server/mcpserver_cel_validation_integration_test.go b/cmd/thv-operator/test-integration/mcp-server/mcpserver_cel_validation_integration_test.go index 5da5d1be79..fa06717ac5 100644 --- a/cmd/thv-operator/test-integration/mcp-server/mcpserver_cel_validation_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/mcpserver_cel_validation_integration_test.go @@ -12,8 +12,8 @@ import ( ) // newMinimalMCPServer creates a minimal MCPServer with the given name and optional -// OIDCConfigRef and AuthzConfigRef for CEL validation testing. -func newMinimalMCPServer(name string, oidc *mcpv1alpha1.OIDCConfigRef, authz *mcpv1alpha1.AuthzConfigRef) *mcpv1alpha1.MCPServer { +// AuthzConfigRef for CEL validation testing. +func newMinimalMCPServer(name string, authz *mcpv1alpha1.AuthzConfigRef) *mcpv1alpha1.MCPServer { return &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -21,185 +21,16 @@ func newMinimalMCPServer(name string, oidc *mcpv1alpha1.OIDCConfigRef, authz *mc }, Spec: mcpv1alpha1.MCPServerSpec{ Image: "example/mcp-server:latest", - OIDCConfig: oidc, AuthzConfig: authz, }, } } -var _ = Describe("CEL Validation for OIDCConfigRef and AuthzConfigRef", Label("k8s", "cel", "validation"), func() { - Context("OIDCConfigRef CEL validation", func() { - Context("type=configMap", func() { - It("should reject when configMap field is missing", func() { - server := newMinimalMCPServer("oidc-cm-missing", &mcpv1alpha1.OIDCConfigRef{ - Type: "configMap", - }, nil) - err := k8sClient.Create(ctx, server) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("configMap must be set when type is 'configMap'")) - }) - - It("should reject when inline field is also set", func() { - server := newMinimalMCPServer("oidc-cm-with-inline", &mcpv1alpha1.OIDCConfigRef{ - Type: "configMap", - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "test-cm", - }, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - }, - }, nil) - err := k8sClient.Create(ctx, server) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("inline must be set when type is 'inline'")) - }) - - It("should reject when kubernetes field is also set", func() { - server := newMinimalMCPServer("oidc-cm-with-k8s", &mcpv1alpha1.OIDCConfigRef{ - Type: "configMap", - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "test-cm", - }, - Kubernetes: &mcpv1alpha1.KubernetesOIDCConfig{ - ServiceAccount: "test-sa", - }, - }, nil) - err := k8sClient.Create(ctx, server) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("kubernetes must not be set when type is not 'kubernetes'")) - }) - - It("should accept when only configMap field is set", func() { - server := newMinimalMCPServer("oidc-cm-valid", &mcpv1alpha1.OIDCConfigRef{ - Type: "configMap", - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "test-cm", - }, - }, nil) - err := k8sClient.Create(ctx, server) - Expect(err).NotTo(HaveOccurred()) - }) - }) - - Context("type=inline", func() { - It("should reject when inline field is missing", func() { - server := newMinimalMCPServer("oidc-inline-missing", &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - }, nil) - err := k8sClient.Create(ctx, server) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("inline must be set when type is 'inline'")) - }) - - It("should reject when configMap field is also set", func() { - server := newMinimalMCPServer("oidc-inline-with-cm", &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - }, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "test-cm", - }, - }, nil) - err := k8sClient.Create(ctx, server) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("configMap must be set when type is 'configMap'")) - }) - - It("should accept when only inline field is set", func() { - server := newMinimalMCPServer("oidc-inline-valid", &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - }, - }, nil) - err := k8sClient.Create(ctx, server) - Expect(err).NotTo(HaveOccurred()) - }) - }) - - Context("type=kubernetes", func() { - It("should reject when configMap field is set", func() { - server := newMinimalMCPServer("oidc-k8s-with-cm", &mcpv1alpha1.OIDCConfigRef{ - Type: "kubernetes", - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "test-cm", - }, - }, nil) - err := k8sClient.Create(ctx, server) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("configMap must be set when type is 'configMap'")) - }) - - It("should reject when inline field is set", func() { - server := newMinimalMCPServer("oidc-k8s-with-inline", &mcpv1alpha1.OIDCConfigRef{ - Type: "kubernetes", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - }, - }, nil) - err := k8sClient.Create(ctx, server) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("inline must be set when type is 'inline'")) - }) - - It("should accept when kubernetes field is set", func() { - server := newMinimalMCPServer("oidc-k8s-valid", &mcpv1alpha1.OIDCConfigRef{ - Type: "kubernetes", - Kubernetes: &mcpv1alpha1.KubernetesOIDCConfig{ - ServiceAccount: "test-sa", - }, - }, nil) - err := k8sClient.Create(ctx, server) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should accept when kubernetes field is omitted (defaults apply)", func() { - server := newMinimalMCPServer("oidc-k8s-defaults", &mcpv1alpha1.OIDCConfigRef{ - Type: "kubernetes", - }, nil) - err := k8sClient.Create(ctx, server) - Expect(err).NotTo(HaveOccurred()) - }) - }) - }) - - Context("OIDCConfigRef multi-violation CEL validation", func() { - It("should report both missing-configMap and extra-inline when type=configMap but only inline is set", func() { - server := newMinimalMCPServer("oidc-cm-only-inline", &mcpv1alpha1.OIDCConfigRef{ - Type: "configMap", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - }, - }, nil) - err := k8sClient.Create(ctx, server) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(And( - ContainSubstring("configMap must be set when type is 'configMap'"), - ContainSubstring("inline must be set when type is 'inline'"), - )) - }) - - It("should report kubernetes-not-allowed violation when type=inline with inline and kubernetes both set", func() { - server := newMinimalMCPServer("oidc-inline-with-k8s", &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - }, - Kubernetes: &mcpv1alpha1.KubernetesOIDCConfig{ - ServiceAccount: "test-sa", - }, - }, nil) - err := k8sClient.Create(ctx, server) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("kubernetes must not be set when type is not 'kubernetes'")) - }) - }) - +var _ = Describe("CEL Validation for AuthzConfigRef", Label("k8s", "cel", "validation"), func() { Context("AuthzConfigRef CEL validation", func() { Context("type=configMap", func() { It("should reject when configMap field is missing", func() { - server := newMinimalMCPServer("authz-cm-missing", nil, &mcpv1alpha1.AuthzConfigRef{ + server := newMinimalMCPServer("authz-cm-missing", &mcpv1alpha1.AuthzConfigRef{ Type: "configMap", }) err := k8sClient.Create(ctx, server) @@ -208,7 +39,7 @@ var _ = Describe("CEL Validation for OIDCConfigRef and AuthzConfigRef", Label("k }) It("should reject when inline field is also set", func() { - server := newMinimalMCPServer("authz-cm-with-inline", nil, &mcpv1alpha1.AuthzConfigRef{ + server := newMinimalMCPServer("authz-cm-with-inline", &mcpv1alpha1.AuthzConfigRef{ Type: "configMap", ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ Name: "test-cm", @@ -223,7 +54,7 @@ var _ = Describe("CEL Validation for OIDCConfigRef and AuthzConfigRef", Label("k }) It("should accept when only configMap field is set", func() { - server := newMinimalMCPServer("authz-cm-valid", nil, &mcpv1alpha1.AuthzConfigRef{ + server := newMinimalMCPServer("authz-cm-valid", &mcpv1alpha1.AuthzConfigRef{ Type: "configMap", ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ Name: "test-cm", @@ -236,7 +67,7 @@ var _ = Describe("CEL Validation for OIDCConfigRef and AuthzConfigRef", Label("k Context("type=inline", func() { It("should reject when inline field is missing", func() { - server := newMinimalMCPServer("authz-inline-missing", nil, &mcpv1alpha1.AuthzConfigRef{ + server := newMinimalMCPServer("authz-inline-missing", &mcpv1alpha1.AuthzConfigRef{ Type: "inline", }) err := k8sClient.Create(ctx, server) @@ -245,7 +76,7 @@ var _ = Describe("CEL Validation for OIDCConfigRef and AuthzConfigRef", Label("k }) It("should reject when configMap field is also set", func() { - server := newMinimalMCPServer("authz-inline-with-cm", nil, &mcpv1alpha1.AuthzConfigRef{ + server := newMinimalMCPServer("authz-inline-with-cm", &mcpv1alpha1.AuthzConfigRef{ Type: "inline", Inline: &mcpv1alpha1.InlineAuthzConfig{ Policies: []string{"permit(principal, action, resource);"}, @@ -260,7 +91,7 @@ var _ = Describe("CEL Validation for OIDCConfigRef and AuthzConfigRef", Label("k }) It("should accept when only inline field is set", func() { - server := newMinimalMCPServer("authz-inline-valid", nil, &mcpv1alpha1.AuthzConfigRef{ + server := newMinimalMCPServer("authz-inline-valid", &mcpv1alpha1.AuthzConfigRef{ Type: "inline", Inline: &mcpv1alpha1.InlineAuthzConfig{ Policies: []string{"permit(principal, action, resource);"}, @@ -274,7 +105,7 @@ var _ = Describe("CEL Validation for OIDCConfigRef and AuthzConfigRef", Label("k Context("AuthzConfigRef multi-violation CEL validation", func() { It("should report both missing-configMap and extra-inline when type=configMap but only inline is set", func() { - server := newMinimalMCPServer("authz-cm-only-inline", nil, &mcpv1alpha1.AuthzConfigRef{ + server := newMinimalMCPServer("authz-cm-only-inline", &mcpv1alpha1.AuthzConfigRef{ Type: "configMap", Inline: &mcpv1alpha1.InlineAuthzConfig{ Policies: []string{"permit(principal, action, resource);"}, @@ -289,46 +120,4 @@ var _ = Describe("CEL Validation for OIDCConfigRef and AuthzConfigRef", Label("k }) }) - Context("OIDCConfig and OIDCConfigRef mutual exclusion", func() { - It("should reject when both oidcConfig and oidcConfigRef are set", func() { - server := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "oidc-mutual-exclusion", - Namespace: "default", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: "example/mcp-server:latest", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "kubernetes", - }, - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ - Name: "some-config", - Audience: "test", - }, - }, - } - err := k8sClient.Create(ctx, server) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("oidcConfig and oidcConfigRef are mutually exclusive")) - }) - - It("should accept when only oidcConfigRef is set", func() { - server := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "oidc-ref-only", - Namespace: "default", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: "example/mcp-server:latest", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ - Name: "some-config", - Audience: "test", - }, - }, - } - err := k8sClient.Create(ctx, server) - Expect(err).NotTo(HaveOccurred()) - }) - }) - }) diff --git a/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go b/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go index fc1b2d0c5c..2dbbe31388 100644 --- a/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go @@ -785,113 +785,6 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { Expect(runConfig.CmdArgs).To(Equal([]string{"--arg1", "--arg2", "--arg3"})) }) - It("Should handle MCPServer with OIDC authentication configuration", func() { - namespace := "oidc-test-ns" - mcpServerName := "oidc-server" - configMapName := mcpServerName + "-runconfig" - - // Create namespace - ns := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: namespace, - }, - } - _ = k8sClient.Create(ctx, ns) - - // Create MCPServer with OIDC configuration - mcpServer := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: mcpServerName, - Namespace: namespace, - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: "auth/mcp-server:latest", - Transport: "stdio", - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "toolhive-api", - JWKSURL: "https://auth.example.com/.well-known/jwks.json", - IntrospectionURL: "https://auth.example.com/oauth/introspect", - ClientID: "toolhive-client", - JWKSAllowPrivateIP: true, - }, - }, - }, - } - - Expect(k8sClient.Create(ctx, mcpServer)).Should(Succeed()) - defer k8sClient.Delete(ctx, mcpServer) - - // Wait for ConfigMap to be created - configMap := &corev1.ConfigMap{} - Eventually(func() error { - return k8sClient.Get(ctx, types.NamespacedName{ - Name: configMapName, - Namespace: namespace, - }, configMap) - }, timeout, interval).Should(Succeed()) - - // Verify ConfigMap has the expected label - Expect(configMap.Labels).To(HaveKeyWithValue("toolhive.stacklok.io/mcp-server", mcpServerName)) - - // Verify ConfigMap data contains runconfig.json - Expect(configMap.Data).To(HaveKey("runconfig.json")) - runConfigJSON := configMap.Data["runconfig.json"] - Expect(runConfigJSON).NotTo(BeEmpty()) - - // Parse and verify RunConfig content - var runConfig runner.RunConfig - err := json.Unmarshal([]byte(runConfigJSON), &runConfig) - Expect(err).NotTo(HaveOccurred()) - - // Verify OIDC configuration - Expect(runConfig.OIDCConfig).NotTo(BeNil()) - Expect(runConfig.OIDCConfig.Issuer).To(Equal("https://auth.example.com")) - Expect(runConfig.OIDCConfig.Audience).To(Equal("toolhive-api")) - Expect(runConfig.OIDCConfig.JWKSURL).To(Equal("https://auth.example.com/.well-known/jwks.json")) - Expect(runConfig.OIDCConfig.IntrospectionURL).To(Equal("https://auth.example.com/oauth/introspect")) - Expect(runConfig.OIDCConfig.ClientID).To(Equal("toolhive-client")) - Expect(runConfig.OIDCConfig.AllowPrivateIP).To(BeTrue()) - - // Verify fields that should be empty/nil - Expect(runConfig.OIDCConfig.CACertPath).To(BeEmpty()) - Expect(runConfig.OIDCConfig.AuthTokenFile).To(BeEmpty()) - - // Verify middleware_configs includes auth middleware - Expect(runConfig.MiddlewareConfigs).NotTo(BeEmpty()) - - // Find the auth middleware - authMiddlewareFound := false - for _, middleware := range runConfig.MiddlewareConfigs { - if middleware.Type == "auth" { - authMiddlewareFound = true - - // Verify auth middleware has the OIDC config - var params map[string]interface{} - err := json.Unmarshal(middleware.Parameters, ¶ms) - Expect(err).NotTo(HaveOccurred(), "Failed to unmarshal auth middleware parameters") - - if oidcConfigMap, exists := params["oidc_config"]; exists && oidcConfigMap != nil { - oidcConfig, ok := oidcConfigMap.(map[string]interface{}) - Expect(ok).To(BeTrue(), "oidc_config should be a map") - Expect(oidcConfig["Issuer"]).To(Equal("https://auth.example.com")) - Expect(oidcConfig["Audience"]).To(Equal("toolhive-api")) - Expect(oidcConfig["JWKSURL"]).To(Equal("https://auth.example.com/.well-known/jwks.json")) - Expect(oidcConfig["IntrospectionURL"]).To(Equal("https://auth.example.com/oauth/introspect")) - Expect(oidcConfig["ClientID"]).To(Equal("toolhive-client")) - Expect(oidcConfig["AllowPrivateIP"]).To(BeTrue()) - } else { - Fail("OIDC config not found in auth middleware parameters") - } - break - } - } - Expect(authMiddlewareFound).To(BeTrue(), "Auth middleware should be present in middleware_configs") - }) - It("Should handle MCPServer with authorization ConfigMap reference", func() { namespace := "authz-configmap-ns" mcpServerName := "authz-configmap-server" diff --git a/cmd/thv-operator/test-integration/mcp-server/mcpserver_sessionstorage_cel_test.go b/cmd/thv-operator/test-integration/mcp-server/mcpserver_sessionstorage_cel_test.go index ca9359dd27..ada7c8a346 100644 --- a/cmd/thv-operator/test-integration/mcp-server/mcpserver_sessionstorage_cel_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/mcpserver_sessionstorage_cel_test.go @@ -88,14 +88,14 @@ var _ = Describe("CEL Validation for SessionStorageConfig on MCPServer", Context("replicas fields", func() { It("should accept nil replicas (HPA-compatible)", func() { - server := newMinimalMCPServer("mcp-nil-replicas", nil, nil) + server := newMinimalMCPServer("mcp-nil-replicas", nil) err := k8sClient.Create(ctx, server) Expect(err).NotTo(HaveOccurred()) }) It("should accept explicit replicas value", func() { replicas := int32(3) - server := newMinimalMCPServer("mcp-explicit-replicas", nil, nil) + server := newMinimalMCPServer("mcp-explicit-replicas", nil) server.Spec.Replicas = &replicas err := k8sClient.Create(ctx, server) Expect(err).NotTo(HaveOccurred()) @@ -103,7 +103,7 @@ var _ = Describe("CEL Validation for SessionStorageConfig on MCPServer", It("should reject negative replicas", func() { replicas := int32(-1) - server := newMinimalMCPServer("mcp-neg-replicas", nil, nil) + server := newMinimalMCPServer("mcp-neg-replicas", nil) server.Spec.Replicas = &replicas err := k8sClient.Create(ctx, server) Expect(err).To(HaveOccurred()) @@ -111,7 +111,7 @@ var _ = Describe("CEL Validation for SessionStorageConfig on MCPServer", It("should reject negative backendReplicas", func() { backendReplicas := int32(-1) - server := newMinimalMCPServer("mcp-neg-backend-replicas", nil, nil) + server := newMinimalMCPServer("mcp-neg-backend-replicas", nil) server.Spec.BackendReplicas = &backendReplicas err := k8sClient.Create(ctx, server) Expect(err).To(HaveOccurred()) diff --git a/cmd/thv/app/export.go b/cmd/thv/app/export.go index 40dd5c01bd..cacb816587 100644 --- a/cmd/thv/app/export.go +++ b/cmd/thv/app/export.go @@ -102,6 +102,12 @@ func exportCmdFunc(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "Create an MCPTelemetryConfig resource and add a telemetryConfigRef to the exported MCPServer.\n") } + // Warn if OIDC config is present but cannot be exported inline + if runConfig.OIDCConfig != nil { + fmt.Fprintf(os.Stderr, "Warning: OIDC configuration detected but not exported.\n") + fmt.Fprintf(os.Stderr, "Create an MCPOIDCConfig resource and add an oidcConfigRef to the exported MCPServer.\n") + } + if err := export.WriteK8sManifest(runConfig, outputFile); err != nil { return fmt.Errorf("failed to write Kubernetes manifest: %w", err) } diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml index caa998f2e5..81e9b3283f 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -229,231 +229,6 @@ spec: Use addHeadersFromSecret for sensitive data like API keys or tokens. type: object type: object - oidcConfig: - description: |- - OIDCConfig defines OIDC authentication configuration for the proxy. - Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead. - This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. - properties: - configMap: - description: |- - ConfigMap references a ConfigMap containing OIDC configuration - Only used when Type is "configmap" - properties: - caBundleRef: - description: |- - CABundleRef references a ConfigMap containing the CA certificate bundle. - When specified, ToolHive auto-mounts the ConfigMap and auto-computes ThvCABundlePath. - If the ConfigMap data contains an explicit thvCABundlePath key, it takes precedence. - properties: - configMapRef: - description: |- - ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or its - key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - key: - default: oidc.json - description: Key is the key in the ConfigMap that contains - the OIDC configuration - type: string - name: - description: Name is the name of the ConfigMap - type: string - required: - - name - type: object - inline: - description: |- - Inline contains direct OIDC configuration - Only used when Type is "inline" - properties: - audience: - description: Audience is the expected audience for the token - type: string - caBundleRef: - description: |- - CABundleRef references a ConfigMap containing the CA certificate bundle. - When specified, ToolHive auto-mounts the ConfigMap and auto-computes the CA bundle path. - properties: - configMapRef: - description: |- - ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or its - key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - clientId: - description: ClientID is the OIDC client ID - type: string - clientSecretRef: - description: ClientSecretRef is a reference to a Kubernetes - Secret containing the client secret - properties: - key: - description: Key is the key within the secret - type: string - name: - description: Name is the name of the secret - type: string - required: - - key - - name - type: object - insecureAllowHTTP: - default: false - description: |- - InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing - WARNING: This is insecure and should NEVER be used in production - Only enable for local development, testing, or trusted internal networks - type: boolean - introspectionUrl: - description: IntrospectionURL is the URL for token introspection - endpoint - type: string - issuer: - description: Issuer is the OIDC issuer URL - type: string - jwksAllowPrivateIP: - default: false - description: |- - JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. - Use with caution - only enable for trusted internal IDPs. - Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP - is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). - type: boolean - jwksAuthTokenPath: - description: |- - JWKSAuthTokenPath is the path to file containing bearer token for JWKS/OIDC requests - The file must be mounted into the pod (e.g., via Secret volume) - type: string - jwksUrl: - description: JWKSURL is the URL to fetch the JWKS from - type: string - protectedResourceAllowPrivateIP: - default: false - description: |- - ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. - Use with caution - only enable for trusted internal IDPs or testing. - Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP - is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). - type: boolean - scopes: - description: |- - Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728) - If empty, defaults to ["openid"] - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - issuer - type: object - kubernetes: - description: |- - Kubernetes configures OIDC for Kubernetes service account token validation - Only used when Type is "kubernetes" - properties: - audience: - default: toolhive - description: Audience is the expected audience for the token - type: string - introspectionUrl: - description: |- - IntrospectionURL is the URL for token introspection endpoint - If empty, OIDC discovery will be used to automatically determine the introspection URL - type: string - issuer: - default: https://kubernetes.default.svc - description: Issuer is the OIDC issuer URL - type: string - jwksUrl: - description: |- - JWKSURL is the URL to fetch the JWKS from - If empty, OIDC discovery will be used to automatically determine the JWKS URL - type: string - namespace: - description: |- - Namespace is the namespace of the service account - If empty, uses the MCPServer's namespace - type: string - serviceAccount: - description: |- - ServiceAccount is the name of the service account to validate tokens for - If empty, uses the pod's service account - type: string - useClusterAuth: - description: |- - UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token - When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification - and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication - Defaults to true if not specified - type: boolean - type: object - resourceUrl: - description: |- - ResourceURL is the explicit resource URL for OAuth discovery endpoint (RFC 9728) - If not specified, defaults to the in-cluster Kubernetes service URL - type: string - type: - default: kubernetes - description: Type is the type of OIDC configuration - enum: - - kubernetes - - configMap - - inline - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: configMap must be set when type is 'configMap', and must - not be set otherwise - rule: 'self.type == ''configMap'' ? has(self.configMap) : !has(self.configMap)' - - message: inline must be set when type is 'inline', and must not - be set otherwise - rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' - - message: kubernetes must not be set when type is not 'kubernetes' - rule: 'self.type != ''kubernetes'' ? !has(self.kubernetes) : true' oidcConfigRef: description: |- OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. @@ -687,10 +462,6 @@ spec: required: - remoteUrl type: object - x-kubernetes-validations: - - message: oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef - to reference a shared MCPOIDCConfig - rule: '!(has(self.oidcConfig) && has(self.oidcConfigRef))' status: description: MCPRemoteProxyStatus defines the observed state of MCPRemoteProxy properties: diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml index 4b1ca998ba..83b8809531 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml @@ -224,237 +224,12 @@ spec: maximum: 65535 minimum: 1 type: integer - oidcConfig: - description: |- - OIDCConfig defines OIDC authentication configuration for the MCP server. - Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead. - This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. - properties: - configMap: - description: |- - ConfigMap references a ConfigMap containing OIDC configuration - Only used when Type is "configmap" - properties: - caBundleRef: - description: |- - CABundleRef references a ConfigMap containing the CA certificate bundle. - When specified, ToolHive auto-mounts the ConfigMap and auto-computes ThvCABundlePath. - If the ConfigMap data contains an explicit thvCABundlePath key, it takes precedence. - properties: - configMapRef: - description: |- - ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or its - key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - key: - default: oidc.json - description: Key is the key in the ConfigMap that contains - the OIDC configuration - type: string - name: - description: Name is the name of the ConfigMap - type: string - required: - - name - type: object - inline: - description: |- - Inline contains direct OIDC configuration - Only used when Type is "inline" - properties: - audience: - description: Audience is the expected audience for the token - type: string - caBundleRef: - description: |- - CABundleRef references a ConfigMap containing the CA certificate bundle. - When specified, ToolHive auto-mounts the ConfigMap and auto-computes the CA bundle path. - properties: - configMapRef: - description: |- - ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or its - key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - clientId: - description: ClientID is the OIDC client ID - type: string - clientSecretRef: - description: ClientSecretRef is a reference to a Kubernetes - Secret containing the client secret - properties: - key: - description: Key is the key within the secret - type: string - name: - description: Name is the name of the secret - type: string - required: - - key - - name - type: object - insecureAllowHTTP: - default: false - description: |- - InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing - WARNING: This is insecure and should NEVER be used in production - Only enable for local development, testing, or trusted internal networks - type: boolean - introspectionUrl: - description: IntrospectionURL is the URL for token introspection - endpoint - type: string - issuer: - description: Issuer is the OIDC issuer URL - type: string - jwksAllowPrivateIP: - default: false - description: |- - JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. - Use with caution - only enable for trusted internal IDPs. - Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP - is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). - type: boolean - jwksAuthTokenPath: - description: |- - JWKSAuthTokenPath is the path to file containing bearer token for JWKS/OIDC requests - The file must be mounted into the pod (e.g., via Secret volume) - type: string - jwksUrl: - description: JWKSURL is the URL to fetch the JWKS from - type: string - protectedResourceAllowPrivateIP: - default: false - description: |- - ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. - Use with caution - only enable for trusted internal IDPs or testing. - Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP - is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). - type: boolean - scopes: - description: |- - Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728) - If empty, defaults to ["openid"] - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - issuer - type: object - kubernetes: - description: |- - Kubernetes configures OIDC for Kubernetes service account token validation - Only used when Type is "kubernetes" - properties: - audience: - default: toolhive - description: Audience is the expected audience for the token - type: string - introspectionUrl: - description: |- - IntrospectionURL is the URL for token introspection endpoint - If empty, OIDC discovery will be used to automatically determine the introspection URL - type: string - issuer: - default: https://kubernetes.default.svc - description: Issuer is the OIDC issuer URL - type: string - jwksUrl: - description: |- - JWKSURL is the URL to fetch the JWKS from - If empty, OIDC discovery will be used to automatically determine the JWKS URL - type: string - namespace: - description: |- - Namespace is the namespace of the service account - If empty, uses the MCPServer's namespace - type: string - serviceAccount: - description: |- - ServiceAccount is the name of the service account to validate tokens for - If empty, uses the pod's service account - type: string - useClusterAuth: - description: |- - UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token - When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification - and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication - Defaults to true if not specified - type: boolean - type: object - resourceUrl: - description: |- - ResourceURL is the explicit resource URL for OAuth discovery endpoint (RFC 9728) - If not specified, defaults to the in-cluster Kubernetes service URL - type: string - type: - default: kubernetes - description: Type is the type of OIDC configuration - enum: - - kubernetes - - configMap - - inline - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: configMap must be set when type is 'configMap', and must - not be set otherwise - rule: 'self.type == ''configMap'' ? has(self.configMap) : !has(self.configMap)' - - message: inline must be set when type is 'inline', and must not - be set otherwise - rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' - - message: kubernetes must not be set when type is not 'kubernetes' - rule: 'self.type != ''kubernetes'' ? !has(self.kubernetes) : true' oidcConfigRef: description: |- OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. The referenced MCPOIDCConfig must exist in the same namespace as this MCPServer. Per-server overrides (audience, scopes) are specified here; shared provider config - lives in the MCPOIDCConfig resource. Mutually exclusive with oidcConfig. + lives in the MCPOIDCConfig resource. properties: audience: description: |- @@ -959,21 +734,17 @@ spec: - image type: object x-kubernetes-validations: - - message: oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef - to reference a shared MCPOIDCConfig - rule: '!(has(self.oidcConfig) && has(self.oidcConfigRef))' - message: rateLimiting requires sessionStorage with provider 'redis' rule: '!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == ''redis'')' - - message: rateLimiting.perUser requires authentication (oidcConfig, oidcConfigRef, + - message: rateLimiting.perUser requires authentication (oidcConfigRef or externalAuthConfigRef) rule: '!(has(self.rateLimiting) && has(self.rateLimiting.perUser)) || - has(self.oidcConfig) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)' - - message: per-tool perUser rate limiting requires authentication (oidcConfig, - oidcConfigRef, or externalAuthConfigRef) + has(self.oidcConfigRef) || has(self.externalAuthConfigRef)' + - message: per-tool perUser rate limiting requires authentication (oidcConfigRef + or externalAuthConfigRef) rule: '!has(self.rateLimiting) || !has(self.rateLimiting.tools) || self.rateLimiting.tools.all(t, - !has(t.perUser)) || has(self.oidcConfig) || has(self.oidcConfigRef) - || has(self.externalAuthConfigRef)' + !has(t.perUser)) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)' status: description: MCPServerStatus defines the observed state of MCPServer properties: diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml index d9d7292262..c58e1ba0e5 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -232,231 +232,6 @@ spec: Use addHeadersFromSecret for sensitive data like API keys or tokens. type: object type: object - oidcConfig: - description: |- - OIDCConfig defines OIDC authentication configuration for the proxy. - Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead. - This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. - properties: - configMap: - description: |- - ConfigMap references a ConfigMap containing OIDC configuration - Only used when Type is "configmap" - properties: - caBundleRef: - description: |- - CABundleRef references a ConfigMap containing the CA certificate bundle. - When specified, ToolHive auto-mounts the ConfigMap and auto-computes ThvCABundlePath. - If the ConfigMap data contains an explicit thvCABundlePath key, it takes precedence. - properties: - configMapRef: - description: |- - ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or its - key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - key: - default: oidc.json - description: Key is the key in the ConfigMap that contains - the OIDC configuration - type: string - name: - description: Name is the name of the ConfigMap - type: string - required: - - name - type: object - inline: - description: |- - Inline contains direct OIDC configuration - Only used when Type is "inline" - properties: - audience: - description: Audience is the expected audience for the token - type: string - caBundleRef: - description: |- - CABundleRef references a ConfigMap containing the CA certificate bundle. - When specified, ToolHive auto-mounts the ConfigMap and auto-computes the CA bundle path. - properties: - configMapRef: - description: |- - ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or its - key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - clientId: - description: ClientID is the OIDC client ID - type: string - clientSecretRef: - description: ClientSecretRef is a reference to a Kubernetes - Secret containing the client secret - properties: - key: - description: Key is the key within the secret - type: string - name: - description: Name is the name of the secret - type: string - required: - - key - - name - type: object - insecureAllowHTTP: - default: false - description: |- - InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing - WARNING: This is insecure and should NEVER be used in production - Only enable for local development, testing, or trusted internal networks - type: boolean - introspectionUrl: - description: IntrospectionURL is the URL for token introspection - endpoint - type: string - issuer: - description: Issuer is the OIDC issuer URL - type: string - jwksAllowPrivateIP: - default: false - description: |- - JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. - Use with caution - only enable for trusted internal IDPs. - Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP - is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). - type: boolean - jwksAuthTokenPath: - description: |- - JWKSAuthTokenPath is the path to file containing bearer token for JWKS/OIDC requests - The file must be mounted into the pod (e.g., via Secret volume) - type: string - jwksUrl: - description: JWKSURL is the URL to fetch the JWKS from - type: string - protectedResourceAllowPrivateIP: - default: false - description: |- - ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. - Use with caution - only enable for trusted internal IDPs or testing. - Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP - is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). - type: boolean - scopes: - description: |- - Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728) - If empty, defaults to ["openid"] - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - issuer - type: object - kubernetes: - description: |- - Kubernetes configures OIDC for Kubernetes service account token validation - Only used when Type is "kubernetes" - properties: - audience: - default: toolhive - description: Audience is the expected audience for the token - type: string - introspectionUrl: - description: |- - IntrospectionURL is the URL for token introspection endpoint - If empty, OIDC discovery will be used to automatically determine the introspection URL - type: string - issuer: - default: https://kubernetes.default.svc - description: Issuer is the OIDC issuer URL - type: string - jwksUrl: - description: |- - JWKSURL is the URL to fetch the JWKS from - If empty, OIDC discovery will be used to automatically determine the JWKS URL - type: string - namespace: - description: |- - Namespace is the namespace of the service account - If empty, uses the MCPServer's namespace - type: string - serviceAccount: - description: |- - ServiceAccount is the name of the service account to validate tokens for - If empty, uses the pod's service account - type: string - useClusterAuth: - description: |- - UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token - When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification - and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication - Defaults to true if not specified - type: boolean - type: object - resourceUrl: - description: |- - ResourceURL is the explicit resource URL for OAuth discovery endpoint (RFC 9728) - If not specified, defaults to the in-cluster Kubernetes service URL - type: string - type: - default: kubernetes - description: Type is the type of OIDC configuration - enum: - - kubernetes - - configMap - - inline - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: configMap must be set when type is 'configMap', and must - not be set otherwise - rule: 'self.type == ''configMap'' ? has(self.configMap) : !has(self.configMap)' - - message: inline must be set when type is 'inline', and must not - be set otherwise - rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' - - message: kubernetes must not be set when type is not 'kubernetes' - rule: 'self.type != ''kubernetes'' ? !has(self.kubernetes) : true' oidcConfigRef: description: |- OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. @@ -690,10 +465,6 @@ spec: required: - remoteUrl type: object - x-kubernetes-validations: - - message: oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef - to reference a shared MCPOIDCConfig - rule: '!(has(self.oidcConfig) && has(self.oidcConfigRef))' status: description: MCPRemoteProxyStatus defines the observed state of MCPRemoteProxy properties: diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml index 9d4b9c7518..cc1eced891 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml @@ -227,237 +227,12 @@ spec: maximum: 65535 minimum: 1 type: integer - oidcConfig: - description: |- - OIDCConfig defines OIDC authentication configuration for the MCP server. - Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead. - This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. - properties: - configMap: - description: |- - ConfigMap references a ConfigMap containing OIDC configuration - Only used when Type is "configmap" - properties: - caBundleRef: - description: |- - CABundleRef references a ConfigMap containing the CA certificate bundle. - When specified, ToolHive auto-mounts the ConfigMap and auto-computes ThvCABundlePath. - If the ConfigMap data contains an explicit thvCABundlePath key, it takes precedence. - properties: - configMapRef: - description: |- - ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or its - key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - key: - default: oidc.json - description: Key is the key in the ConfigMap that contains - the OIDC configuration - type: string - name: - description: Name is the name of the ConfigMap - type: string - required: - - name - type: object - inline: - description: |- - Inline contains direct OIDC configuration - Only used when Type is "inline" - properties: - audience: - description: Audience is the expected audience for the token - type: string - caBundleRef: - description: |- - CABundleRef references a ConfigMap containing the CA certificate bundle. - When specified, ToolHive auto-mounts the ConfigMap and auto-computes the CA bundle path. - properties: - configMapRef: - description: |- - ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or its - key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - clientId: - description: ClientID is the OIDC client ID - type: string - clientSecretRef: - description: ClientSecretRef is a reference to a Kubernetes - Secret containing the client secret - properties: - key: - description: Key is the key within the secret - type: string - name: - description: Name is the name of the secret - type: string - required: - - key - - name - type: object - insecureAllowHTTP: - default: false - description: |- - InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing - WARNING: This is insecure and should NEVER be used in production - Only enable for local development, testing, or trusted internal networks - type: boolean - introspectionUrl: - description: IntrospectionURL is the URL for token introspection - endpoint - type: string - issuer: - description: Issuer is the OIDC issuer URL - type: string - jwksAllowPrivateIP: - default: false - description: |- - JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. - Use with caution - only enable for trusted internal IDPs. - Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP - is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). - type: boolean - jwksAuthTokenPath: - description: |- - JWKSAuthTokenPath is the path to file containing bearer token for JWKS/OIDC requests - The file must be mounted into the pod (e.g., via Secret volume) - type: string - jwksUrl: - description: JWKSURL is the URL to fetch the JWKS from - type: string - protectedResourceAllowPrivateIP: - default: false - description: |- - ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. - Use with caution - only enable for trusted internal IDPs or testing. - Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP - is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). - type: boolean - scopes: - description: |- - Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728) - If empty, defaults to ["openid"] - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - issuer - type: object - kubernetes: - description: |- - Kubernetes configures OIDC for Kubernetes service account token validation - Only used when Type is "kubernetes" - properties: - audience: - default: toolhive - description: Audience is the expected audience for the token - type: string - introspectionUrl: - description: |- - IntrospectionURL is the URL for token introspection endpoint - If empty, OIDC discovery will be used to automatically determine the introspection URL - type: string - issuer: - default: https://kubernetes.default.svc - description: Issuer is the OIDC issuer URL - type: string - jwksUrl: - description: |- - JWKSURL is the URL to fetch the JWKS from - If empty, OIDC discovery will be used to automatically determine the JWKS URL - type: string - namespace: - description: |- - Namespace is the namespace of the service account - If empty, uses the MCPServer's namespace - type: string - serviceAccount: - description: |- - ServiceAccount is the name of the service account to validate tokens for - If empty, uses the pod's service account - type: string - useClusterAuth: - description: |- - UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token - When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification - and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication - Defaults to true if not specified - type: boolean - type: object - resourceUrl: - description: |- - ResourceURL is the explicit resource URL for OAuth discovery endpoint (RFC 9728) - If not specified, defaults to the in-cluster Kubernetes service URL - type: string - type: - default: kubernetes - description: Type is the type of OIDC configuration - enum: - - kubernetes - - configMap - - inline - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: configMap must be set when type is 'configMap', and must - not be set otherwise - rule: 'self.type == ''configMap'' ? has(self.configMap) : !has(self.configMap)' - - message: inline must be set when type is 'inline', and must not - be set otherwise - rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' - - message: kubernetes must not be set when type is not 'kubernetes' - rule: 'self.type != ''kubernetes'' ? !has(self.kubernetes) : true' oidcConfigRef: description: |- OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. The referenced MCPOIDCConfig must exist in the same namespace as this MCPServer. Per-server overrides (audience, scopes) are specified here; shared provider config - lives in the MCPOIDCConfig resource. Mutually exclusive with oidcConfig. + lives in the MCPOIDCConfig resource. properties: audience: description: |- @@ -962,21 +737,17 @@ spec: - image type: object x-kubernetes-validations: - - message: oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef - to reference a shared MCPOIDCConfig - rule: '!(has(self.oidcConfig) && has(self.oidcConfigRef))' - message: rateLimiting requires sessionStorage with provider 'redis' rule: '!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == ''redis'')' - - message: rateLimiting.perUser requires authentication (oidcConfig, oidcConfigRef, + - message: rateLimiting.perUser requires authentication (oidcConfigRef or externalAuthConfigRef) rule: '!(has(self.rateLimiting) && has(self.rateLimiting.perUser)) || - has(self.oidcConfig) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)' - - message: per-tool perUser rate limiting requires authentication (oidcConfig, - oidcConfigRef, or externalAuthConfigRef) + has(self.oidcConfigRef) || has(self.externalAuthConfigRef)' + - message: per-tool perUser rate limiting requires authentication (oidcConfigRef + or externalAuthConfigRef) rule: '!has(self.rateLimiting) || !has(self.rateLimiting.tools) || self.rateLimiting.tools.all(t, - !has(t.perUser)) || has(self.oidcConfig) || has(self.oidcConfigRef) - || has(self.externalAuthConfigRef)' + !has(t.perUser)) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)' status: description: MCPServerStatus defines the observed state of MCPServer properties: diff --git a/deploy/keycloak/mcpserver-with-auth.yaml b/deploy/keycloak/mcpserver-with-auth.yaml index f0c9709fab..f023b19a16 100644 --- a/deploy/keycloak/mcpserver-with-auth.yaml +++ b/deploy/keycloak/mcpserver-with-auth.yaml @@ -1,4 +1,19 @@ apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPOIDCConfig +metadata: + name: keycloak-oidc + namespace: toolhive-system +spec: + type: inline + inline: + # Keycloak issuer URL for the toolhive realm + issuer: http://keycloak:8080/realms/toolhive + # Explicit JWKS URL to avoid OIDC discovery issues + jwksUrl: http://keycloak-dev-service.keycloak.svc.cluster.local:8080/realms/toolhive/protocol/openid-connect/certs + # Optional: Allow private IP addresses for development + jwksAllowPrivateIP: true +--- +apiVersion: toolhive.stacklok.dev/v1alpha1 kind: MCPServer metadata: name: fetch-server-keycloak @@ -17,18 +32,11 @@ spec: mcpPort: 9090 env: - # OIDC authentication with Keycloak - oidcConfig: - type: inline - inline: - # Keycloak issuer URL for the toolhive realm - issuer: http://keycloak:8080/realms/toolhive - # Explicit JWKS URL to avoid OIDC discovery issues - jwksUrl: http://keycloak-dev-service.keycloak.svc.cluster.local:8080/realms/toolhive/protocol/openid-connect/certs - # MCP server client ID - tokens must have this in their audience claim - audience: mcp-server - # Optional: Allow private IP addresses for development - jwksAllowPrivateIP: true + # OIDC authentication with Keycloak via shared MCPOIDCConfig + oidcConfigRef: + name: keycloak-oidc + # MCP server client ID - tokens must have this in their audience claim + audience: mcp-server # Basic permission profile allowing network access permissionProfile: diff --git a/docs/arch/09-operator-architecture.md b/docs/arch/09-operator-architecture.md index 53b0cd31c2..c55f2aa06a 100644 --- a/docs/arch/09-operator-architecture.md +++ b/docs/arch/09-operator-architecture.md @@ -157,10 +157,8 @@ Defines an MCP server deployment, including container images, transports, middle MCPServer resources support various transport types (stdio, SSE, streamable-http), permission profiles, OIDC authentication, and Cedar-based authorization policies. The operator reconciles these resources into Kubernetes Deployments, Services, and StatefulSets. MCPServer supports referencing shared configuration CRDs: -- `oidcConfigRef` — references an MCPOIDCConfig for shared OIDC settings (replaces deprecated inline `oidcConfig`) -- `telemetryConfigRef` — references an MCPTelemetryConfig for shared telemetry settings (replaces deprecated inline `telemetry`) - -Both ref fields are mutually exclusive with their inline counterparts (enforced by CEL validation). +- `oidcConfigRef` — references an MCPOIDCConfig for shared OIDC settings +- `telemetryConfigRef` — references an MCPTelemetryConfig for shared telemetry settings **Status fields** include phase (Ready, Pending, Failed, Terminating), the accessible URL, and config hashes (`oidcConfigHash`, `telemetryConfigHash`) for change detection on referenced CRDs. @@ -257,13 +255,12 @@ Defines a proxy for remote MCP servers with authentication, authorization, audit **Key fields:** - `remoteUrl` - URL of the remote MCP server to proxy -- `oidcConfigRef` - Reference to shared MCPOIDCConfig (preferred, with per-server `audience` and `scopes`) -- `oidcConfig` - Inline OIDC authentication (deprecated, use `oidcConfigRef` instead) +- `oidcConfigRef` - Reference to shared MCPOIDCConfig (with per-server `audience` and `scopes`) - `externalAuthConfigRef` - Token exchange for remote service authentication - `authzConfig` - Authorization policies - `toolConfigRef` - Tool filtering and renaming -`oidcConfigRef` and `oidcConfig` are mutually exclusive (CEL enforced). OIDC is optional — omit both for unauthenticated proxies. +OIDC is optional — omit `oidcConfigRef` for unauthenticated proxies. **Implementation**: `cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go` @@ -659,29 +656,6 @@ spec: serviceName: my-server # per-server, must be unique ``` -### Inline Configuration (Deprecated) - -The inline patterns below still work but are deprecated in favor of the shared CRD references above. They will be removed in v1beta1. The `oidcConfigRef` / `telemetryConfigRef` fields are mutually exclusive with their inline counterparts (enforced by CEL validation). - -**OIDC (inline — deprecated):** -```yaml -spec: - oidcConfig: - type: inline - inline: - issuer: https://auth.example.com - audience: my-app -``` - -**OIDC (configMap — deprecated):** -```yaml -spec: - oidcConfig: - type: configMap - configMap: - name: oidc-config -``` - **Authz policies:** ```yaml spec: diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 6e09a71d07..e844fa4601 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -1918,7 +1918,6 @@ _Appears in:_ | `remoteUrl` _string_ | RemoteURL is the URL of the remote MCP server to proxy | | Pattern: `^https?://`
Required: \{\}
| | `proxyPort` _integer_ | ProxyPort is the port to expose the MCP proxy on | 8080 | Maximum: 65535
Minimum: 1
| | `transport` _string_ | Transport is the transport method for the remote proxy (sse or streamable-http) | streamable-http | Enum: [sse streamable-http]
| -| `oidcConfig` _[api.v1alpha1.OIDCConfigRef](#apiv1alpha1oidcconfigref)_ | OIDCConfig defines OIDC authentication configuration for the proxy.
Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead.
This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. | | Optional: \{\}
| | `oidcConfigRef` _[api.v1alpha1.MCPOIDCConfigReference](#apiv1alpha1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.
The referenced MCPOIDCConfig must exist in the same namespace as this MCPRemoteProxy.
Per-server overrides (audience, scopes) are specified here; shared provider config
lives in the MCPOIDCConfig resource. | | Optional: \{\}
| | `externalAuthConfigRef` _[api.v1alpha1.ExternalAuthConfigRef](#apiv1alpha1externalauthconfigref)_ | ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange.
When specified, the proxy will exchange validated incoming tokens for remote service tokens.
The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPRemoteProxy. | | Optional: \{\}
| | `authServerRef` _[api.v1alpha1.AuthServerRef](#apiv1alpha1authserverref)_ | AuthServerRef optionally references a resource that configures an embedded
OAuth 2.0/OIDC authorization server to authenticate MCP clients.
Currently the only supported kind is MCPExternalAuthConfig (type: embeddedAuthServer). | | Optional: \{\}
| @@ -2156,8 +2155,7 @@ _Appears in:_ | `permissionProfile` _[api.v1alpha1.PermissionProfileRef](#apiv1alpha1permissionprofileref)_ | PermissionProfile defines the permission profile to use | | Optional: \{\}
| | `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the MCP server
This allows for customizing the pod configuration beyond what is provided by the other fields.
Note that to modify the specific container the MCP server runs in, you must specify
the `mcp` container name in the PodTemplateSpec.
This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object
Optional: \{\}
| | `resourceOverrides` _[api.v1alpha1.ResourceOverrides](#apiv1alpha1resourceoverrides)_ | ResourceOverrides allows overriding annotations and labels for resources created by the operator | | Optional: \{\}
| -| `oidcConfig` _[api.v1alpha1.OIDCConfigRef](#apiv1alpha1oidcconfigref)_ | OIDCConfig defines OIDC authentication configuration for the MCP server.
Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead.
This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. | | Optional: \{\}
| -| `oidcConfigRef` _[api.v1alpha1.MCPOIDCConfigReference](#apiv1alpha1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.
The referenced MCPOIDCConfig must exist in the same namespace as this MCPServer.
Per-server overrides (audience, scopes) are specified here; shared provider config
lives in the MCPOIDCConfig resource. Mutually exclusive with oidcConfig. | | Optional: \{\}
| +| `oidcConfigRef` _[api.v1alpha1.MCPOIDCConfigReference](#apiv1alpha1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.
The referenced MCPOIDCConfig must exist in the same namespace as this MCPServer.
Per-server overrides (audience, scopes) are specified here; shared provider config
lives in the MCPOIDCConfig resource. | | Optional: \{\}
| | `authzConfig` _[api.v1alpha1.AuthzConfigRef](#apiv1alpha1authzconfigref)_ | AuthzConfig defines authorization policy configuration for the MCP server | | Optional: \{\}
| | `audit` _[api.v1alpha1.AuditConfig](#apiv1alpha1auditconfig)_ | Audit defines audit logging configuration for the MCP server | | Optional: \{\}
| | `toolConfigRef` _[api.v1alpha1.ToolConfigRef](#apiv1alpha1toolconfigref)_ | ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming.
The referenced MCPToolConfig must exist in the same namespace as this MCPServer.
Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\}
| @@ -2487,8 +2485,6 @@ OIDCConfigRef defines a reference to OIDC configuration _Appears in:_ - [api.v1alpha1.IncomingAuthConfig](#apiv1alpha1incomingauthconfig) -- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | diff --git a/examples/operator/external-auth/mcpremoteproxy_with_bearer_token.yaml b/examples/operator/external-auth/mcpremoteproxy_with_bearer_token.yaml index b37ecbbbb9..9e8ed6f65e 100644 --- a/examples/operator/external-auth/mcpremoteproxy_with_bearer_token.yaml +++ b/examples/operator/external-auth/mcpremoteproxy_with_bearer_token.yaml @@ -28,6 +28,18 @@ spec: name: api-bearer-token key: token +--- +# Shared OIDC configuration for incoming client authentication +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPOIDCConfig +metadata: + name: api-proxy-oidc + namespace: default +spec: + type: inline + inline: + issuer: "https://auth.example.com" + --- # MCPRemoteProxy that uses bearer token authentication for outgoing requests apiVersion: toolhive.stacklok.dev/v1alpha1 @@ -41,11 +53,9 @@ spec: transport: streamable-http # OIDC configuration for incoming authentication (validates tokens from clients) - oidcConfig: - type: inline - inline: - issuer: "https://auth.example.com" - audience: "mcp-api" + oidcConfigRef: + name: api-proxy-oidc + audience: "mcp-api" # Reference to external auth configuration (bearer token) externalAuthConfigRef: diff --git a/examples/operator/mcp-servers/mcpremoteproxy_with_inline_oidc_secretref.yaml b/examples/operator/mcp-servers/mcpremoteproxy_with_inline_oidc_secretref.yaml deleted file mode 100644 index 298df969e2..0000000000 --- a/examples/operator/mcp-servers/mcpremoteproxy_with_inline_oidc_secretref.yaml +++ /dev/null @@ -1,39 +0,0 @@ -# DEPRECATED: Inline oidcConfig will be removed in v1beta1. -# Prefer using a shared MCPOIDCConfig with oidcConfigRef instead. -# See mcpremoteproxy_with_oidcconfig_ref.yaml for the recommended pattern. -apiVersion: v1 -kind: Secret -metadata: - name: github-oauth-secret - namespace: toolhive-system -type: Opaque -stringData: - client-secret: "your-github-oauth-client-secret" ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRemoteProxy -metadata: - name: github-proxy - namespace: toolhive-system -spec: - remoteUrl: "https://api.github.com/mcp" - transport: streamable-http - oidcConfig: - type: inline - resourceUrl: "https://github-proxy.example.com" - inline: - issuer: "https://github.com" - audience: "github-mcp-api" - introspectionUrl: "https://github.com/oauth/token/info" - clientId: "Iv1.your-github-app-client-id" - # Use clientSecretRef to securely reference the OAuth client secret - clientSecretRef: - name: github-oauth-secret - key: client-secret - resources: - limits: - cpu: "200m" - memory: "256Mi" - requests: - cpu: "100m" - memory: "128Mi" diff --git a/examples/operator/mcp-servers/mcpserver_with_configmap_oidc.yaml b/examples/operator/mcp-servers/mcpserver_with_configmap_oidc.yaml deleted file mode 100644 index b1a7a6e4a5..0000000000 --- a/examples/operator/mcp-servers/mcpserver_with_configmap_oidc.yaml +++ /dev/null @@ -1,31 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: google-oidc-config - namespace: toolhive-system -data: - issuer: "https://accounts.google.com" - audience: "my-google-client-id" - clientId: "my-google-client-id" - # jwksUrl is optional - will be auto-discovered from issuer if not provided ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPServer -metadata: - name: secure-fetch-google - namespace: toolhive-system -spec: - image: docker.io/mcp/fetch - transport: stdio - proxyPort: 8080 - oidcConfig: - type: configmap - configMap: - name: google-oidc-config - resources: - limits: - cpu: "100m" - memory: "128Mi" - requests: - cpu: "50m" - memory: "64Mi" diff --git a/examples/operator/mcp-servers/mcpserver_with_inline_oidc.yaml b/examples/operator/mcp-servers/mcpserver_with_inline_oidc.yaml deleted file mode 100644 index fab940425f..0000000000 --- a/examples/operator/mcp-servers/mcpserver_with_inline_oidc.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# DEPRECATED: Inline oidcConfig will be removed in v1beta1. -# Prefer using a shared MCPOIDCConfig with oidcConfigRef instead. -# See mcpserver_with_oidcconfig_ref.yaml for the recommended pattern. -# -# Example: MCPServer with inline OIDC configuration -# Note: For client secret support, see mcpserver_with_inline_oidc_secretref.yaml -# which demonstrates using clientSecretRef to reference a Kubernetes Secret. -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPServer -metadata: - name: secure-fetch-inline - namespace: toolhive-system -spec: - image: docker.io/mcp/fetch - transport: stdio - proxyPort: 8080 - oidcConfig: - type: inline - inline: - issuer: "https://my-oidc-provider.com" - audience: "my-audience" - jwksUrl: "https://my-oidc-provider.com/.well-known/jwks.json" - clientId: "my-client-id" - # Optional: Reference a Kubernetes Secret for client secret - # clientSecretRef: - # name: oidc-client-secret - # key: client-secret - resources: - limits: - cpu: "100m" - memory: "128Mi" - requests: - cpu: "50m" - memory: "64Mi" diff --git a/examples/operator/mcp-servers/mcpserver_with_inline_oidc_secretref.yaml b/examples/operator/mcp-servers/mcpserver_with_inline_oidc_secretref.yaml deleted file mode 100644 index 13b7b6bcbd..0000000000 --- a/examples/operator/mcp-servers/mcpserver_with_inline_oidc_secretref.yaml +++ /dev/null @@ -1,40 +0,0 @@ -# DEPRECATED: Inline oidcConfig will be removed in v1beta1. -# Prefer using a shared MCPOIDCConfig with oidcConfigRef instead. -# See mcpserver_with_oidcconfig_ref.yaml for the recommended pattern. -apiVersion: v1 -kind: Secret -metadata: - name: oidc-client-secret - namespace: toolhive-system -type: Opaque -stringData: - client-secret: "your-oidc-client-secret-value" ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPServer -metadata: - name: secure-fetch-inline-secretref - namespace: toolhive-system -spec: - image: docker.io/mcp/fetch - transport: stdio - proxyPort: 8080 - oidcConfig: - type: inline - inline: - issuer: "https://my-oidc-provider.com" - audience: "my-audience" - jwksUrl: "https://my-oidc-provider.com/.well-known/jwks.json" - introspectionUrl: "https://my-oidc-provider.com/oauth/introspect" - clientId: "my-client-id" - # Use clientSecretRef to reference a Kubernetes Secret instead of hardcoding the secret - clientSecretRef: - name: oidc-client-secret - key: client-secret - resources: - limits: - cpu: "100m" - memory: "128Mi" - requests: - cpu: "50m" - memory: "64Mi" diff --git a/examples/operator/mcp-servers/mcpserver_with_kubernetes_oidc.yaml b/examples/operator/mcp-servers/mcpserver_with_kubernetes_oidc.yaml deleted file mode 100644 index acd56f4fe4..0000000000 --- a/examples/operator/mcp-servers/mcpserver_with_kubernetes_oidc.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# DEPRECATED: Inline oidcConfig will be removed in v1beta1. -# Prefer using a shared MCPOIDCConfig with oidcConfigRef instead. -# See mcpserver_with_oidcconfig_ref.yaml for the recommended pattern. -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPServer -metadata: - name: secure-fetch-k8s - namespace: toolhive-system -spec: - image: docker.io/mcp/fetch - transport: stdio - proxyPort: 8080 - oidcConfig: - type: kubernetes - kubernetes: - serviceAccount: "mcp-client" - namespace: "mcp-clients" - audience: "toolhive" - resources: - limits: - cpu: "100m" - memory: "128Mi" - requests: - cpu: "50m" - memory: "64Mi" diff --git a/pkg/export/k8s.go b/pkg/export/k8s.go index 1b10af79b8..6cdc84893a 100644 --- a/pkg/export/k8s.go +++ b/pkg/export/k8s.go @@ -125,20 +125,9 @@ func runConfigToMCPServer(config *runner.RunConfig) (*v1alpha1.MCPServer, error) } } - // Convert OIDC config - if config.OIDCConfig != nil { - mcpServer.Spec.OIDCConfig = &v1alpha1.OIDCConfigRef{ - Type: v1alpha1.OIDCConfigTypeInline, - Inline: &v1alpha1.InlineOIDCConfig{ - Issuer: config.OIDCConfig.Issuer, - Audience: config.OIDCConfig.Audience, - }, - } - - if config.OIDCConfig.JWKSURL != "" { - mcpServer.Spec.OIDCConfig.Inline.JWKSURL = config.OIDCConfig.JWKSURL - } - } + // Note: OIDC authentication requires a separate MCPOIDCConfig resource + // and an oidcConfigRef on the MCPServer. This export does not generate + // the MCPOIDCConfig resource — create it manually and reference it. // Convert authz config if config.AuthzConfig != nil && len(config.AuthzConfig.RawConfig()) > 0 { diff --git a/pkg/export/k8s_test.go b/pkg/export/k8s_test.go index de7cb56a68..337a728479 100644 --- a/pkg/export/k8s_test.go +++ b/pkg/export/k8s_test.go @@ -15,7 +15,6 @@ import ( "github.com/stacklok/toolhive-core/permissions" v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" "github.com/stacklok/toolhive/pkg/audit" - "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/authz" "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" "github.com/stacklok/toolhive/pkg/runner" @@ -149,29 +148,6 @@ func TestWriteK8sManifest(t *testing.T) { assert.Equal(t, "none", mcpServer.Spec.PermissionProfile.Name) }, }, - { - name: "config with OIDC", - config: &runner.RunConfig{ - Image: "ghcr.io/stacklok/mcp-server:latest", - Name: "test", - BaseName: "test", - Transport: types.TransportTypeStdio, - OIDCConfig: &auth.TokenValidatorConfig{ - Issuer: "https://accounts.google.com", - Audience: "my-client-id", - JWKSURL: "https://accounts.google.com/.well-known/jwks.json", - }, - }, - validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { - t.Helper() - require.NotNil(t, mcpServer.Spec.OIDCConfig) - assert.Equal(t, v1alpha1.OIDCConfigTypeInline, mcpServer.Spec.OIDCConfig.Type) - require.NotNil(t, mcpServer.Spec.OIDCConfig.Inline) - assert.Equal(t, "https://accounts.google.com", mcpServer.Spec.OIDCConfig.Inline.Issuer) - assert.Equal(t, "my-client-id", mcpServer.Spec.OIDCConfig.Inline.Audience) - assert.Equal(t, "https://accounts.google.com/.well-known/jwks.json", mcpServer.Spec.OIDCConfig.Inline.JWKSURL) - }, - }, { name: "config with authz", config: &runner.RunConfig{ diff --git a/test/e2e/thv-operator/acceptance_tests/ratelimit_test.go b/test/e2e/thv-operator/acceptance_tests/ratelimit_test.go index 09a4284c67..6e0cf6a965 100644 --- a/test/e2e/thv-operator/acceptance_tests/ratelimit_test.go +++ b/test/e2e/thv-operator/acceptance_tests/ratelimit_test.go @@ -186,6 +186,7 @@ var _ = Describe("MCPServer Rate Limiting", Ordered, func() { Context("per-user rate limits", Ordered, func() { var ( serverName = "peruser-rl-test" + oidcConfigName = "peruser-rl-oidc" oidcServerName = "oidc-peruser-rl" nodePort int32 oidcNodePort int32 @@ -199,7 +200,24 @@ var _ = Describe("MCPServer Rate Limiting", Ordered, func() { ctx, k8sClient, oidcServerName, testNamespace, timeout, pollingInterval) GinkgoWriter.Printf("Mock OIDC server: issuer=%s nodePort=%d\n", issuerURL, oidcNodePort) - By("Creating MCPServer with per-user rate limit and inline OIDC auth") + By("Creating MCPOIDCConfig for inline OIDC auth") + oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: oidcConfigName, + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: issuerURL, + JWKSAllowPrivateIP: true, + InsecureAllowHTTP: true, + }, + }, + } + Expect(k8sClient.Create(ctx, oidcConfig)).To(Succeed()) + + By("Creating MCPServer with per-user rate limit and OIDC auth ref") server := &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: serverName, @@ -217,14 +235,9 @@ var _ = Describe("MCPServer Rate Limiting", Ordered, func() { Provider: "redis", Address: fmt.Sprintf("redis.%s.svc.cluster.local:6379", testNamespace), }, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: issuerURL, - Audience: "vmcp-audience", - JWKSAllowPrivateIP: true, - InsecureAllowHTTP: true, - }, + OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + Name: oidcConfigName, + Audience: "vmcp-audience", }, RateLimiting: &mcpv1alpha1.RateLimitConfig{ PerUser: &mcpv1alpha1.RateLimitBucket{ @@ -269,6 +282,10 @@ var _ = Describe("MCPServer Rate Limiting", Ordered, func() { _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: serverName, Namespace: testNamespace}, }) + By("Cleaning up MCPOIDCConfig") + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: oidcConfigName, Namespace: testNamespace}, + }) By("Cleaning up OIDC server") if oidcCleanup != nil { oidcCleanup() From 9176e93fe4bd07c46d877658c6a6a7b019367627 Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota Date: Wed, 15 Apr 2026 09:36:13 +0200 Subject: [PATCH 036/117] Add horizontal scaling tests for vmcp (#4724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "Revert "Add LRU capacity to ValidatingCache, remove sentinel pattern, add storage Update" (#4730)" This reverts commit acc1f66303f8087d5823165f61ae15c9cc1b0605. * changes from review * Add horizontal scaling tests for vmcp Add integration and e2e tests for Redis-backed vMCP session sharing Integration tests (pkg/vmcp/server/sessionmanager, miniredis): - AC1: cross-pod session reconstruction via RestoreSession on cache miss - AC2: hijack prevention — wrong/nil callers rejected after restore, correct caller routes successfully - AC4: all backends unreachable on restore → empty routing table returned - AC5: NotifyBackendExpired removes backend from Redis metadata; subsequent restore on a fresh pod skips the expired backend - AC6: LocalSessionDataStorage (no Redis) → no cross-pod sharing E2E tests (test/e2e/thv-operator/virtualmcp, Kind + real Redis): - replicas=2 + Redis → SessionStorageWarning=False and Ready=True - cross-pod session reconstruction: session initialized on pod A is usable on pod B via transport.WithSession(sessionID) Note: AC3 (LRU eviction, RC-10) is intentionally excluded — TTL-based Redis eviction is sufficient. Closes #4222 * changes from review * fixes from review --------- Co-authored-by: taskbot --- .../virtualmcpserver_deployment.go | 2 +- cmd/vmcp/app/commands.go | 9 +- pkg/vmcp/k8s/manager.go | 10 +- pkg/vmcp/server/server.go | 53 ++ .../horizontal_scaling_integration_test.go | 381 ++++++++++ .../server/sessionmanager/session_manager.go | 16 +- .../sessionmanager/session_manager_test.go | 8 +- .../session/connector_integration_test.go | 81 ++ pkg/vmcp/session/default_session_test.go | 30 +- pkg/vmcp/session/factory.go | 25 +- pkg/vmcp/session/factory_metadata_test.go | 90 ++- .../session/internal/backend/mcp_session.go | 16 +- .../internal/backend/mcp_session_test.go | 2 +- pkg/vmcp/session/token_binding_test.go | 2 +- test/e2e/thv-operator/virtualmcp/helpers.go | 19 +- .../virtualmcp/mcpserver_scaling_test.go | 152 ++-- .../virtualmcp_redis_session_test.go | 718 ++++++++++++++++++ .../virtualmcpserver_scaling_test.go | 15 +- 18 files changed, 1523 insertions(+), 106 deletions(-) create mode 100644 pkg/vmcp/server/sessionmanager/horizontal_scaling_integration_test.go create mode 100644 test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go index 02fd044275..8cc58d86a1 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go @@ -207,7 +207,7 @@ func (r *VirtualMCPServerReconciler) deploymentForVirtualMCPServer( vmcpLivenessInitialDelay, vmcpLivenessPeriod, vmcpLivenessTimeout, vmcpLivenessFailures, ), ReadinessProbe: ctrlutil.BuildHealthProbe( - "/health", "http", + "/readyz", "http", vmcpReadinessInitialDelay, vmcpReadinessPeriod, vmcpReadinessTimeout, vmcpReadinessFailures, ), SecurityContext: containerSecurityContext, diff --git a/cmd/vmcp/app/commands.go b/cmd/vmcp/app/commands.go index 53238c7780..31dc6cebbc 100644 --- a/cmd/vmcp/app/commands.go +++ b/cmd/vmcp/app/commands.go @@ -630,13 +630,20 @@ func runServe(cmd *cobra.Command, _ []string) error { AuditConfig: cfg.Audit, HealthMonitorConfig: healthMonitorConfig, StatusReportingInterval: getStatusReportingInterval(cfg), - Watcher: backendWatcher, + Watcher: nil, // set below if backendWatcher is non-nil StatusReporter: statusReporter, OptimizerConfig: optCfg, SessionFactory: sessionFactory, SessionStorage: cfg.SessionStorage, } + // Assign Watcher only when backendWatcher is non-nil. A typed nil *k8s.BackendWatcher + // assigned directly to the Watcher interface field produces a non-nil interface value, + // which bypasses the nil check in handleReadiness and panics on the first /readyz probe. + if backendWatcher != nil { + serverCfg.Watcher = backendWatcher + } + // Convert composite tool configurations to workflow definitions workflowDefs, err := vmcpserver.ConvertConfigToWorkflowDefinitions(cfg.CompositeTools) if err != nil { diff --git a/pkg/vmcp/k8s/manager.go b/pkg/vmcp/k8s/manager.go index aceb73b629..4d17d76148 100644 --- a/pkg/vmcp/k8s/manager.go +++ b/pkg/vmcp/k8s/manager.go @@ -190,7 +190,7 @@ func (w *BackendWatcher) Start(ctx context.Context) error { slog.Info("watching backend resources", "namespace", w.namespace, "group", w.groupRef) // Register backend watch controller to reconcile MCPServer/MCPRemoteProxy changes - err := w.addBackendWatchController() + err := w.addBackendWatchController(ctx) if err != nil { return fmt.Errorf("failed to add backend watch controller: %w", err) } @@ -285,7 +285,7 @@ func (w *BackendWatcher) WaitForCacheSync(ctx context.Context) bool { // Returns: // - nil: Reconciler registered successfully // - error: Failed to create discoverer or register reconciler -func (w *BackendWatcher) addBackendWatchController() error { +func (w *BackendWatcher) addBackendWatchController(ctx context.Context) error { // Create K8s discoverer for backend conversion // This reuses the existing workloads package conversion logic discoverer := workloads.NewK8SDiscovererWithClient( @@ -302,6 +302,12 @@ func (w *BackendWatcher) addBackendWatchController() error { Discoverer: discoverer, } + // Register field indexes required by the reconciler's watch handlers. + // Must be called before SetupWithManager. + if err := reconciler.SetupIndexes(ctx, w.ctrlManager); err != nil { + return fmt.Errorf("failed to setup backend reconciler indexes: %w", err) + } + // Register reconciler with manager // This sets up watches on MCPServer, MCPRemoteProxy, and ExternalAuthConfig if err := reconciler.SetupWithManager(w.ctrlManager); err != nil { diff --git a/pkg/vmcp/server/server.go b/pkg/vmcp/server/server.go index 48dac4089e..d8eb991562 100644 --- a/pkg/vmcp/server/server.go +++ b/pkg/vmcp/server/server.go @@ -21,6 +21,7 @@ import ( "sync" "time" + "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/stacklok/toolhive/pkg/audit" @@ -491,6 +492,24 @@ func New( srv.handleSessionRegistration(ctx, session) }) + // Register OnBeforeListTools hook for lazy session tool injection. + // + // When a session is reconstructed from Redis on a different pod (cross-pod sharing), + // the SDK's per-session tool store is empty because OnRegisterSession only fires + // during Initialize, which the client doesn't re-send to pod B. This hook lazily + // injects the tools from the VMCP session manager into the ephemeral SDK session + // before handleListTools reads from the per-session tool store. + hooks.AddBeforeListTools(func(ctx context.Context, _ any, _ *mcp.ListToolsRequest) { + srv.lazyInjectSessionTools(ctx) + }) + + // Register OnBeforeCallTool hook for the same reason as OnBeforeListTools. + // A client may call a tool directly without first calling tools/list, so we + // also need to ensure the tool handlers are registered before the call is routed. + hooks.AddBeforeCallTool(func(ctx context.Context, _ any, _ *mcp.CallToolRequest) { + srv.lazyInjectSessionTools(ctx) + }) + // Disarm the close-on-error guard: Server is fully constructed. closeStorageOnErr = false return srv, nil @@ -1008,6 +1027,40 @@ func setSessionToolsDirect(session server.ClientSession, tools []server.ServerTo return nil } +// lazyInjectSessionTools injects tools into the SDK ephemeral session for sessions +// that were reconstructed from Redis on a different pod (cross-pod session sharing). +// +// When a client connects to pod B with an existing session ID (established on pod A), +// the SDK creates an ephemeral session with no tools because OnRegisterSession only fires +// during Initialize, which the client doesn't re-send to pod B. This method is called +// from OnBeforeListTools and OnBeforeCallTool hooks to lazily inject the tools before +// the SDK handler reads from the per-session tool store. +// +// For sessions initialized on this pod (normal case), tools are already in the store +// (set by setSessionToolsDirect during OnRegisterSession); this method is a no-op. +func (s *Server) lazyInjectSessionTools(ctx context.Context) { + sess := server.ClientSessionFromContext(ctx) + if sess == nil { + return + } + sessionWithTools, ok := sess.(server.SessionWithTools) + if !ok { + return + } + if len(sessionWithTools.GetSessionTools()) > 0 { + return // tools already registered (normal pod-local case) + } + sessionID := sess.SessionID() + adaptedTools, err := s.vmcpSessionMgr.GetAdaptedTools(sessionID) + if err != nil || len(adaptedTools) == 0 { + slog.Debug("lazyInjectSessionTools: no tools available for session", "session_id", sessionID) + return + } + if err := setSessionToolsDirect(sess, adaptedTools); err != nil { + slog.Warn("lazyInjectSessionTools: failed to inject tools", "session_id", sessionID, "error", err) + } +} + // handleSessionRegistration processes a new MCP session registration. // It fires AFTER the session is registered in the SDK. func (s *Server) handleSessionRegistration( diff --git a/pkg/vmcp/server/sessionmanager/horizontal_scaling_integration_test.go b/pkg/vmcp/server/sessionmanager/horizontal_scaling_integration_test.go new file mode 100644 index 0000000000..5155426ce7 --- /dev/null +++ b/pkg/vmcp/server/sessionmanager/horizontal_scaling_integration_test.go @@ -0,0 +1,381 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package sessionmanager + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + mcpmcp "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/auth" + transportsession "github.com/stacklok/toolhive/pkg/transport/session" + "github.com/stacklok/toolhive/pkg/vmcp" + vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth" + "github.com/stacklok/toolhive/pkg/vmcp/auth/strategies" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" + vmcpsession "github.com/stacklok/toolhive/pkg/vmcp/session" + sessiontypes "github.com/stacklok/toolhive/pkg/vmcp/session/types" +) + +// hmacSecret is a fixed 32-byte secret used across all integration tests. +var hmacSecret = []byte("test-hmac-secret-32bytes-exactly") + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// newUnauthenticatedAuthRegistry builds an OutgoingAuthRegistry with only the +// unauthenticated strategy registered — suitable for tests whose backend MCP +// servers require no auth. +func newUnauthenticatedAuthRegistry(t *testing.T) vmcpauth.OutgoingAuthRegistry { + t.Helper() + reg := vmcpauth.NewDefaultOutgoingAuthRegistry() + require.NoError(t, reg.RegisterStrategy(authtypes.StrategyTypeUnauthenticated, strategies.NewUnauthenticatedStrategy())) + return reg +} + +// newSharedRedisStorage creates a RedisSessionDataStorage pointing at mr. +// The storage is closed via t.Cleanup. +func newSharedRedisStorage(t *testing.T, mr *miniredis.Miniredis) transportsession.DataStorage { + t.Helper() + storage, err := transportsession.NewRedisSessionDataStorage( + context.Background(), + transportsession.RedisConfig{ + Addr: mr.Addr(), + KeyPrefix: "test:vmcp:session:", + }, + time.Hour, + ) + require.NoError(t, err) + t.Cleanup(func() { _ = storage.Close() }) + return storage +} + +// newTestManagerWithSharedStorage creates a Manager backed by the given +// DataStorage, a real session factory with the package-level hmacSecret, and +// an ImmutableRegistry containing backends. Cleanup is registered via +// t.Cleanup. +func newTestManagerWithSharedStorage(t *testing.T, storage transportsession.DataStorage, backends []*vmcp.Backend) *Manager { + t.Helper() + backendList := make([]vmcp.Backend, len(backends)) + for i, b := range backends { + backendList[i] = *b + } + registry := vmcp.NewImmutableRegistry(backendList) + factory := vmcpsession.NewSessionFactory( + newUnauthenticatedAuthRegistry(t), + vmcpsession.WithHMACSecret(hmacSecret), + ) + sm, cleanup, err := New(storage, &FactoryConfig{Base: factory}, registry) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, cleanup(context.Background())) }) + return sm +} + +// createSession runs the two-phase Generate + CreateSession flow. +// identity may be nil for anonymous sessions. +// Returns the assigned session ID. +func createSession(t *testing.T, sm *Manager, identity *auth.Identity) string { + t.Helper() + sessionID := sm.Generate() + require.NotEmpty(t, sessionID) + ctx := context.Background() + if identity != nil { + ctx = auth.WithIdentity(ctx, identity) + } + _, err := sm.CreateSession(ctx, sessionID) + require.NoError(t, err) + return sessionID +} + +// startMCPBackend starts an in-process streamable-HTTP MCP server that +// exposes a single tool named toolName (which echoes its "input" argument). +// The server is shut down when t completes. +// Returns a *vmcp.Backend pointing at the server. +func startMCPBackend(t *testing.T, backendID, toolName string) *vmcp.Backend { + t.Helper() + mcpSrv := mcpserver.NewMCPServer(backendID, "1.0.0") + mcpSrv.AddTool( + mcpmcp.NewTool(toolName, + mcpmcp.WithDescription("Echoes the input argument"), + mcpmcp.WithString("input", mcpmcp.Required()), + ), + func(_ context.Context, req mcpmcp.CallToolRequest) (*mcpmcp.CallToolResult, error) { + args, _ := req.Params.Arguments.(map[string]any) + input, _ := args["input"].(string) + return &mcpmcp.CallToolResult{ + Content: []mcpmcp.Content{mcpmcp.NewTextContent(input)}, + }, nil + }, + ) + streamableSrv := mcpserver.NewStreamableHTTPServer(mcpSrv) + mux := http.NewServeMux() + mux.Handle("/mcp", streamableSrv) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + return &vmcp.Backend{ + ID: backendID, + Name: backendID, + BaseURL: ts.URL + "/mcp", + TransportType: "streamable-http", + } +} + +// startStoppableMCPBackend is like startMCPBackend but also returns a stop +// function so the caller can shut the backend down mid-test (e.g. to simulate +// a backend going away). The stop function is idempotent and is also +// registered with t.Cleanup so the server is always closed even if the test +// fails before the caller invokes stop. +func startStoppableMCPBackend(t *testing.T, backendID, toolName string) (*vmcp.Backend, func()) { + t.Helper() + mcpSrv := mcpserver.NewMCPServer(backendID, "1.0.0") + mcpSrv.AddTool( + mcpmcp.NewTool(toolName, + mcpmcp.WithDescription("Echoes the input argument"), + mcpmcp.WithString("input", mcpmcp.Required()), + ), + func(_ context.Context, req mcpmcp.CallToolRequest) (*mcpmcp.CallToolResult, error) { + args, _ := req.Params.Arguments.(map[string]any) + input, _ := args["input"].(string) + return &mcpmcp.CallToolResult{ + Content: []mcpmcp.Content{mcpmcp.NewTextContent(input)}, + }, nil + }, + ) + streamableSrv := mcpserver.NewStreamableHTTPServer(mcpSrv) + mux := http.NewServeMux() + mux.Handle("/mcp", streamableSrv) + ts := httptest.NewServer(mux) + var once sync.Once + stop := func() { once.Do(ts.Close) } + t.Cleanup(stop) + return &vmcp.Backend{ + ID: backendID, + Name: backendID, + BaseURL: ts.URL + "/mcp", + TransportType: "streamable-http", + }, stop +} + +// --------------------------------------------------------------------------- +// AC1: Cross-pod session reconstruction +// --------------------------------------------------------------------------- + +// TestHorizontalScaling_CrossPodReconstruction verifies that a session +// created on "pod A" (Manager A) can be reconstructed on "pod B" (Manager B) +// via GetMultiSession → cache miss → RestoreSession from Redis. +func TestHorizontalScaling_CrossPodReconstruction(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) + storage := newSharedRedisStorage(t, mr) + backend := startMCPBackend(t, "backend-alpha", "echo") + + // Pod A: create a session; it is stored in Redis and cached locally in smA. + smA := newTestManagerWithSharedStorage(t, storage, []*vmcp.Backend{backend}) + sessionID := createSession(t, smA, nil) + + // Pod B: fresh Manager, same Redis storage — session is NOT in local cache. + smB := newTestManagerWithSharedStorage(t, storage, []*vmcp.Backend{backend}) + + // GetMultiSession triggers cache miss → loadSession → RestoreSession from Redis. + sess, ok := smB.GetMultiSession(sessionID) + require.True(t, ok, "pod B must reconstruct the session from Redis on cache miss") + require.NotNil(t, sess) + + // The restored session must have reconnected to the backend and discovered tools. + require.NotEmpty(t, sess.Tools(), "restored session must have the backend's tools") + assert.Equal(t, "echo", sess.Tools()[0].Name) +} + +// --------------------------------------------------------------------------- +// AC2: Cross-pod hijack prevention +// --------------------------------------------------------------------------- + +// TestHorizontalScaling_CrossPodHijackPrevention verifies that: +// - A session bound to alice on pod A can be reconstructed on pod B. +// - After restoration, a wrong-token caller is rejected (ErrUnauthorizedCaller). +// - A nil caller is rejected (ErrNilCaller). +// - The original caller (correct token) is not rejected at the auth layer. +func TestHorizontalScaling_CrossPodHijackPrevention(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) + storage := newSharedRedisStorage(t, mr) + backend := startMCPBackend(t, "backend-alpha", "echo") + + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}, + Token: "alice-bearer-token", + } + wrongCaller := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Subject: "eve"}, + Token: "eve-bearer-token", + } + + // Pod A: create session bound to alice. + smA := newTestManagerWithSharedStorage(t, storage, []*vmcp.Backend{backend}) + sessionID := createSession(t, smA, identity) + + // Pod B: restore from Redis. + smB := newTestManagerWithSharedStorage(t, storage, []*vmcp.Backend{backend}) + sess, ok := smB.GetMultiSession(sessionID) + require.True(t, ok, "session must be restorable on pod B") + require.NotNil(t, sess) + + ctx := context.Background() + + // Wrong caller must be rejected before any backend routing. + _, err := sess.CallTool(ctx, wrongCaller, "echo", map[string]any{"input": "hi"}, nil) + assert.ErrorIs(t, err, sessiontypes.ErrUnauthorizedCaller, "wrong token must be rejected") + + // Nil caller must be rejected. + _, err = sess.CallTool(ctx, nil, "echo", map[string]any{"input": "hi"}, nil) + assert.ErrorIs(t, err, sessiontypes.ErrNilCaller, "nil caller must be rejected") + + // Original caller must pass auth and successfully route to the backend. + // The backend is still running, so the call must complete without error. + _, err = sess.CallTool(ctx, identity, "echo", map[string]any{"input": "hi"}, nil) + require.NoError(t, err, "correct caller must be able to invoke the tool after restore") +} + +// --------------------------------------------------------------------------- +// AC3 is intentionally omitted: LRU eviction (RC-10, issue #4221) was dropped +// in favour of TTL-based Redis eviction. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// AC4: All backends fail during RestoreSession → empty routing table +// --------------------------------------------------------------------------- + +// TestHorizontalScaling_AllBackendsFailOnRestore verifies that when all +// backends are unreachable at restore time, GetMultiSession still returns a +// valid (non-nil) session with an empty routing table — consistent with the +// makeSession partial-failure behaviour documented in the spec. +func TestHorizontalScaling_AllBackendsFailOnRestore(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) + storage := newSharedRedisStorage(t, mr) + + // Use a stoppable backend so we can shut it down mid-test. + backend, stopBackend := startStoppableMCPBackend(t, "backend-alpha", "echo") + + smWriter := newTestManagerWithSharedStorage(t, storage, []*vmcp.Backend{backend}) + sessionID := createSession(t, smWriter, nil) + + // Stop the backend — RestoreSession will be unable to reconnect. + stopBackend() + + // Use a fresh manager: its cache is empty, so GetMultiSession takes the + // restore path without needing to explicitly evict the session. + smReader := newTestManagerWithSharedStorage(t, storage, []*vmcp.Backend{backend}) + sess, ok := smReader.GetMultiSession(sessionID) + require.True(t, ok, "GetMultiSession must return ok=true even when backends are unreachable") + require.NotNil(t, sess) + assert.Empty(t, sess.Tools(), "routing table must be empty when no backend reconnected") +} + +// --------------------------------------------------------------------------- +// AC5: RC-16 backend expiry — NotifyBackendExpired removes metadata; +// subsequent RestoreSession skips the expired backend. +// --------------------------------------------------------------------------- + +// TestHorizontalScaling_BackendExpiry_SkipsExpiredOnRestore verifies that +// after NotifyBackendExpired removes a backend from Redis metadata, a +// subsequent RestoreSession on a different pod only connects to the remaining +// backend and does not include the expired backend's tools. +func TestHorizontalScaling_BackendExpiry_SkipsExpiredOnRestore(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) + storage := newSharedRedisStorage(t, mr) + + // Two backends with distinct tool names so we can tell them apart. + backendA := startMCPBackend(t, "backend-alpha", "tool-alpha") + backendB := startMCPBackend(t, "backend-beta", "tool-beta") + + // Pod A: create session connected to both backends. + smA := newTestManagerWithSharedStorage(t, storage, []*vmcp.Backend{backendA, backendB}) + sessionID := createSession(t, smA, nil) + + // Verify session A has tools from both backends before expiry. + sessA, ok := smA.GetMultiSession(sessionID) + require.True(t, ok) + toolNames := make(map[string]bool) + for _, tool := range sessA.Tools() { + toolNames[tool.Name] = true + } + require.True(t, toolNames["tool-alpha"], "session A must have tool-alpha before expiry") + require.True(t, toolNames["tool-beta"], "session A must have tool-beta before expiry") + + // NotifyBackendExpired updates Redis to remove backend-beta; the node-local cache + // entry is evicted lazily on the next GetMultiSession when checkSession detects drift. + smA.NotifyBackendExpired(sessionID, backendB.ID, sessA.GetMetadata()) + + // Pod C: fresh Manager, same storage and both backends in registry. + // (backendB is still running — we're testing that RestoreSession filters + // it out based on the updated Redis metadata, not because it's unreachable.) + smC := newTestManagerWithSharedStorage(t, storage, []*vmcp.Backend{backendA, backendB}) + sessC, ok := smC.GetMultiSession(sessionID) + require.True(t, ok, "session must be restorable after NotifyBackendExpired") + require.NotNil(t, sessC) + + // Restored session must only have tool-alpha; tool-beta was filtered out. + restoredTools := make(map[string]bool) + for _, tool := range sessC.Tools() { + restoredTools[tool.Name] = true + } + assert.True(t, restoredTools["tool-alpha"], "restored session must have tool-alpha") + assert.False(t, restoredTools["tool-beta"], "restored session must NOT have tool-beta after expiry") +} + +// --------------------------------------------------------------------------- +// AC6: In-memory-only mode (no Redis) — no cross-pod sharing +// --------------------------------------------------------------------------- + +// TestHorizontalScaling_InMemoryOnlyMode verifies that when Redis is not +// configured (LocalSessionDataStorage), sessions are not visible to a second +// Manager instance, and single-pod usage continues to work correctly. +func TestHorizontalScaling_InMemoryOnlyMode(t *testing.T) { + t.Parallel() + + backend := startMCPBackend(t, "backend-alpha", "echo") + + newLocalStorage := func(t *testing.T) transportsession.DataStorage { + t.Helper() + s, err := transportsession.NewLocalSessionDataStorage(time.Hour) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + return s + } + + // Pod A and pod B each have their own local storage — no sharing. + storageA := newLocalStorage(t) + storageB := newLocalStorage(t) + + smA := newTestManagerWithSharedStorage(t, storageA, []*vmcp.Backend{backend}) + smB := newTestManagerWithSharedStorage(t, storageB, []*vmcp.Backend{backend}) + + sessionID := createSession(t, smA, nil) + + // Pod B must not be able to see pod A's session. + _, ok := smB.GetMultiSession(sessionID) + assert.False(t, ok, "in-memory-only: pod B must not see pod A's session") + + // Single-pod usage on pod A must still work. + sess, ok := smA.GetMultiSession(sessionID) + require.True(t, ok, "pod A must still serve its own session") + require.NotNil(t, sess) + assert.NotEmpty(t, sess.Tools(), "session on pod A must have tools") +} diff --git a/pkg/vmcp/server/sessionmanager/session_manager.go b/pkg/vmcp/server/sessionmanager/session_manager.go index 49ab10ad86..cad8365567 100644 --- a/pkg/vmcp/server/sessionmanager/session_manager.go +++ b/pkg/vmcp/server/sessionmanager/session_manager.go @@ -18,7 +18,6 @@ import ( "errors" "fmt" "log/slog" - "maps" "strings" "time" @@ -654,8 +653,19 @@ func (sm *Manager) checkSession(sessionID string, sess vmcpsession.MultiSession) return cache.ErrExpired } - // Evict if any metadata key has drifted, so the next Get restores current state. - if !maps.Equal(sess.GetMetadata(), metadata) { + // Evict if the backend ID list has drifted (e.g. NotifyBackendExpired removed a + // backend), so the next Get calls RestoreSession with the updated backend list. + // + // We intentionally compare only MetadataKeyBackendIDs rather than the full + // metadata map. Per-backend session IDs (MetadataKeyBackendSessionPrefix+*) + // are the session IDs negotiated by the restoring pod's backend connections. + // RestoreSession sends the stored IDs as Mcp-Session-Id hints, so a backend + // that honors session resumption will return the same ID — but not all backends + // do (e.g. SSE transports have no session ID at all). Comparing the full map + // would evict on every cross-pod cache hit whenever any backend assigns a + // fresh ID, preventing tools from ever being served. + sessBackendIDs := sess.GetMetadata()[vmcpsession.MetadataKeyBackendIDs] + if sessBackendIDs != metadata[vmcpsession.MetadataKeyBackendIDs] { return cache.ErrExpired } diff --git a/pkg/vmcp/server/sessionmanager/session_manager_test.go b/pkg/vmcp/server/sessionmanager/session_manager_test.go index 49dc56b180..8b3bd792fc 100644 --- a/pkg/vmcp/server/sessionmanager/session_manager_test.go +++ b/pkg/vmcp/server/sessionmanager/session_manager_test.go @@ -616,12 +616,16 @@ func TestSessionManager_Terminate(t *testing.T) { sessionID := sm.Generate() require.NotEmpty(t, sessionID) - // Upgrade to full MultiSession; CreateSession writes tokenHashMeta to storage. _, err := sm.CreateSession(context.Background(), sessionID) require.NoError(t, err) require.NotNil(t, createdSess) - // Terminate deletes from storage; cache entry remains until next Get. + // CreateSession already persists tokenHashMeta via sess.GetMetadata(), + // so Terminate will take the Phase 2 path (storage.Delete) without + // any additional seeding. + + // Terminate deletes from storage; the cache entry is evicted lazily on + // the next GetMultiSession call when checkSession detects ErrSessionNotFound. isNotAllowed, err := sm.Terminate(sessionID) require.NoError(t, err) assert.False(t, isNotAllowed) diff --git a/pkg/vmcp/session/connector_integration_test.go b/pkg/vmcp/session/connector_integration_test.go index 9262c459ef..3fc0e2d58f 100644 --- a/pkg/vmcp/session/connector_integration_test.go +++ b/pkg/vmcp/session/connector_integration_test.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "net/http" "net/http/httptest" + "sync" "testing" "github.com/google/uuid" @@ -465,3 +466,83 @@ func TestTokenBinding_MetadataEncoding(t *testing.T) { require.NoError(t, err, "token salt must be valid hex") assert.Len(t, saltBytes, 16, "decoded token salt must be 16 bytes") } + +// startInProcessMCPServerWithHeaderCapture starts an in-process MCP server and +// returns the base URL along with a function that returns all Mcp-Session-Id +// header values received by the server from clients. +func startInProcessMCPServerWithHeaderCapture(t *testing.T) (string, func() []string) { + t.Helper() + + mcpSrv := mcpserver.NewMCPServer("integration-test-backend", "1.0.0") + mcpSrv.AddTool( + mcpmcp.NewTool("echo", mcpmcp.WithDescription("echo"), mcpmcp.WithString("input", mcpmcp.Required())), + func(_ context.Context, req mcpmcp.CallToolRequest) (*mcpmcp.CallToolResult, error) { + args, _ := req.Params.Arguments.(map[string]any) + input, _ := args["input"].(string) + return &mcpmcp.CallToolResult{Content: []mcpmcp.Content{mcpmcp.NewTextContent(input)}}, nil + }, + ) + + streamableSrv := mcpserver.NewStreamableHTTPServer(mcpSrv) + + var mu sync.Mutex + var capturedIDs []string + + mux := http.NewServeMux() + mux.Handle("/mcp", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if id := r.Header.Get("Mcp-Session-Id"); id != "" { + mu.Lock() + capturedIDs = append(capturedIDs, id) + mu.Unlock() + } + streamableSrv.ServeHTTP(w, r) + })) + + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + return ts.URL + "/mcp", func() []string { + mu.Lock() + defer mu.Unlock() + out := make([]string, len(capturedIDs)) + copy(out, capturedIDs) + return out + } +} + +// TestSessionFactory_Integration_RestoreSession_SendsStoredSessionHintToBackend +// verifies that RestoreSession passes the stored backend session ID as the +// Mcp-Session-Id hint in the Initialize request so the backend can resume +// rather than create a new session. +func TestSessionFactory_Integration_RestoreSession_SendsStoredSessionHintToBackend(t *testing.T) { + t.Parallel() + + baseURL, capturedIDs := startInProcessMCPServerWithHeaderCapture(t) + backend := &vmcp.Backend{ + ID: "integration-backend", + Name: "integration-backend", + BaseURL: baseURL, + TransportType: "streamable-http", + } + + factory := NewSessionFactory(newUnauthenticatedRegistry(t)) + + // Create the original session — the backend assigns a session ID over + // streamable-HTTP and we store it in metadata. + orig, err := factory.MakeSessionWithID(context.Background(), uuid.New().String(), nil, true, []*vmcp.Backend{backend}) + require.NoError(t, err) + t.Cleanup(func() { _ = orig.Close() }) + + storedMeta := orig.GetMetadata() + storedBackendSessionID := storedMeta[MetadataKeyBackendSessionPrefix+"integration-backend"] + require.NotEmpty(t, storedBackendSessionID, "streamable-HTTP backend must assign a session ID on Initialize") + + // RestoreSession: the factory must send the stored session ID as Mcp-Session-Id. + restored, err := factory.RestoreSession(context.Background(), uuid.New().String(), storedMeta, []*vmcp.Backend{backend}) + require.NoError(t, err) + t.Cleanup(func() { _ = restored.Close() }) + + // The server must have received the stored ID as a hint in the Initialize request. + assert.Contains(t, capturedIDs(), storedBackendSessionID, + "RestoreSession must send the stored backend session ID as Mcp-Session-Id hint") +} diff --git a/pkg/vmcp/session/default_session_test.go b/pkg/vmcp/session/default_session_test.go index 7d5be93102..a0055f57e9 100644 --- a/pkg/vmcp/session/default_session_test.go +++ b/pkg/vmcp/session/default_session_test.go @@ -537,7 +537,7 @@ func TestNewSessionFactory_MakeSession(t *testing.T) { } //nolint:unparam // second return is always nil by design in the success-path connector - successConnector := func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + successConnector := func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { return &mockConnectedBackend{sessID: "bs-1"}, &vmcp.CapabilityList{ Tools: []vmcp.Tool{tool}, Resources: []vmcp.Resource{resource}, @@ -616,7 +616,7 @@ func TestNewSessionFactory_PartialInitialisation(t *testing.T) { {ID: "fail", Name: "fail", BaseURL: "http://fail:9999", TransportType: "streamable-http"}, } - connector := func(_ context.Context, target *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + connector := func(_ context.Context, target *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { if target.WorkloadID == "fail" { return nil, nil, errors.New("backend unavailable") } @@ -650,20 +650,20 @@ func TestNewSessionFactory_ConnectorReturnsNilWithoutError(t *testing.T) { }{ { name: "nil conn with nil caps", - connector: func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + connector: func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { return nil, nil, nil }, }, { name: "nil conn with non-nil caps", - connector: func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + connector: func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { return nil, &vmcp.CapabilityList{}, nil }, }, { name: "non-nil conn with nil caps must close conn to avoid leak", wantConnClose: true, - connector: func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + connector: func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { return &mockConnectedBackend{}, nil, nil }, }, @@ -676,8 +676,8 @@ func TestNewSessionFactory_ConnectorReturnsNilWithoutError(t *testing.T) { // Replace the connector with one that captures the mock so we can // inspect closeCalled after MakeSession returns. var captured *mockConnectedBackend - wrappedConnector := func(ctx context.Context, target *vmcp.BackendTarget, id *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { - conn, caps, err := tt.connector(ctx, target, id) + wrappedConnector := func(ctx context.Context, target *vmcp.BackendTarget, id *auth.Identity, hint string) (internalbk.Session, *vmcp.CapabilityList, error) { + conn, caps, err := tt.connector(ctx, target, id, hint) if m, ok := conn.(*mockConnectedBackend); ok { captured = m } @@ -707,7 +707,7 @@ func TestNewSessionFactory_ConnectorReturnsConnWithError(t *testing.T) { backend := &vmcp.Backend{ID: "b1", Name: "b1", BaseURL: "http://x:9", TransportType: "streamable-http"} leaked := &mockConnectedBackend{} - connector := func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + connector := func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { return leaked, nil, errors.New("init failed but conn was partially opened") } @@ -732,7 +732,7 @@ func TestNewSessionFactory_CapabilityNameConflictIsResolvedDeterministically(t * {ID: "alpha", Name: "alpha", BaseURL: "http://alpha:9", TransportType: "streamable-http"}, } - connector := func(_ context.Context, target *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + connector := func(_ context.Context, target *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { return &mockConnectedBackend{sessID: target.WorkloadID}, &vmcp.CapabilityList{ Tools: []vmcp.Tool{{Name: "fetch", BackendID: target.WorkloadID}}, Resources: []vmcp.Resource{{URI: "file://data", BackendID: target.WorkloadID}}, @@ -766,7 +766,7 @@ func TestNewSessionFactory_AllBackendsFail(t *testing.T) { t.Parallel() backend := &vmcp.Backend{ID: "b1", Name: "b1", BaseURL: "http://x:9", TransportType: "streamable-http"} - connector := func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + connector := func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { return nil, nil, errors.New("down") } @@ -785,7 +785,7 @@ func TestNewSessionFactory_BackendInitTimeout(t *testing.T) { backend := &vmcp.Backend{ID: "slow", Name: "slow", BaseURL: "http://x:9", TransportType: "streamable-http"} released := make(chan struct{}) - connector := func(ctx context.Context, _ *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + connector := func(ctx context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { select { case <-ctx.Done(): return nil, nil, ctx.Err() @@ -823,7 +823,7 @@ func TestNewSessionFactory_ParallelInit(t *testing.T) { var mu sync.Mutex var maxConcurrent, current int32 - connector := func(_ context.Context, target *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + connector := func(_ context.Context, target *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { mu.Lock() current++ if current > maxConcurrent { @@ -864,10 +864,10 @@ func TestNewSessionFactory_MakeSession_Metadata(t *testing.T) { backend2 := &vmcp.Backend{ID: "b2", Name: "backend-2", BaseURL: "http://localhost:9002", TransportType: "streamable-http"} //nolint:unparam // error return is always nil by design in the success-path connector - successConnector := func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + successConnector := func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { return &mockConnectedBackend{}, &vmcp.CapabilityList{}, nil } - failConnector := func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + failConnector := func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { return nil, nil, errors.New("connection refused") } @@ -1132,7 +1132,7 @@ func TestValidateSessionID(t *testing.T) { func TestMakeSessionWithID_InvalidIDReturnsError(t *testing.T) { t.Parallel() - f := newSessionFactoryWithConnector(func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + f := newSessionFactoryWithConnector(func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { return nil, nil, nil }) diff --git a/pkg/vmcp/session/factory.go b/pkg/vmcp/session/factory.go index c7362484a0..a5e115eacd 100644 --- a/pkg/vmcp/session/factory.go +++ b/pkg/vmcp/session/factory.go @@ -108,6 +108,11 @@ type MultiSessionFactory interface { // 2. Running the MCP Initialize handshake. // 3. Querying backend capabilities (tools, resources, prompts). // +// sessionHint is the backend-assigned session ID from a prior connection (stored +// in Redis metadata). When non-empty the connector should send it as the +// Mcp-Session-Id hint during Initialize so the backend can resume rather than +// re-initialize. Pass an empty string for brand-new sessions. +// // The returned backend.Session owns the underlying transport connection and // must be closed when the session ends. The returned CapabilityList is used // to populate the session's routing table and capability lists. @@ -118,6 +123,7 @@ type backendConnector func( ctx context.Context, target *vmcp.BackendTarget, identity *auth.Identity, + sessionHint string, ) (backend.Session, *vmcp.CapabilityList, error) // defaultMultiSessionFactory is the production MultiSessionFactory implementation. @@ -220,12 +226,13 @@ func (f *defaultMultiSessionFactory) initOneBackend( ctx context.Context, b *vmcp.Backend, identity *auth.Identity, + sessionHint string, ) *initResult { bCtx, cancel := context.WithTimeout(ctx, f.backendInitTimeout) defer cancel() target := vmcp.BackendToTarget(b) - conn, caps, err := f.connector(bCtx, target, identity) + conn, caps, err := f.connector(bCtx, target, identity, sessionHint) if err != nil { if conn != nil { _ = conn.Close() @@ -415,6 +422,7 @@ func (f *defaultMultiSessionFactory) makeBaseSession( sessID string, identity *auth.Identity, backends []*vmcp.Backend, + sessionHints map[string]string, ) (*defaultMultiSession, error) { filtered := make([]*vmcp.Backend, 0, len(backends)) for _, b := range backends { @@ -435,7 +443,7 @@ func (f *defaultMultiSessionFactory) makeBaseSession( defer wg.Done() sem <- struct{}{} defer func() { <-sem }() - rawResults[i] = f.initOneBackend(ctx, b, identity) + rawResults[i] = f.initOneBackend(ctx, b, identity, sessionHints[b.ID]) }(i, b) } wg.Wait() @@ -507,7 +515,7 @@ func (f *defaultMultiSessionFactory) makeSession( identity *auth.Identity, backends []*vmcp.Backend, ) (MultiSession, error) { - baseSession, err := f.makeBaseSession(ctx, sessID, identity, backends) + baseSession, err := f.makeBaseSession(ctx, sessID, identity, backends, nil) if err != nil { return nil, err } @@ -558,9 +566,18 @@ func (f *defaultMultiSessionFactory) RestoreSession( identity.Subject = subject } + // Extract stored per-backend session IDs as hints so each backend can + // resume its session (via Mcp-Session-Id) rather than starting a new one. + sessionHints := make(map[string]string, len(filteredBackends)) + for _, b := range filteredBackends { + if hint := storedMetadata[MetadataKeyBackendSessionPrefix+b.ID]; hint != "" { + sessionHints[b.ID] = hint + } + } + // Build the base session (backend connections + routing table) without the // security wrapper. The wrapper is applied separately using stored hash/salt. - baseSession, err := f.makeBaseSession(ctx, id, identity, filteredBackends) + baseSession, err := f.makeBaseSession(ctx, id, identity, filteredBackends, sessionHints) if err != nil { return nil, fmt.Errorf("RestoreSession: failed to rebuild backend connections: %w", err) } diff --git a/pkg/vmcp/session/factory_metadata_test.go b/pkg/vmcp/session/factory_metadata_test.go index 4c0df5e8d6..81f1ed50fc 100644 --- a/pkg/vmcp/session/factory_metadata_test.go +++ b/pkg/vmcp/session/factory_metadata_test.go @@ -6,6 +6,7 @@ package session import ( "context" "strings" + "sync" "testing" "github.com/google/uuid" @@ -23,7 +24,7 @@ func TestMakeSession_PersistsBackendSessionIDs(t *testing.T) { t.Run("two backends: both session IDs written to metadata", func(t *testing.T) { t.Parallel() - connector := func(_ context.Context, target *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + connector := func(_ context.Context, target *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { ids := map[string]string{ "backend-a": "sess-a", "backend-b": "sess-b", @@ -71,7 +72,7 @@ func TestMakeSession_PersistsBackendSessionIDs(t *testing.T) { t.Run("partial failure: only successful backend written", func(t *testing.T) { t.Parallel() - connector := func(_ context.Context, target *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + connector := func(_ context.Context, target *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { if target.WorkloadID == "backend-ok" { return &mockConnectedBackend{sessID: "sess-ok"}, &vmcp.CapabilityList{}, nil } @@ -102,7 +103,7 @@ func TestMakeSession_PersistsBackendSessionIDs(t *testing.T) { func TestRestoreSession_FreshlyPopulatesMetadataKeyBackendIDs(t *testing.T) { t.Parallel() - connector := func(_ context.Context, target *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + connector := func(_ context.Context, target *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { ids := map[string]string{ "backend-a": "sess-a", "backend-b": "sess-b", @@ -166,3 +167,86 @@ func TestRestoreSession_AbsentMetadataKeyBackendIDsReturnsError(t *testing.T) { assert.Contains(t, err.Error(), MetadataKeyBackendIDs, "error message must name the missing key") } + +// TestRestoreSession_PassesStoredSessionHintToConnector verifies that +// RestoreSession reads the per-backend session IDs stored in metadata and +// passes them as session hints to the backend connector, so backends can +// resume rather than re-initialize their sessions. +func TestRestoreSession_PassesStoredSessionHintToConnector(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + hintsReceived := map[string]string{} + + // connector records the session hint it receives for each backend. + // It always returns a stable session ID so that the original session + // has predictable per-backend metadata to store. + connector := func(_ context.Context, target *vmcp.BackendTarget, _ *auth.Identity, sessionHint string) (internalbk.Session, *vmcp.CapabilityList, error) { + mu.Lock() + hintsReceived[target.WorkloadID] = sessionHint + mu.Unlock() + return &mockConnectedBackend{sessID: "orig-" + target.WorkloadID}, &vmcp.CapabilityList{}, nil + } + + factory := newSessionFactoryWithConnector(connector) + backends := []*vmcp.Backend{ + {ID: "backend-a"}, + {ID: "backend-b"}, + } + + // Create the original session — connector receives empty hints. + original, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), nil, true, backends) + require.NoError(t, err) + t.Cleanup(func() { _ = original.Close() }) + + // Confirm original session stored per-backend session IDs in metadata. + storedMeta := original.GetMetadata() + storedHintA := storedMeta[MetadataKeyBackendSessionPrefix+"backend-a"] + storedHintB := storedMeta[MetadataKeyBackendSessionPrefix+"backend-b"] + require.NotEmpty(t, storedHintA, "original session must write backend-a session ID to metadata") + require.NotEmpty(t, storedHintB, "original session must write backend-b session ID to metadata") + + // Reset captured hints before calling RestoreSession. + mu.Lock() + hintsReceived = map[string]string{} + mu.Unlock() + + // RestoreSession must pass the stored session IDs as hints to the connector. + restored, err := factory.RestoreSession(t.Context(), uuid.New().String(), storedMeta, backends) + require.NoError(t, err) + t.Cleanup(func() { _ = restored.Close() }) + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, storedHintA, hintsReceived["backend-a"], + "RestoreSession must pass stored backend-a session ID as hint to connector") + assert.Equal(t, storedHintB, hintsReceived["backend-b"], + "RestoreSession must pass stored backend-b session ID as hint to connector") +} + +// TestMakeSession_PassesEmptySessionHintToConnector verifies that MakeSession +// (creating a new session, not restoring) passes an empty hint so that the +// backend always creates a fresh session. +func TestMakeSession_PassesEmptySessionHintToConnector(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + hintsReceived := map[string]string{} + + connector := func(_ context.Context, target *vmcp.BackendTarget, _ *auth.Identity, sessionHint string) (internalbk.Session, *vmcp.CapabilityList, error) { + mu.Lock() + hintsReceived[target.WorkloadID] = sessionHint + mu.Unlock() + return &mockConnectedBackend{sessID: "new-sess"}, &vmcp.CapabilityList{}, nil + } + + factory := newSessionFactoryWithConnector(connector) + sess, err := factory.MakeSessionWithID(t.Context(), uuid.New().String(), nil, true, []*vmcp.Backend{{ID: "backend-a"}}) + require.NoError(t, err) + t.Cleanup(func() { _ = sess.Close() }) + + mu.Lock() + defer mu.Unlock() + assert.Empty(t, hintsReceived["backend-a"], + "MakeSession must pass an empty session hint to the connector") +} diff --git a/pkg/vmcp/session/internal/backend/mcp_session.go b/pkg/vmcp/session/internal/backend/mcp_session.go index dfbb626ce8..75425e98f6 100644 --- a/pkg/vmcp/session/internal/backend/mcp_session.go +++ b/pkg/vmcp/session/internal/backend/mcp_session.go @@ -199,13 +199,15 @@ func NewHTTPConnector(registry vmcpauth.OutgoingAuthRegistry) func( ctx context.Context, target *vmcp.BackendTarget, identity *auth.Identity, + sessionHint string, ) (Session, *vmcp.CapabilityList, error) { return func( ctx context.Context, target *vmcp.BackendTarget, identity *auth.Identity, + sessionHint string, ) (Session, *vmcp.CapabilityList, error) { - c, err := createMCPClient(target, identity, registry) + c, err := createMCPClient(target, identity, registry, sessionHint) if err != nil { return nil, nil, fmt.Errorf("failed to create MCP client for backend %s: %w", target.WorkloadID, err) } @@ -233,10 +235,13 @@ func NewHTTPConnector(registry vmcpauth.OutgoingAuthRegistry) func( // createMCPClient builds and starts a mark3labs MCP client for target. // The transport is started with context.Background() so its lifetime is bound // to client.Close(), not to any caller-supplied init context. +// sessionHint, when non-empty, is passed as the initial Mcp-Session-Id for +// streamable-HTTP transports so the backend can resume an existing session. func createMCPClient( target *vmcp.BackendTarget, identity *auth.Identity, registry vmcpauth.OutgoingAuthRegistry, + sessionHint string, ) (*mcpclient.Client, error) { // Resolve and validate the auth strategy once at client creation time. strategyName := authtypes.StrategyTypeUnauthenticated @@ -294,11 +299,14 @@ func createMCPClient( Transport: sizeLimited, Timeout: defaultBackendRequestTimeout, } - c, err = mcpclient.NewStreamableHttpClient( - target.BaseURL, + streamableOpts := []mcptransport.StreamableHTTPCOption{ mcptransport.WithHTTPTimeout(defaultBackendRequestTimeout), mcptransport.WithHTTPBasicClient(httpClient), - ) + } + if sessionHint != "" { + streamableOpts = append(streamableOpts, mcptransport.WithSession(sessionHint)) + } + c, err = mcpclient.NewStreamableHttpClient(target.BaseURL, streamableOpts...) case "sse": // For SSE, the entire session is delivered as one long-lived HTTP // response body. Applying io.LimitReader to that body would silently diff --git a/pkg/vmcp/session/internal/backend/mcp_session_test.go b/pkg/vmcp/session/internal/backend/mcp_session_test.go index 77f3345465..7daf5decce 100644 --- a/pkg/vmcp/session/internal/backend/mcp_session_test.go +++ b/pkg/vmcp/session/internal/backend/mcp_session_test.go @@ -40,7 +40,7 @@ func TestCreateMCPClient_UnsupportedTransport(t *testing.T) { TransportType: transport, } - _, err := createMCPClient(target, nil, newTestRegistry(t)) + _, err := createMCPClient(target, nil, newTestRegistry(t), "") require.Error(t, err) assert.ErrorIs(t, err, vmcp.ErrUnsupportedTransport, "transport %q should return ErrUnsupportedTransport", transport) diff --git a/pkg/vmcp/session/token_binding_test.go b/pkg/vmcp/session/token_binding_test.go index 073e4ad203..db8fdf412e 100644 --- a/pkg/vmcp/session/token_binding_test.go +++ b/pkg/vmcp/session/token_binding_test.go @@ -26,7 +26,7 @@ import ( // backend to be skipped during init. This lets us exercise session-metadata // logic without real backend connections. func nilBackendConnector() backendConnector { - return func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity) (internalbk.Session, *vmcp.CapabilityList, error) { + return func(_ context.Context, _ *vmcp.BackendTarget, _ *auth.Identity, _ string) (internalbk.Session, *vmcp.CapabilityList, error) { return nil, nil, nil } } diff --git a/test/e2e/thv-operator/virtualmcp/helpers.go b/test/e2e/thv-operator/virtualmcp/helpers.go index b5a02a4450..a5dfe53226 100644 --- a/test/e2e/thv-operator/virtualmcp/helpers.go +++ b/test/e2e/thv-operator/virtualmcp/helpers.go @@ -34,6 +34,13 @@ import ( "github.com/stacklok/toolhive/test/e2e/thv-operator/testutil" ) +// Shared test constants used across all e2e test files in this package. +const ( + defaultNamespace = "default" + e2eTimeout = 5 * time.Minute + e2ePollInterval = 2 * time.Second +) + // WaitForVirtualMCPServerReady waits for a VirtualMCPServer to reach Ready status // and ensures at least one associated pod is actually running and ready. // This is used when waiting for a single expected pod (e.g., one replica deployment). @@ -828,7 +835,7 @@ func GetVMCPNodePort( } // Verify the HTTP server is ready to handle requests - if err := checkHTTPHealthReady(nodePort, 2*time.Second); err != nil { + if err := checkHTTPHealthReady(nodePort); err != nil { return fmt.Errorf("nodePort %d accessible but HTTP server not ready: %w", nodePort, err) } @@ -847,25 +854,21 @@ func checkPortAccessible(nodePort int32, timeout time.Duration) error { if err != nil { return fmt.Errorf("port %d not accessible: %w", nodePort, err) } - // Port is accessible - close connection (ignore errors as port accessibility is confirmed) _ = conn.Close() return nil } // checkHTTPHealthReady verifies the HTTP server is ready by checking the /health endpoint. // This is more reliable than just TCP check as it ensures the application is serving requests. -func checkHTTPHealthReady(nodePort int32, timeout time.Duration) error { - httpClient := &http.Client{Timeout: timeout} +func checkHTTPHealthReady(nodePort int32) error { + httpClient := &http.Client{Timeout: 2 * time.Second} url := fmt.Sprintf("http://localhost:%d/health", nodePort) resp, err := httpClient.Get(url) if err != nil { return fmt.Errorf("health check failed for port %d: %w", nodePort, err) } - defer func() { - // Error ignored in test cleanup - _ = resp.Body.Close() - }() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("health check returned status %d for port %d", resp.StatusCode, nodePort) diff --git a/test/e2e/thv-operator/virtualmcp/mcpserver_scaling_test.go b/test/e2e/thv-operator/virtualmcp/mcpserver_scaling_test.go index c94afc7251..c4223b6f46 100644 --- a/test/e2e/thv-operator/virtualmcp/mcpserver_scaling_test.go +++ b/test/e2e/thv-operator/virtualmcp/mcpserver_scaling_test.go @@ -6,9 +6,11 @@ package virtualmcp import ( "context" + "encoding/json" "fmt" "net" "os/exec" + "strings" "time" mcpclient "github.com/mark3labs/mcp-go/client" @@ -16,6 +18,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" + "github.com/redis/go-redis/v9" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -26,17 +29,23 @@ import ( mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" "github.com/stacklok/toolhive/test/e2e/images" + "github.com/stacklok/toolhive/test/e2e/thv-operator/testutil" +) + +const ( + proxyPort = int32(8080) // MCPServer proxy container port + vmcpPort = int32(4483) // VirtualMCPServer container port ) // deployRedis creates a single-replica Redis Deployment and ClusterIP Service. // Returns after the deployment has at least one ready replica. -func deployRedis(namespace, name string, timeout, pollInterval time.Duration) { +func deployRedis(name string) { labels := map[string]string{"app": name} deployment := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: name, - Namespace: namespace, + Namespace: defaultNamespace, Labels: labels, }, Spec: appsv1.DeploymentSpec{ @@ -68,7 +77,7 @@ func deployRedis(namespace, name string, timeout, pollInterval time.Duration) { service := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: name, - Namespace: namespace, + Namespace: defaultNamespace, }, Spec: corev1.ServiceSpec{ Selector: labels, @@ -85,20 +94,20 @@ func deployRedis(namespace, name string, timeout, pollInterval time.Duration) { ginkgo.By("Waiting for Redis to become ready") gomega.Eventually(func() bool { dep := &appsv1.Deployment{} - if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, dep); err != nil { + if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: defaultNamespace}, dep); err != nil { return false } return dep.Status.ReadyReplicas > 0 - }, timeout, pollInterval).Should(gomega.BeTrue(), "Redis should be ready") + }, e2eTimeout, e2ePollInterval).Should(gomega.BeTrue(), "Redis should be ready") } // cleanupRedis removes the Redis Deployment and Service. -func cleanupRedis(namespace, name string) { +func cleanupRedis(name string) { _ = k8sClient.Delete(ctx, &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: defaultNamespace}, }) _ = k8sClient.Delete(ctx, &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: defaultNamespace}, }) } @@ -130,25 +139,10 @@ func getReadyMCPServerPods(mcpServerName, namespace string) ([]corev1.Pod, error return ready, nil } -// waitForMCPServerRunning waits for an MCPServer to reach Running phase. -func waitForMCPServerRunning(name, namespace string, timeout, pollInterval time.Duration) { - gomega.Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} - if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, server); err != nil { - return err - } - if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { - return fmt.Errorf("MCPServer phase is %s, want Ready", server.Status.Phase) - } - return nil - }, timeout, pollInterval).Should(gomega.Succeed()) -} - -// portForwardToPod starts a kubectl port-forward to a specific pod and returns the -// local port and a cleanup function. The caller must call cleanup to stop the port-forward. -// -//nolint:unparam // namespace kept as parameter for reusability across test contexts -func portForwardToPod(podName, namespace string, targetPort int32) (int, func(), error) { +// portForwardToPod starts a kubectl port-forward to a specific pod's containerPort and +// returns the local port and a cleanup function. The caller must call cleanup to stop +// the port-forward. +func portForwardToPod(podName string, containerPort int32) (int, func(), error) { // Find a free local port listener, err := net.Listen("tcp", ":0") if err != nil { @@ -159,11 +153,11 @@ func portForwardToPod(podName, namespace string, targetPort int32) (int, func(), _ = listener.Close() kubeconfigArg := fmt.Sprintf("--kubeconfig=%s", kubeconfig) - //nolint:gosec // kubeconfig, namespace, podName, and ports are test-controlled values + //nolint:gosec // kubeconfig, podName, and ports are test-controlled values cmd := exec.Command("kubectl", kubeconfigArg, - "-n", namespace, "port-forward", + "-n", defaultNamespace, "port-forward", fmt.Sprintf("pod/%s", podName), - fmt.Sprintf("%d:%d", localPort, targetPort)) + fmt.Sprintf("%d:%d", localPort, containerPort)) if err := cmd.Start(); err != nil { return 0, nil, fmt.Errorf("failed to start port-forward to %s: %w", podName, err) } @@ -189,13 +183,65 @@ func portForwardToPod(podName, namespace string, targetPort int32) (int, func(), return 0, nil, fmt.Errorf("port-forward to %s never became ready on localhost:%d", podName, localPort) } +// readRedisSessionBackendIDs port-forwards to the Redis pod with label app=redisName, +// reads the session key keyPrefix+sessionID, and returns only the per-backend session +// ID entries (keys prefixed with "vmcp.backend.session."). +func readRedisSessionBackendIDs(redisName, keyPrefix, sessionID string) (map[string]string, error) { + // Find the Redis pod. + podList := &corev1.PodList{} + if err := k8sClient.List(ctx, podList, + client.InNamespace(defaultNamespace), + client.MatchingLabels{"app": redisName}, + ); err != nil { + return nil, fmt.Errorf("listing Redis pods: %w", err) + } + var redisPodName string + for _, pod := range podList.Items { + if pod.Status.Phase == corev1.PodRunning { + redisPodName = pod.Name + break + } + } + if redisPodName == "" { + return nil, fmt.Errorf("no running Redis pod found for app=%s", redisName) + } + + // Port-forward to Redis. + localPort, cleanup, err := portForwardToPod(redisPodName, 6379) + if err != nil { + return nil, fmt.Errorf("port-forward to Redis pod %s: %w", redisPodName, err) + } + defer cleanup() + + rdb := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("localhost:%d", localPort), + }) + defer rdb.Close() + + readCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + raw, err := rdb.Get(readCtx, keyPrefix+sessionID).Bytes() + if err != nil { + return nil, fmt.Errorf("Redis GET %s%s: %w", keyPrefix, sessionID, err) + } + + var all map[string]string + if err := json.Unmarshal(raw, &all); err != nil { + return nil, fmt.Errorf("unmarshal session metadata: %w", err) + } + + const backendSessionPrefix = "vmcp.backend.session." + result := make(map[string]string) + for k, v := range all { + if strings.HasPrefix(k, backendSessionPrefix) { + result[k] = v + } + } + return result, nil +} + var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", func() { - const ( - timeout = time.Minute * 5 - pollInterval = time.Second * 2 - defaultNamespace = "default" - proxyPort = int32(8080) - ) ginkgo.Context("When MCPServer has replicas=2 and backendReplicas=2", ginkgo.Ordered, func() { var ( @@ -209,7 +255,7 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu redisName = fmt.Sprintf("e2e-redis-be-%d", ts) ginkgo.By("Deploying Redis for session storage") - deployRedis(defaultNamespace, redisName, timeout, pollInterval) + deployRedis(redisName) replicas := int32(2) backendReplicas := int32(2) @@ -234,7 +280,7 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu })).To(gomega.Succeed()) ginkgo.By("Waiting for MCPServer to be Running") - waitForMCPServerRunning(mcpServerName, defaultNamespace, timeout, pollInterval) + testutil.WaitForMCPServerRunning(ctx, k8sClient, mcpServerName, defaultNamespace, e2eTimeout, e2ePollInterval) ginkgo.By("Waiting for 2 ready proxy runner pods") gomega.Eventually(func() (int, error) { @@ -243,19 +289,19 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu return 0, err } return len(pods), nil - }, timeout, pollInterval).Should(gomega.Equal(2)) + }, e2eTimeout, e2ePollInterval).Should(gomega.Equal(2)) }) ginkgo.AfterAll(func() { _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: mcpServerName, Namespace: defaultNamespace}, }) - cleanupRedis(defaultNamespace, redisName) + cleanupRedis(redisName) gomega.Eventually(func() bool { err := k8sClient.Get(ctx, types.NamespacedName{Name: mcpServerName, Namespace: defaultNamespace}, &mcpv1alpha1.MCPServer{}) return apierrors.IsNotFound(err) - }, timeout, pollInterval).Should(gomega.BeTrue()) + }, e2eTimeout, e2ePollInterval).Should(gomega.BeTrue()) }) ginkgo.It("Should route session from proxy A to proxy B via Redis-shared state", func() { @@ -268,7 +314,7 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu return 0, err } return len(pods), nil - }, timeout, pollInterval).Should(gomega.Equal(2)) + }, e2eTimeout, e2ePollInterval).Should(gomega.Equal(2)) podA := pods[0] podB := pods[1] @@ -276,12 +322,12 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu "The two proxy pods must be distinct") ginkgo.By(fmt.Sprintf("Setting up port-forward to proxy A (%s)", podA.Name)) - localPortA, cleanupA, err := portForwardToPod(podA.Name, defaultNamespace, proxyPort) + localPortA, cleanupA, err := portForwardToPod(podA.Name, proxyPort) gomega.Expect(err).NotTo(gomega.HaveOccurred()) defer cleanupA() ginkgo.By(fmt.Sprintf("Setting up port-forward to proxy B (%s)", podB.Name)) - localPortB, cleanupB, err := portForwardToPod(podB.Name, defaultNamespace, proxyPort) + localPortB, cleanupB, err := portForwardToPod(podB.Name, proxyPort) gomega.Expect(err).NotTo(gomega.HaveOccurred()) defer cleanupB() @@ -339,7 +385,7 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu redisName = fmt.Sprintf("e2e-redis-%d", ts) ginkgo.By("Deploying Redis for session storage") - deployRedis(defaultNamespace, redisName, timeout, pollInterval) + deployRedis(redisName) replicas := int32(2) redisAddr := fmt.Sprintf("%s.%s.svc.cluster.local:6379", redisName, defaultNamespace) @@ -362,7 +408,7 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu })).To(gomega.Succeed()) ginkgo.By("Waiting for MCPServer to be Running") - waitForMCPServerRunning(mcpServerName, defaultNamespace, timeout, pollInterval) + testutil.WaitForMCPServerRunning(ctx, k8sClient, mcpServerName, defaultNamespace, e2eTimeout, e2ePollInterval) ginkgo.By("Waiting for 2 ready pods") gomega.Eventually(func() (int, error) { @@ -371,19 +417,19 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu return 0, err } return len(pods), nil - }, timeout, pollInterval).Should(gomega.Equal(2)) + }, e2eTimeout, e2ePollInterval).Should(gomega.Equal(2)) }) ginkgo.AfterAll(func() { _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: mcpServerName, Namespace: defaultNamespace}, }) - cleanupRedis(defaultNamespace, redisName) + cleanupRedis(redisName) gomega.Eventually(func() bool { err := k8sClient.Get(ctx, types.NamespacedName{Name: mcpServerName, Namespace: defaultNamespace}, &mcpv1alpha1.MCPServer{}) return apierrors.IsNotFound(err) - }, timeout, pollInterval).Should(gomega.BeTrue()) + }, e2eTimeout, e2ePollInterval).Should(gomega.BeTrue()) }) ginkgo.It("Should have SessionStorageWarning=False since Redis is configured", func() { @@ -402,7 +448,7 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu } } return fmt.Errorf("SessionStorageWarning condition not found") - }, timeout, pollInterval).Should(gomega.Succeed()) + }, e2eTimeout, e2ePollInterval).Should(gomega.Succeed()) }) ginkgo.It("Should allow a session established on pod A to be used on pod B", func() { @@ -415,7 +461,7 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu return 0, err } return len(pods), nil - }, timeout, pollInterval).Should(gomega.Equal(2)) + }, e2eTimeout, e2ePollInterval).Should(gomega.Equal(2)) podA := pods[0] podB := pods[1] @@ -423,12 +469,12 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu "The two pods must be distinct") ginkgo.By(fmt.Sprintf("Setting up port-forward to pod A (%s)", podA.Name)) - localPortA, cleanupA, err := portForwardToPod(podA.Name, defaultNamespace, proxyPort) + localPortA, cleanupA, err := portForwardToPod(podA.Name, proxyPort) gomega.Expect(err).NotTo(gomega.HaveOccurred()) defer cleanupA() ginkgo.By(fmt.Sprintf("Setting up port-forward to pod B (%s)", podB.Name)) - localPortB, cleanupB, err := portForwardToPod(podB.Name, defaultNamespace, proxyPort) + localPortB, cleanupB, err := portForwardToPod(podB.Name, proxyPort) gomega.Expect(err).NotTo(gomega.HaveOccurred()) defer cleanupB() diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go new file mode 100644 index 0000000000..9346a3d9a5 --- /dev/null +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go @@ -0,0 +1,718 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package virtualmcp contains e2e tests for VirtualMCPServer against a real Kubernetes cluster +package virtualmcp + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + mcpclient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/client/transport" + "github.com/mark3labs/mcp-go/mcp" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/test/e2e/images" +) + +var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() { + const ( + timeout = time.Minute * 5 + pollInterval = time.Second * 2 + ) + + // ------------------------------------------------------------------------- + // Context 1: replicas=2 + Redis → SessionStorageWarning is False + // ------------------------------------------------------------------------- + + ginkgo.Context("When VirtualMCPServer has replicas=2 with Redis configured", ginkgo.Ordered, func() { + var ( + mcpGroupName string + backendName string + vmcpName string + redisName string + ) + + ginkgo.BeforeAll(func() { + ts := time.Now().UnixNano() + mcpGroupName = fmt.Sprintf("e2e-redis-group-%d", ts) + backendName = fmt.Sprintf("e2e-redis-backend-%d", ts) + vmcpName = fmt.Sprintf("e2e-redis-vmcp-%d", ts) + redisName = fmt.Sprintf("e2e-redis-%d", ts) + + ginkgo.By("Deploying Redis") + deployRedis(redisName) + + ginkgo.By("Creating MCPGroup") + CreateMCPGroupAndWait(ctx, k8sClient, mcpGroupName, defaultNamespace, + "E2E Redis session group", timeout, pollInterval) + + ginkgo.By("Creating backend MCPServer") + gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + Spec: mcpv1alpha1.MCPServerSpec{ + GroupRef: mcpGroupName, + Image: images.YardstickServerImage, + Transport: "streamable-http", + ProxyPort: 8080, + MCPPort: 8080, + }, + })).To(gomega.Succeed()) + + ginkgo.By("Waiting for backend MCPServer to be ready") + gomega.Eventually(func() error { + server := &mcpv1alpha1.MCPServer{} + if err := k8sClient.Get(ctx, types.NamespacedName{ + Name: backendName, + Namespace: defaultNamespace, + }, server); err != nil { + return fmt.Errorf("failed to get MCPServer: %w", err) + } + if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + return fmt.Errorf("MCPServer not ready yet, phase: %s", server.Status.Phase) + } + return nil + }, timeout, pollInterval).Should(gomega.Succeed(), "backend MCPServer should be ready") + + replicas := int32(2) + redisAddr := fmt.Sprintf("%s.%s.svc.cluster.local:6379", redisName, defaultNamespace) + + ginkgo.By("Creating VirtualMCPServer with replicas=2 and Redis") + gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Config: vmcpconfig.Config{Group: mcpGroupName}, + IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, + Replicas: &replicas, + SessionAffinity: "None", + SessionStorage: &mcpv1alpha1.SessionStorageConfig{ + Provider: mcpv1alpha1.SessionStorageProviderRedis, + Address: redisAddr, + KeyPrefix: "thv:vmcp:e2e:", + }, + }, + })).To(gomega.Succeed()) + + ginkgo.By("Waiting for 2 ready pods") + gomega.Eventually(func() (int, error) { + return countReadyPods(vmcpName) + }, timeout, pollInterval).Should(gomega.Equal(2)) + + ginkgo.By("Waiting for VirtualMCPServer to report Ready") + WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpName, defaultNamespace, timeout, pollInterval) + }) + + ginkgo.AfterAll(func() { + _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, + }) + cleanupRedis(redisName) + gomega.Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1alpha1.VirtualMCPServer{}) + return apierrors.IsNotFound(err) + }, timeout, pollInterval).Should(gomega.BeTrue()) + }) + + ginkgo.It("Should set SessionStorageWarning=False when Redis is configured", func() { + WaitForCondition(ctx, k8sClient, vmcpName, defaultNamespace, + mcpv1alpha1.ConditionSessionStorageWarning, "False", + timeout, pollInterval) + }) + + ginkgo.It("Should report Ready=True when Redis is configured", func() { + WaitForCondition(ctx, k8sClient, vmcpName, defaultNamespace, + mcpv1alpha1.ConditionTypeVirtualMCPServerReady, "True", + timeout, pollInterval) + }) + }) + + // ------------------------------------------------------------------------- + // Context 2: cross-pod session reconstruction with Redis + // ------------------------------------------------------------------------- + + ginkgo.Context("When cross-pod session reconstruction with Redis", ginkgo.Ordered, func() { + var ( + mcpGroupName string + backendName string + vmcpName string + redisName string + ) + + ginkgo.BeforeAll(func() { + ts := time.Now().UnixNano() + mcpGroupName = fmt.Sprintf("e2e-redis-xpod-group-%d", ts) + backendName = fmt.Sprintf("e2e-redis-xpod-backend-%d", ts) + vmcpName = fmt.Sprintf("e2e-redis-xpod-vmcp-%d", ts) + redisName = fmt.Sprintf("e2e-redis-xpod-%d", ts) + + ginkgo.By("Deploying Redis") + deployRedis(redisName) + + ginkgo.By("Creating MCPGroup") + CreateMCPGroupAndWait(ctx, k8sClient, mcpGroupName, defaultNamespace, + "E2E Redis cross-pod session group", timeout, pollInterval) + + ginkgo.By("Creating backend MCPServer") + gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + Spec: mcpv1alpha1.MCPServerSpec{ + GroupRef: mcpGroupName, + Image: images.YardstickServerImage, + Transport: "streamable-http", + ProxyPort: 8080, + MCPPort: 8080, + }, + })).To(gomega.Succeed()) + + ginkgo.By("Waiting for backend MCPServer to be ready") + gomega.Eventually(func() error { + server := &mcpv1alpha1.MCPServer{} + if err := k8sClient.Get(ctx, types.NamespacedName{ + Name: backendName, + Namespace: defaultNamespace, + }, server); err != nil { + return fmt.Errorf("failed to get MCPServer: %w", err) + } + if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + return fmt.Errorf("MCPServer not ready yet, phase: %s", server.Status.Phase) + } + return nil + }, timeout, pollInterval).Should(gomega.Succeed(), "backend MCPServer should be ready") + + replicas := int32(2) + redisAddr := fmt.Sprintf("%s.%s.svc.cluster.local:6379", redisName, defaultNamespace) + + ginkgo.By("Creating VirtualMCPServer with replicas=2 and Redis") + gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Config: vmcpconfig.Config{Group: mcpGroupName}, + IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, + Replicas: &replicas, + SessionAffinity: "None", + SessionStorage: &mcpv1alpha1.SessionStorageConfig{ + Provider: mcpv1alpha1.SessionStorageProviderRedis, + Address: redisAddr, + KeyPrefix: "thv:vmcp:e2e:", + }, + }, + })).To(gomega.Succeed()) + + ginkgo.By("Waiting for 2 ready pods") + gomega.Eventually(func() (int, error) { + return countReadyPods(vmcpName) + }, timeout, pollInterval).Should(gomega.Equal(2)) + + ginkgo.By("Waiting for VirtualMCPServer to report Ready") + WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpName, defaultNamespace, timeout, pollInterval) + }) + + ginkgo.AfterAll(func() { + _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, + }) + cleanupRedis(redisName) + gomega.Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1alpha1.VirtualMCPServer{}) + return apierrors.IsNotFound(err) + }, timeout, pollInterval).Should(gomega.BeTrue()) + }) + + ginkgo.It("Should allow a session established on pod A to be reconstructed on pod B", func() { + ginkgo.By("Getting the two ready pods") + var pods []corev1.Pod + gomega.Eventually(func() (int, error) { + podList, err := GetVirtualMCPServerPods(ctx, k8sClient, vmcpName, defaultNamespace) + if err != nil { + return 0, err + } + var ready []corev1.Pod + for _, pod := range podList.Items { + if pod.Status.Phase != corev1.PodRunning { + continue + } + for _, c := range pod.Status.Conditions { + if c.Type == corev1.PodReady && c.Status == corev1.ConditionTrue { + ready = append(ready, pod) + } + } + } + pods = ready + return len(ready), nil + }, timeout, pollInterval).Should(gomega.Equal(2)) + + podA := pods[0] + podB := pods[1] + gomega.Expect(podA.Name).NotTo(gomega.Equal(podB.Name), "The two pods must be distinct") + + ginkgo.By(fmt.Sprintf("Port-forwarding to pod A (%s)", podA.Name)) + localPortA, cleanupA, err := portForwardToPod(podA.Name, vmcpPort) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + defer cleanupA() + + ginkgo.By(fmt.Sprintf("Port-forwarding to pod B (%s)", podB.Name)) + localPortB, cleanupB, err := portForwardToPod(podB.Name, vmcpPort) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + defer cleanupB() + + ginkgo.By("Initializing session on pod A") + clientA, err := CreateInitializedMCPClient(int32(localPortA), "e2e-redis-test", 30*time.Second) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + defer clientA.Close() + + sessionID := clientA.Client.GetSessionId() + gomega.Expect(sessionID).NotTo(gomega.BeEmpty(), "session ID must be assigned after Initialize") + + ginkgo.By("Listing tools on pod A") + toolsA, err := clientA.Client.ListTools(clientA.Ctx, mcp.ListToolsRequest{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(toolsA.Tools).NotTo(gomega.BeEmpty(), "pod A must return tools") + + ginkgo.By("Verifying pod A stored backend session IDs in Redis") + backendIDsBeforeRestore, err := readRedisSessionBackendIDs(redisName, "thv:vmcp:e2e:", sessionID) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(backendIDsBeforeRestore).NotTo(gomega.BeEmpty(), + "pod A must have written per-backend session IDs to Redis so pod B can use them as hints") + + ginkgo.By(fmt.Sprintf("Connecting to pod B (%s) with the same session ID", podB.Name)) + serverURLB := fmt.Sprintf("http://localhost:%d/mcp", localPortB) + clientB, err := mcpclient.NewStreamableHttpClient(serverURLB, transport.WithSession(sessionID)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + defer func() { _ = clientB.Close() }() + + startCtx, startCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer startCancel() + gomega.Expect(clientB.Start(startCtx)).To(gomega.Succeed()) + + ginkgo.By("Listing tools on pod B using the session from pod A") + listCtx, listCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer listCancel() + toolsB, err := clientB.ListTools(listCtx, mcp.ListToolsRequest{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(toolsB.Tools).NotTo(gomega.BeEmpty(), "pod B must return tools via Redis-reconstructed session") + + ginkgo.By("Verifying backend session IDs in Redis are the same hints pod B received") + backendIDsAfterRestore, err := readRedisSessionBackendIDs(redisName, "thv:vmcp:e2e:", sessionID) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(backendIDsAfterRestore).To(gomega.Equal(backendIDsBeforeRestore), + "RestoreSession must not overwrite the backend session IDs stored by pod A — "+ + "pod B used them as hints and the IDs must be stable") + + ginkgo.By("Verifying both pods return the same tool count") + gomega.Expect(toolsB.Tools).To(gomega.HaveLen(len(toolsA.Tools)), + "pod B must see same session state as pod A") + }) + }) + + // ------------------------------------------------------------------------- + // Context 3: VirtualMCPServer pod restart — session survives in Redis + // ------------------------------------------------------------------------- + + ginkgo.Context("When a VirtualMCPServer pod restarts with Redis configured", ginkgo.Ordered, func() { + var ( + mcpGroupName string + backendName string + vmcpName string + redisName string + ) + + ginkgo.BeforeAll(func() { + ts := time.Now().UnixNano() + mcpGroupName = fmt.Sprintf("e2e-redis-restart-group-%d", ts) + backendName = fmt.Sprintf("e2e-redis-restart-backend-%d", ts) + vmcpName = fmt.Sprintf("e2e-redis-restart-vmcp-%d", ts) + redisName = fmt.Sprintf("e2e-redis-restart-%d", ts) + + ginkgo.By("Deploying Redis") + deployRedis(redisName) + + ginkgo.By("Creating MCPGroup") + CreateMCPGroupAndWait(ctx, k8sClient, mcpGroupName, defaultNamespace, + "E2E Redis pod restart group", timeout, pollInterval) + + ginkgo.By("Creating backend MCPServer") + gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + Spec: mcpv1alpha1.MCPServerSpec{ + GroupRef: mcpGroupName, + Image: images.YardstickServerImage, + Transport: "streamable-http", + ProxyPort: 8080, + MCPPort: 8080, + }, + })).To(gomega.Succeed()) + + ginkgo.By("Waiting for backend MCPServer to be ready") + gomega.Eventually(func() error { + server := &mcpv1alpha1.MCPServer{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: backendName, Namespace: defaultNamespace}, server); err != nil { + return err + } + if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + return fmt.Errorf("MCPServer not ready, phase: %s", server.Status.Phase) + } + return nil + }, timeout, pollInterval).Should(gomega.Succeed()) + + replicas := int32(1) + redisAddr := fmt.Sprintf("%s.%s.svc.cluster.local:6379", redisName, defaultNamespace) + + ginkgo.By("Creating VirtualMCPServer with replicas=1, Redis, and NodePort") + gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Config: vmcpconfig.Config{Group: mcpGroupName}, + IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, + Replicas: &replicas, + ServiceType: "NodePort", + SessionAffinity: "None", + SessionStorage: &mcpv1alpha1.SessionStorageConfig{ + Provider: mcpv1alpha1.SessionStorageProviderRedis, + Address: redisAddr, + KeyPrefix: "thv:vmcp:e2e:", + }, + }, + })).To(gomega.Succeed()) + + ginkgo.By("Waiting for VirtualMCPServer to be ready") + WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpName, defaultNamespace, timeout, pollInterval) + }) + + ginkgo.AfterAll(func() { + _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, + }) + cleanupRedis(redisName) + gomega.Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1alpha1.VirtualMCPServer{}) + return apierrors.IsNotFound(err) + }, timeout, pollInterval).Should(gomega.BeTrue()) + }) + + ginkgo.It("Should recover the session on the new pod after the original pod is deleted", func() { + ginkgo.By("Getting the NodePort for the VirtualMCPServer") + vmcpNodePort := GetVMCPNodePort(ctx, k8sClient, vmcpName, defaultNamespace, timeout, pollInterval) + + ginkgo.By("Initializing an MCP session") + mcpClientA, err := CreateInitializedMCPClient(vmcpNodePort, "e2e-redis-restart-test", 30*time.Second) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + sessionID := mcpClientA.Client.GetSessionId() + gomega.Expect(sessionID).NotTo(gomega.BeEmpty(), "session ID must be assigned after Initialize") + + ginkgo.By("Verifying tools are available before pod restart") + toolsBefore, err := mcpClientA.Client.ListTools(mcpClientA.Ctx, mcp.ListToolsRequest{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(toolsBefore.Tools).NotTo(gomega.BeEmpty()) + + // Cancel context to stop in-flight requests without sending DELETE. + // This simulates the pod being killed, not a clean client disconnect. + // We intentionally skip Client.Close() here because Close() sends a + // DELETE /mcp request that would terminate the session in Redis before + // the pod is restarted — defeating the purpose of this test. + // The transport's background goroutine (started by Start()) selects on + // ctx.Done(), so Cancel() is sufficient to stop it without leaking. + mcpClientA.Cancel() + + ginkgo.By("Getting the running pod name before restart") + var pods []corev1.Pod + gomega.Eventually(func() (int, error) { + podList, err := GetVirtualMCPServerPods(ctx, k8sClient, vmcpName, defaultNamespace) + if err != nil { + return 0, err + } + var ready []corev1.Pod + for _, pod := range podList.Items { + if pod.Status.Phase != corev1.PodRunning { + continue + } + for _, c := range pod.Status.Conditions { + if c.Type == corev1.PodReady && c.Status == corev1.ConditionTrue { + ready = append(ready, pod) + } + } + } + pods = ready + return len(ready), nil + }, timeout, pollInterval).Should(gomega.Equal(1)) + oldPodName := pods[0].Name + + ginkgo.By(fmt.Sprintf("Deleting pod %s (Deployment will recreate it)", oldPodName)) + gomega.Expect(k8sClient.Delete(ctx, &pods[0])).To(gomega.Succeed()) + + ginkgo.By("Waiting for a new pod to be Running+Ready") + gomega.Eventually(func() (string, error) { + podList, err := GetVirtualMCPServerPods(ctx, k8sClient, vmcpName, defaultNamespace) + if err != nil { + return "", err + } + for _, pod := range podList.Items { + if pod.Name == oldPodName || pod.Status.Phase != corev1.PodRunning { + continue + } + for _, c := range pod.Status.Conditions { + if c.Type == corev1.PodReady && c.Status == corev1.ConditionTrue { + return pod.Name, nil + } + } + } + return "", fmt.Errorf("waiting for new pod") + }, timeout, pollInterval).ShouldNot(gomega.BeEmpty()) + + ginkgo.By("Waiting for the NodePort to be serving HTTP again") + gomega.Eventually(func() error { + return checkHTTPHealthReady(vmcpNodePort) + }, timeout, pollInterval).Should(gomega.Succeed()) + + ginkgo.By("Creating a new client with the SAME session ID") + serverURL := fmt.Sprintf("http://localhost:%d/mcp", vmcpNodePort) + newClient, err := mcpclient.NewStreamableHttpClient(serverURL, transport.WithSession(sessionID)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + defer func() { _ = newClient.Close() }() + + startCtx, startCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer startCancel() + gomega.Expect(newClient.Start(startCtx)).To(gomega.Succeed()) + + // Send 5 requests to give confidence the fix holds: without Redis-backed + // session reconstruction, each request would fail because the new pod's + // in-memory cache is cold. + ginkgo.By("Sending 5 requests to verify the session is recovered from Redis on the new pod") + for i := range 5 { + listCtx, listCancel := context.WithTimeout(context.Background(), 30*time.Second) + toolsAfter, listErr := newClient.ListTools(listCtx, mcp.ListToolsRequest{}) + listCancel() + gomega.Expect(listErr).NotTo(gomega.HaveOccurred(), + "Request %d/5 should succeed after pod restart — session must be recovered from Redis", i+1) + gomega.Expect(toolsAfter.Tools).To(gomega.HaveLen(len(toolsBefore.Tools)), + "Request %d/5 should return the same tools as before restart", i+1) + } + }) + }) + + // ------------------------------------------------------------------------- + // Context 4: Terminated session rejected by pod B via lazy eviction (#4731) + // ------------------------------------------------------------------------- + + ginkgo.Context("When a session is terminated on pod A, pod B rejects it via lazy eviction", ginkgo.Ordered, func() { + var ( + mcpGroupName string + backendName string + vmcpName string + redisName string + ) + + ginkgo.BeforeAll(func() { + ts := time.Now().UnixNano() + mcpGroupName = fmt.Sprintf("e2e-redis-term-group-%d", ts) + backendName = fmt.Sprintf("e2e-redis-term-backend-%d", ts) + vmcpName = fmt.Sprintf("e2e-redis-term-vmcp-%d", ts) + redisName = fmt.Sprintf("e2e-redis-term-%d", ts) + + ginkgo.By("Deploying Redis") + deployRedis(redisName) + + ginkgo.By("Creating MCPGroup") + CreateMCPGroupAndWait(ctx, k8sClient, mcpGroupName, defaultNamespace, + "E2E Redis terminated session group", timeout, pollInterval) + + ginkgo.By("Creating backend MCPServer") + gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + Spec: mcpv1alpha1.MCPServerSpec{ + GroupRef: mcpGroupName, + Image: images.YardstickServerImage, + Transport: "streamable-http", + ProxyPort: 8080, + MCPPort: 8080, + }, + })).To(gomega.Succeed()) + + ginkgo.By("Waiting for backend MCPServer to be ready") + gomega.Eventually(func() error { + server := &mcpv1alpha1.MCPServer{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: backendName, Namespace: defaultNamespace}, server); err != nil { + return err + } + if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + return fmt.Errorf("MCPServer not ready, phase: %s", server.Status.Phase) + } + return nil + }, timeout, pollInterval).Should(gomega.Succeed()) + + replicas := int32(2) + redisAddr := fmt.Sprintf("%s.%s.svc.cluster.local:6379", redisName, defaultNamespace) + + ginkgo.By("Creating VirtualMCPServer with replicas=2, Redis, and SessionAffinity=None") + gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Config: vmcpconfig.Config{Group: mcpGroupName}, + IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, + Replicas: &replicas, + SessionAffinity: "None", + SessionStorage: &mcpv1alpha1.SessionStorageConfig{ + Provider: mcpv1alpha1.SessionStorageProviderRedis, + Address: redisAddr, + KeyPrefix: "thv:vmcp:e2e:", + }, + }, + })).To(gomega.Succeed()) + + ginkgo.By("Waiting for 2 ready pods") + gomega.Eventually(func() (int, error) { + return countReadyPods(vmcpName) + }, timeout, pollInterval).Should(gomega.Equal(2)) + + ginkgo.By("Waiting for VirtualMCPServer to report Ready") + WaitForVirtualMCPServerReady(ctx, k8sClient, vmcpName, defaultNamespace, timeout, pollInterval) + }) + + ginkgo.AfterAll(func() { + _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, + }) + cleanupRedis(redisName) + gomega.Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1alpha1.VirtualMCPServer{}) + return apierrors.IsNotFound(err) + }, timeout, pollInterval).Should(gomega.BeTrue()) + }) + + ginkgo.It("Should reject the session on pod B after it is terminated on pod A", func() { + ginkgo.By("Getting the two ready pods") + var pods []corev1.Pod + gomega.Eventually(func() (int, error) { + podList, err := GetVirtualMCPServerPods(ctx, k8sClient, vmcpName, defaultNamespace) + if err != nil { + return 0, err + } + var ready []corev1.Pod + for _, pod := range podList.Items { + if pod.Status.Phase != corev1.PodRunning { + continue + } + for _, c := range pod.Status.Conditions { + if c.Type == corev1.PodReady && c.Status == corev1.ConditionTrue { + ready = append(ready, pod) + } + } + } + pods = ready + return len(ready), nil + }, timeout, pollInterval).Should(gomega.Equal(2)) + + podA := pods[0] + podB := pods[1] + gomega.Expect(podA.Name).NotTo(gomega.Equal(podB.Name), "The two pods must be distinct") + + ginkgo.By(fmt.Sprintf("Port-forwarding to pod A (%s)", podA.Name)) + localPortA, cleanupA, err := portForwardToPod(podA.Name, vmcpPort) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + defer cleanupA() + + ginkgo.By(fmt.Sprintf("Port-forwarding to pod B (%s)", podB.Name)) + localPortB, cleanupB, err := portForwardToPod(podB.Name, vmcpPort) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + defer cleanupB() + + ginkgo.By("Initializing a session on pod A") + clientA, err := CreateInitializedMCPClient(int32(localPortA), "e2e-redis-term-test", 30*time.Second) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + sessionID := clientA.Client.GetSessionId() + gomega.Expect(sessionID).NotTo(gomega.BeEmpty(), "session ID must be assigned after Initialize") + + ginkgo.By("Verifying the session is usable on pod A") + toolsA, err := clientA.Client.ListTools(clientA.Ctx, mcp.ListToolsRequest{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(toolsA.Tools).NotTo(gomega.BeEmpty()) + + ginkgo.By(fmt.Sprintf("Reconstructing the session on pod B (%s) via Redis", podB.Name)) + serverURLB := fmt.Sprintf("http://localhost:%d/mcp", localPortB) + clientB, err := mcpclient.NewStreamableHttpClient(serverURLB, transport.WithSession(sessionID)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + defer func() { _ = clientB.Close() }() + + startCtx, startCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer startCancel() + gomega.Expect(clientB.Start(startCtx)).To(gomega.Succeed()) + + listCtxB, listCancelB := context.WithTimeout(context.Background(), 30*time.Second) + toolsB, err := clientB.ListTools(listCtxB, mcp.ListToolsRequest{}) + listCancelB() + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(toolsB.Tools).NotTo(gomega.BeEmpty(), + "pod B should serve the session before termination") + + // Terminate the session on pod A by sending DELETE /mcp directly. + // We do this via raw HTTP rather than clientA.Close() to avoid the + // context-cancellation ordering in InitializedMCPClient.Close(). + ginkgo.By("Terminating the session on pod A via DELETE /mcp") + deleteURL := fmt.Sprintf("http://localhost:%d/mcp", localPortA) + deleteCtx, deleteCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer deleteCancel() + req, err := http.NewRequestWithContext(deleteCtx, http.MethodDelete, deleteURL, nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + req.Header.Set("Mcp-Session-Id", sessionID) + resp, err := http.DefaultClient.Do(req) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + _ = resp.Body.Close() + gomega.Expect(resp.StatusCode).To(gomega.BeElementOf(http.StatusOK, http.StatusNoContent), + "DELETE /mcp should return 200 or 204") + clientA.Cancel() + + // Pod B's in-memory cache still holds the session, but the ValidatingCache's + // checkSession callback will find the key absent in Redis (deleted by the + // Terminate call above) and return ErrExpired, triggering lazy eviction. + // The next request from pod B should therefore fail with a session-not-found error. + ginkgo.By("Verifying pod B rejects subsequent requests for the terminated session") + gomega.Eventually(func() error { + listCtx, listCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer listCancel() + _, listErr := clientB.ListTools(listCtx, mcp.ListToolsRequest{}) + if listErr == nil { + return fmt.Errorf("expected pod B to reject the terminated session, but request succeeded") + } + if !errors.Is(listErr, transport.ErrSessionTerminated) { + return fmt.Errorf("expected ErrSessionTerminated (404), got: %w", listErr) + } + return nil + }, timeout, pollInterval).Should(gomega.Succeed(), + "pod B should reject the session after it is terminated on pod A") + }) + }) +}) diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go index 87d9de65a6..9cc8a817b0 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go @@ -22,8 +22,8 @@ import ( ) // countReadyPods returns the number of Running+Ready pods for a VirtualMCPServer. -func countReadyPods(vmcpName, namespace string) (int, error) { - podList, err := GetVirtualMCPServerPods(ctx, k8sClient, vmcpName, namespace) +func countReadyPods(vmcpName string) (int, error) { + podList, err := GetVirtualMCPServerPods(ctx, k8sClient, vmcpName, defaultNamespace) if err != nil { return 0, err } @@ -43,9 +43,8 @@ func countReadyPods(vmcpName, namespace string) (int, error) { var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { const ( - timeout = time.Minute * 5 - pollInterval = time.Second * 2 - defaultNamespace = "default" + timeout = time.Minute * 5 + pollInterval = time.Second * 2 ) // ------------------------------------------------------------------------- @@ -124,7 +123,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { ginkgo.It("Should eventually run 2 ready pods", func() { ginkgo.By("Waiting for 2 pods to become Running+Ready") gomega.Eventually(func() (int, error) { - return countReadyPods(vmcpName, defaultNamespace) + return countReadyPods(vmcpName) }, timeout, pollInterval).Should(gomega.Equal(2)) }) @@ -217,7 +216,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { ginkgo.It("Should initially have 1 running pod and no SessionStorageWarning", func() { gomega.Eventually(func() (int, error) { - return countReadyPods(vmcpName, defaultNamespace) + return countReadyPods(vmcpName) }, timeout, pollInterval).Should(gomega.Equal(1)) WaitForCondition(ctx, k8sClient, vmcpName, defaultNamespace, @@ -251,7 +250,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { ginkgo.By("Verifying 2 pods become ready") gomega.Eventually(func() (int, error) { - return countReadyPods(vmcpName, defaultNamespace) + return countReadyPods(vmcpName) }, timeout, pollInterval).Should(gomega.Equal(2)) ginkgo.By("Verifying SessionStorageWarning is now set") From 5e1e1d55859be01191a58b54e1d29ad9e3ccc381 Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota Date: Wed, 15 Apr 2026 09:36:37 +0200 Subject: [PATCH 037/117] =?UTF-8?q?Remove=20Upsert=20from=20DataStorage=20?= =?UTF-8?q?interface=20=E2=80=94=20closes=20#4726=20(#4797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upsert had zero production callers after #4669 converted all call sites to the safer Create/Update methods. Keeping it in the interface was a footgun that invited the unconditional-write pattern back. - Remove Upsert from DataStorage interface and both implementations - Update contract tests to use Create for round-trip and nil-metadata cases - Drop the two Upsert-specific contract tests (overwrite and empty-ID) that tested behaviour no longer in the interface - Replace test fixture setup calls with Create (new sessions) or Update (sessions already written by CreateSession/Generate) Co-authored-by: taskbot --- pkg/transport/session/session_data_storage.go | 25 +++---- .../session/session_data_storage_local.go | 14 ---- .../session/session_data_storage_redis.go | 24 ++----- .../session/session_data_storage_test.go | 68 ++++++++---------- .../sessionmanager/session_manager_test.go | 71 ++++++++++--------- 5 files changed, 83 insertions(+), 119 deletions(-) diff --git a/pkg/transport/session/session_data_storage.go b/pkg/transport/session/session_data_storage.go index 9093fcdf5d..eae3a7a739 100644 --- a/pkg/transport/session/session_data_storage.go +++ b/pkg/transport/session/session_data_storage.go @@ -23,11 +23,12 @@ import ( // # Contract // // - Create atomically creates metadata for id only if it does not already exist. -// Use this in preference to Load+Upsert to avoid TOCTOU races. -// - Upsert creates or overwrites the metadata for id, refreshing the TTL. +// Returns (true, nil) if created, (false, nil) if the key already existed. // - Update overwrites metadata only if the key already exists (SET XX semantics). // Returns (true, nil) if updated, (false, nil) if the session was not found. -// Use this instead of Load+Upsert to avoid TOCTOU resurrection races. +// Use this instead of Load→mutate→unconditional-write to avoid TOCTOU +// resurrection races (a concurrent Delete between Load and write would be +// silently undone by an unconditional write). // - Load retrieves metadata and refreshes the TTL (sliding-window expiry). // Returns ErrSessionNotFound if the session does not exist. // - Delete removes the session. It is not an error if the session is absent. @@ -39,27 +40,23 @@ import ( // - LocalSessionDataStorage (in-memory, single-process) // - RedisSessionDataStorage (Redis/Valkey, multi-process) type DataStorage interface { - // Upsert creates or updates session metadata with a sliding TTL. - Upsert(ctx context.Context, id string, metadata map[string]string) error + // Create atomically creates session metadata only if the session ID + // does not already exist. Returns (true, nil) if the entry was created, + // (false, nil) if it already existed, or (false, err) on storage errors. + Create(ctx context.Context, id string, metadata map[string]string) (bool, error) // Update overwrites session metadata only if the session ID already exists // (conditional write, equivalent to Redis SET XX). Returns (true, nil) if // the entry was updated, (false, nil) if it was not found, or (false, err) - // on storage errors. Use this instead of Load+Upsert to prevent resurrections - // after a concurrent Delete. + // on storage errors. Use this (SET XX) for writes that must not recreate a + // key that was concurrently deleted — an unconditional write would silently + // resurrect a session the caller intentionally terminated. Update(ctx context.Context, id string, metadata map[string]string) (bool, error) // Load retrieves session metadata and refreshes its TTL. // Returns ErrSessionNotFound if the session does not exist. Load(ctx context.Context, id string) (map[string]string, error) - // Create atomically creates session metadata only if the session ID - // does not already exist. Returns (true, nil) if the entry was created, - // (false, nil) if it already existed, or (false, err) on storage errors. - // Use this in preference to Load+Upsert to avoid TOCTOU races in - // multi-pod deployments. - Create(ctx context.Context, id string, metadata map[string]string) (bool, error) - // Delete removes session metadata. Not an error if absent. Delete(ctx context.Context, id string) error diff --git a/pkg/transport/session/session_data_storage_local.go b/pkg/transport/session/session_data_storage_local.go index bc125a0480..709d40de48 100644 --- a/pkg/transport/session/session_data_storage_local.go +++ b/pkg/transport/session/session_data_storage_local.go @@ -42,20 +42,6 @@ type LocalSessionDataStorage struct { stopOnce sync.Once } -// Upsert creates or updates session metadata. -func (s *LocalSessionDataStorage) Upsert(_ context.Context, id string, metadata map[string]string) error { - if id == "" { - return fmt.Errorf("cannot write session data with empty ID") - } - if metadata == nil { - metadata = make(map[string]string) - } - s.mu.Lock() - s.sessions[id] = newLocalDataEntry(maps.Clone(metadata)) - s.mu.Unlock() - return nil -} - // Load retrieves session metadata and refreshes its last-access timestamp. // Returns ErrSessionNotFound if the session does not exist. func (s *LocalSessionDataStorage) Load(_ context.Context, id string) (map[string]string, error) { diff --git a/pkg/transport/session/session_data_storage_redis.go b/pkg/transport/session/session_data_storage_redis.go index 02d04c2027..b5bdea4a01 100644 --- a/pkg/transport/session/session_data_storage_redis.go +++ b/pkg/transport/session/session_data_storage_redis.go @@ -16,7 +16,7 @@ import ( // RedisSessionDataStorage implements DataStorage backed by Redis/Valkey. // // Metadata is serialized as a JSON object and stored with a sliding-window TTL: -// each Upsert/Load call refreshes the key's expiry so that active sessions +// each Create/Update/Load call refreshes the key's expiry so that active sessions // never expire while they are in use. // // Because only plain map[string]string is stored, there are no type assertions @@ -29,7 +29,7 @@ type RedisSessionDataStorage struct { // NewRedisSessionDataStorage constructs a RedisSessionDataStorage. // cfg provides connection parameters; ttl is the sliding-window expiry applied -// on every Upsert and Load. The caller must call Close when done. +// on every Create/Update and Load. The caller must call Close when done. func NewRedisSessionDataStorage(ctx context.Context, cfg RedisConfig, ttl time.Duration) (*RedisSessionDataStorage, error) { if err := validateRedisConfig(&cfg); err != nil { return nil, fmt.Errorf("invalid redis configuration: %w", err) @@ -52,21 +52,6 @@ func (s *RedisSessionDataStorage) key(id string) string { return s.keyPrefix + id } -// Upsert serializes metadata as JSON and writes it to Redis with a sliding TTL. -func (s *RedisSessionDataStorage) Upsert(ctx context.Context, id string, metadata map[string]string) error { - if id == "" { - return fmt.Errorf("cannot write session data with empty ID") - } - if metadata == nil { - metadata = make(map[string]string) - } - data, err := json.Marshal(metadata) - if err != nil { - return fmt.Errorf("failed to serialize session metadata: %w", err) - } - return s.client.Set(ctx, s.key(id), data, s.ttl).Err() -} - // Load retrieves metadata from Redis and refreshes the key's TTL via GETEX. // Returns ErrSessionNotFound if the key does not exist. func (s *RedisSessionDataStorage) Load(ctx context.Context, id string) (map[string]string, error) { @@ -120,8 +105,9 @@ func (s *RedisSessionDataStorage) Update(ctx context.Context, id string, metadat } // Create atomically creates session metadata only if the key does not -// already exist. Uses Redis SET NX (set-if-not-exists) to eliminate the -// TOCTOU race between Load and Upsert in multi-pod deployments. +// already exist. Uses Redis SET NX (set-if-not-exists) to avoid the +// read-then-write TOCTOU race that a Load-then-unconditional-write pattern +// would introduce in multi-pod deployments. func (s *RedisSessionDataStorage) Create(ctx context.Context, id string, metadata map[string]string) (bool, error) { if id == "" { return false, fmt.Errorf("cannot write session data with empty ID") diff --git a/pkg/transport/session/session_data_storage_test.go b/pkg/transport/session/session_data_storage_test.go index 63b41f9107..bdc35fc64d 100644 --- a/pkg/transport/session/session_data_storage_test.go +++ b/pkg/transport/session/session_data_storage_test.go @@ -25,13 +25,15 @@ import ( func runDataStorageTests(t *testing.T, newStorage func(t *testing.T) DataStorage) { t.Helper() - t.Run("Upsert and Load round-trip", func(t *testing.T) { + t.Run("Create and Load round-trip", func(t *testing.T) { t.Parallel() s := newStorage(t) ctx := context.Background() meta := map[string]string{"key": "value", "env": "test"} - require.NoError(t, s.Upsert(ctx, "sess-1", meta)) + stored, err := s.Create(ctx, "sess-1", meta) + require.NoError(t, err) + require.True(t, stored) loaded, err := s.Load(ctx, "sess-1") require.NoError(t, err) @@ -39,39 +41,19 @@ func runDataStorageTests(t *testing.T, newStorage func(t *testing.T) DataStorage assert.Equal(t, "test", loaded["env"]) }) - t.Run("Upsert overwrites existing entry", func(t *testing.T) { + t.Run("Create nil metadata is treated as empty map", func(t *testing.T) { t.Parallel() s := newStorage(t) ctx := context.Background() - require.NoError(t, s.Upsert(ctx, "sess-upsert", map[string]string{"v": "1"})) - require.NoError(t, s.Upsert(ctx, "sess-upsert", map[string]string{"v": "2"})) - - loaded, err := s.Load(ctx, "sess-upsert") + stored, err := s.Create(ctx, "sess-nil-meta", nil) require.NoError(t, err) - assert.Equal(t, "2", loaded["v"]) - }) - - t.Run("Upsert nil metadata is treated as empty map", func(t *testing.T) { - t.Parallel() - s := newStorage(t) - ctx := context.Background() - - require.NoError(t, s.Upsert(ctx, "sess-nil-meta", nil)) + require.True(t, stored) loaded, err := s.Load(ctx, "sess-nil-meta") require.NoError(t, err) assert.NotNil(t, loaded) }) - t.Run("Upsert with empty ID returns error", func(t *testing.T) { - t.Parallel() - s := newStorage(t) - ctx := context.Background() - - err := s.Upsert(ctx, "", map[string]string{}) - assert.Error(t, err) - }) - t.Run("Load miss returns ErrSessionNotFound", func(t *testing.T) { t.Parallel() s := newStorage(t) @@ -109,7 +91,8 @@ func runDataStorageTests(t *testing.T, newStorage func(t *testing.T) DataStorage s := newStorage(t) ctx := context.Background() - require.NoError(t, s.Upsert(ctx, "sess-existing", map[string]string{"x": "original"})) + _, err := s.Create(ctx, "sess-existing", map[string]string{"x": "original"}) + require.NoError(t, err) stored, err := s.Create(ctx, "sess-existing", map[string]string{"x": "overwrite"}) require.NoError(t, err) @@ -167,10 +150,11 @@ func runDataStorageTests(t *testing.T, newStorage func(t *testing.T) DataStorage s := newStorage(t) ctx := context.Background() - require.NoError(t, s.Upsert(ctx, "sess-del", map[string]string{})) + _, err := s.Create(ctx, "sess-del", map[string]string{}) + require.NoError(t, err) require.NoError(t, s.Delete(ctx, "sess-del")) - _, err := s.Load(ctx, "sess-del") + _, err = s.Load(ctx, "sess-del") assert.ErrorIs(t, err, ErrSessionNotFound) }) @@ -197,7 +181,8 @@ func runDataStorageTests(t *testing.T, newStorage func(t *testing.T) DataStorage s := newStorage(t) ctx := context.Background() - require.NoError(t, s.Upsert(ctx, "sess-update", map[string]string{"v": "original"})) + _, err := s.Create(ctx, "sess-update", map[string]string{"v": "original"}) + require.NoError(t, err) updated, err := s.Update(ctx, "sess-update", map[string]string{"v": "updated"}) require.NoError(t, err) @@ -227,7 +212,8 @@ func runDataStorageTests(t *testing.T, newStorage func(t *testing.T) DataStorage s := newStorage(t) ctx := context.Background() - require.NoError(t, s.Upsert(ctx, "sess-deleted", map[string]string{"v": "1"})) + _, err := s.Create(ctx, "sess-deleted", map[string]string{"v": "1"}) + require.NoError(t, err) require.NoError(t, s.Delete(ctx, "sess-deleted")) updated, err := s.Update(ctx, "sess-deleted", map[string]string{"v": "2"}) @@ -249,7 +235,8 @@ func runDataStorageTests(t *testing.T, newStorage func(t *testing.T) DataStorage s := newStorage(t) ctx := context.Background() - require.NoError(t, s.Upsert(ctx, "sess-update-nil", map[string]string{"v": "original"})) + _, err := s.Create(ctx, "sess-update-nil", map[string]string{"v": "original"}) + require.NoError(t, err) updated, err := s.Update(ctx, "sess-update-nil", nil) require.NoError(t, err) @@ -286,7 +273,8 @@ func TestLocalSessionDataStorage(t *testing.T) { t.Cleanup(func() { _ = s.Close() }) ctx := context.Background() - require.NoError(t, s.Upsert(ctx, "ttl-sess", map[string]string{"k": "v"})) + _, err = s.Create(ctx, "ttl-sess", map[string]string{"k": "v"}) + require.NoError(t, err) backdateLocalEntry(t, s, "ttl-sess", ttl+time.Millisecond) s.deleteExpired() @@ -302,7 +290,8 @@ func TestLocalSessionDataStorage(t *testing.T) { t.Cleanup(func() { _ = s.Close() }) ctx := context.Background() - require.NoError(t, s.Upsert(ctx, "active-sess", map[string]string{})) + _, err = s.Create(ctx, "active-sess", map[string]string{}) + require.NoError(t, err) backdateLocalEntry(t, s, "active-sess", ttl+time.Millisecond) // Load should refresh the entry's timestamp. @@ -362,10 +351,11 @@ func TestRedisSessionDataStorage(t *testing.T) { s, mr := newTestRedisDataStorage(t) ctx := context.Background() - require.NoError(t, s.Upsert(ctx, "ttl-refresh", map[string]string{})) + _, err := s.Create(ctx, "ttl-refresh", map[string]string{}) + require.NoError(t, err) mr.FastForward(29 * time.Minute) - _, err := s.Load(ctx, "ttl-refresh") + _, err = s.Load(ctx, "ttl-refresh") require.NoError(t, err, "load should succeed before expiry") // Advance past the original TTL; load should have reset the clock. @@ -380,10 +370,11 @@ func TestRedisSessionDataStorage(t *testing.T) { s, mr := newTestRedisDataStorage(t) ctx := context.Background() - require.NoError(t, s.Upsert(ctx, "expiring", map[string]string{})) + _, err := s.Create(ctx, "expiring", map[string]string{}) + require.NoError(t, err) mr.FastForward(31 * time.Minute) - _, err := s.Load(ctx, "expiring") + _, err = s.Load(ctx, "expiring") assert.ErrorIs(t, err, ErrSessionNotFound) }) @@ -406,7 +397,8 @@ func TestRedisSessionDataStorage(t *testing.T) { s, mr := newTestRedisDataStorage(t) ctx := context.Background() - require.NoError(t, s.Upsert(ctx, "ttl-update", map[string]string{"v": "1"})) + _, err := s.Create(ctx, "ttl-update", map[string]string{"v": "1"}) + require.NoError(t, err) mr.FastForward(29 * time.Minute) updated, err := s.Update(ctx, "ttl-update", map[string]string{"v": "2"}) diff --git a/pkg/vmcp/server/sessionmanager/session_manager_test.go b/pkg/vmcp/server/sessionmanager/session_manager_test.go index 8b3bd792fc..a7bc3cbc80 100644 --- a/pkg/vmcp/server/sessionmanager/session_manager_test.go +++ b/pkg/vmcp/server/sessionmanager/session_manager_test.go @@ -80,14 +80,11 @@ func newMockFactoryWithError(t *testing.T, ctrl *gomock.Controller, err error) * return factory } -// alwaysFailDataStorage is a DataStorage whose Upsert/Create always return an +// alwaysFailDataStorage is a DataStorage whose Create/Update always return an // error. It is used to exercise the Generate() double-failure path (UUID collision // simulation — both attempts to Create fail, so Generate() must return ""). type alwaysFailDataStorage struct{} -func (alwaysFailDataStorage) Upsert(_ context.Context, _ string, _ map[string]string) error { - return errors.New("storage unavailable") -} func (alwaysFailDataStorage) Load(_ context.Context, _ string) (map[string]string, error) { return nil, transportsession.ErrSessionNotFound } @@ -105,7 +102,7 @@ func (alwaysFailDataStorage) Close() error { return type configurableFailDataStorage struct { transportsession.DataStorage storeCallCount int - failStoreAfter int // fail Upsert/Create after this many successful calls (0 = never fail, -1 = always fail) + failStoreAfter int // fail Create/Update after this many successful calls (0 = never fail, -1 = always fail) failDelete bool } @@ -114,13 +111,6 @@ func (s *configurableFailDataStorage) shouldFail() bool { return s.failStoreAfter == -1 || (s.failStoreAfter >= 0 && s.storeCallCount > s.failStoreAfter) } -func (s *configurableFailDataStorage) Upsert(ctx context.Context, id string, metadata map[string]string) error { - if s.shouldFail() { - return errors.New("injected Upsert failure") - } - return s.DataStorage.Upsert(ctx, id, metadata) -} - func (s *configurableFailDataStorage) Create(ctx context.Context, id string, metadata map[string]string) (bool, error) { if s.shouldFail() { return false, errors.New("injected Create failure") @@ -662,9 +652,10 @@ func TestSessionManager_Terminate(t *testing.T) { // Seed MetadataKeyTokenHash into storage so Terminate recognises this // as a Phase 2 (full MultiSession) and deletes rather than marks terminated. - require.NoError(t, storage.Upsert(context.Background(), sessionID, map[string]string{ + _, err = storage.Update(context.Background(), sessionID, map[string]string{ sessiontypes.MetadataKeyTokenHash: "", - })) + }) + require.NoError(t, err) // Session must exist before termination. _, loadErr := storage.Load(context.Background(), sessionID) @@ -862,7 +853,8 @@ func TestSessionManager_GetMultiSession(t *testing.T) { sessionID := "restore-placeholder-session" // Write placeholder metadata directly to storage, bypassing the cache. // Generate() stores an empty map with no token hash. - require.NoError(t, sm.storage.Upsert(context.Background(), sessionID, map[string]string{})) + _, err := sm.storage.Create(context.Background(), sessionID, map[string]string{}) + require.NoError(t, err) // loadSession detects absent MetadataKeyTokenHash → ErrSessionNotFound. multiSess, ok := sm.GetMultiSession(sessionID) @@ -896,7 +888,8 @@ func TestSessionManager_GetMultiSession(t *testing.T) { sessiontypes.MetadataKeyTokenHash: "", // anonymous sentinel — present but empty vmcpsession.MetadataKeyBackendIDs: "", // always written; empty = zero backends } - require.NoError(t, sm.storage.Upsert(context.Background(), sessionID, initializedMeta)) + _, err := sm.storage.Create(context.Background(), sessionID, initializedMeta) + require.NoError(t, err) // loadSession should call RestoreSession, not treat it as a placeholder. multiSess, ok := sm.GetMultiSession(sessionID) @@ -932,7 +925,8 @@ func TestSessionManager_GetMultiSession(t *testing.T) { sessiontypes.MetadataKeyTokenHash: "", // Phase 2 completion marker // MetadataKeyBackendIDs intentionally absent (legacy record) } - require.NoError(t, sm.storage.Upsert(context.Background(), sessionID, legacyMeta)) + _, err := sm.storage.Create(context.Background(), sessionID, legacyMeta) + require.NoError(t, err) multiSess, ok := sm.GetMultiSession(sessionID) require.True(t, ok, "legacy record without MetadataKeyBackendIDs must still be restorable") @@ -1994,9 +1988,10 @@ func TestSessionManager_CheckSession(t *testing.T) { t.Parallel() sm, storage := newTestSessionManager(t, makeFactory(t), newFakeRegistry()) sessionID := "alive-session" - require.NoError(t, storage.Upsert(context.Background(), sessionID, map[string]string{})) + _, err := storage.Create(context.Background(), sessionID, map[string]string{}) + require.NoError(t, err) - err := sm.checkSession(sessionID, makeEmptySess(t)) + err = sm.checkSession(sessionID, makeEmptySess(t)) assert.NoError(t, err, "alive session must return nil") }) @@ -2015,11 +2010,12 @@ func TestSessionManager_CheckSession(t *testing.T) { // so the cache evicts the entry and onEvict closes backend connections. sm, storage := newTestSessionManager(t, makeFactory(t), newFakeRegistry()) sessionID := "terminated-session" - require.NoError(t, storage.Upsert(context.Background(), sessionID, map[string]string{ + _, err := storage.Create(context.Background(), sessionID, map[string]string{ MetadataKeyTerminated: MetadataValTrue, - })) + }) + require.NoError(t, err) - err := sm.checkSession(sessionID, makeEmptySess(t)) + err = sm.checkSession(sessionID, makeEmptySess(t)) assert.ErrorIs(t, err, cache.ErrExpired, "terminated session must return ErrExpired") }) @@ -2033,9 +2029,10 @@ func TestSessionManager_CheckSession(t *testing.T) { sessionID := "stale-session" // Seed storage with the up-to-date backend list (backend-a expired). - require.NoError(t, storage.Upsert(context.Background(), sessionID, map[string]string{ + _, err := storage.Create(context.Background(), sessionID, map[string]string{ vmcpsession.MetadataKeyBackendIDs: "backend-b", - })) + }) + require.NoError(t, err) // Inject a cached session whose metadata still lists both backends, // simulating what this pod had before it learned about the expiry. @@ -2046,7 +2043,7 @@ func TestSessionManager_CheckSession(t *testing.T) { }).AnyTimes() sm.sessions.Set(sessionID, cached) - err := sm.checkSession(sessionID, cached) + err = sm.checkSession(sessionID, cached) assert.ErrorIs(t, err, cache.ErrExpired, "stale backend list must return ErrExpired to trigger cross-pod eviction") }) @@ -2056,9 +2053,10 @@ func TestSessionManager_CheckSession(t *testing.T) { sm, storage := newTestSessionManager(t, makeFactory(t), newFakeRegistry()) sessionID := "fresh-session" - require.NoError(t, storage.Upsert(context.Background(), sessionID, map[string]string{ + _, err := storage.Create(context.Background(), sessionID, map[string]string{ vmcpsession.MetadataKeyBackendIDs: "backend-a", - })) + }) + require.NoError(t, err) ctrl := gomock.NewController(t) cached := sessionmocks.NewMockMultiSession(ctrl) @@ -2067,7 +2065,7 @@ func TestSessionManager_CheckSession(t *testing.T) { }).AnyTimes() sm.sessions.Set(sessionID, cached) - err := sm.checkSession(sessionID, cached) + err = sm.checkSession(sessionID, cached) assert.NoError(t, err, "matching backend list must return nil") }) @@ -2078,14 +2076,15 @@ func TestSessionManager_CheckSession(t *testing.T) { sm, storage := newTestSessionManager(t, makeFactory(t), newFakeRegistry()) sessionID := "no-ids-session" - require.NoError(t, storage.Upsert(context.Background(), sessionID, map[string]string{})) + _, err := storage.Create(context.Background(), sessionID, map[string]string{}) + require.NoError(t, err) ctrl := gomock.NewController(t) cached := sessionmocks.NewMockMultiSession(ctrl) cached.EXPECT().GetMetadata().Return(map[string]string{}).AnyTimes() sm.sessions.Set(sessionID, cached) - err := sm.checkSession(sessionID, cached) + err = sm.checkSession(sessionID, cached) assert.NoError(t, err, "matching empty metadata must not cause eviction") }) } @@ -2109,7 +2108,9 @@ func TestNotifyBackendExpired(t *testing.T) { for workloadID, sessID := range sessionIDs { meta[vmcpsession.MetadataKeyBackendSessionPrefix+workloadID] = sessID } - require.NoError(t, storage.Upsert(context.Background(), sessionID, meta)) + updated, err := storage.Update(context.Background(), sessionID, meta) + require.NoError(t, err) + require.True(t, updated, "session must exist before seeding backend metadata") return meta } @@ -2191,7 +2192,8 @@ func TestNotifyBackendExpired(t *testing.T) { vmcpsession.MetadataKeyBackendSessionPrefix + "workload-a": "sess-a", // MetadataKeyBackendIDs intentionally absent } - require.NoError(t, storage.Upsert(context.Background(), sessionID, corruptedMeta)) + _, err := storage.Update(context.Background(), sessionID, corruptedMeta) + require.NoError(t, err) corruptedMetaBefore := maps.Clone(corruptedMeta) sm.NotifyBackendExpired(sessionID, "workload-a", corruptedMeta) @@ -2249,9 +2251,10 @@ func TestNotifyBackendExpired(t *testing.T) { // Seed MetadataKeyTokenHash into storage so Terminate recognises this // as a Phase 2 (full MultiSession) and deletes rather than marks terminated. - require.NoError(t, storage.Upsert(context.Background(), sessionID, map[string]string{ + _, err = storage.Update(context.Background(), sessionID, map[string]string{ sessiontypes.MetadataKeyTokenHash: "", - })) + }) + require.NoError(t, err) _, err = sm.Terminate(sessionID) require.NoError(t, err) From 7339bbd970c35af5207b373acfcba058d0eda3da Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota Date: Wed, 15 Apr 2026 10:29:51 +0200 Subject: [PATCH 038/117] fix lint issues with mcpgroup (#4838) Co-authored-by: taskbot --- .../virtualmcp/virtualmcp_redis_session_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go index 9346a3d9a5..6ecf7a5364 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go @@ -62,7 +62,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -173,7 +173,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -357,7 +357,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -548,7 +548,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: mcpGroupName, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, From 2fd2d3bf5f9f840af530d864589f68dc1095a0e7 Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:53:59 +0200 Subject: [PATCH 039/117] Add GetContent API for on-the-fly SKILL.md retrieval from OCI and git sources (#4810) * Add GetContent method to SkillService for on-the-fly SKILL.md retrieval Add ContentOptions, SkillContent, and SkillFileEntry types to the skills package and extend the SkillService interface with GetContent. Regenerate the mock to match. * Implement GetContent with OCI, git, and registry name resolution GetContent resolves skill content without installing by trying, in order: git:// and https:// references via the git resolver, the local OCI store and remote OCI pull, and finally a registry catalog name lookup that handles both plain names and qualified namespace/name identifiers. Also fixes resolveRegistryPackages to append Subfolder as a #path fragment to git URLs, and updates resolveFromRegistry to support qualified namespace/name references. * Add tests for GetContent git resolution and registry fallback Cover git reference dispatch, git resolver failures, nil resolver guard, registry name fallback to git, qualified namespace/name filtering, splitQualifiedName, and resolveRegistryPackages subfolder handling. * Update swagger.json with GetContent endpoint and schemas * Fix gci import ordering, line length, and regenerate swagger docs * Address code review: guard registry lookup, fix doc comment, add 502 to swagger Skip registry name lookup for refs containing : or @ to avoid a wasted network round-trip when a fully-qualified OCI reference fails. Fix the GetContent doc comment to reflect the actual resolution order. Document the 502 Bad Gateway response in the swagger annotation. * Fix gci import grouping to match project config --- docs/server/docs.go | 108 ++++++++++ docs/server/swagger.json | 108 ++++++++++ docs/server/swagger.yaml | 74 +++++++ pkg/api/v1/skills.go | 34 +++ pkg/skills/client/client.go | 11 + pkg/skills/client/client_test.go | 78 +++++++ pkg/skills/mocks/mock_service.go | 15 ++ pkg/skills/options.go | 30 +++ pkg/skills/service.go | 3 + pkg/skills/skillsvc/skillsvc.go | 230 +++++++++++++++++++- pkg/skills/skillsvc/skillsvc_test.go | 302 +++++++++++++++++++++++++++ 11 files changed, 986 insertions(+), 7 deletions(-) diff --git a/docs/server/docs.go b/docs/server/docs.go index ed55ea6cc9..5ea52bbaec 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1541,6 +1541,52 @@ const docTemplate = `{ "ScopeProject" ] }, + "github_com_stacklok_toolhive_pkg_skills.SkillContent": { + "properties": { + "body": { + "description": "Body is the raw SKILL.md markdown content.", + "type": "string" + }, + "description": { + "description": "Description is the skill description from the OCI config labels.", + "type": "string" + }, + "files": { + "description": "Files is the list of all files in the artifact with their sizes.", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SkillFileEntry" + }, + "type": "array", + "uniqueItems": false + }, + "license": { + "description": "License is the SPDX license identifier from the OCI config labels.", + "type": "string" + }, + "name": { + "description": "Name is the skill name from the OCI config labels.", + "type": "string" + }, + "version": { + "description": "Version is the skill version from the OCI config labels.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_skills.SkillFileEntry": { + "properties": { + "path": { + "description": "Path is the file path within the artifact.", + "type": "string" + }, + "size": { + "description": "Size is the uncompressed file size in bytes.", + "type": "integer" + } + }, + "type": "object" + }, "github_com_stacklok_toolhive_pkg_skills.SkillInfo": { "properties": { "installed_skill": { @@ -5115,6 +5161,68 @@ const docTemplate = `{ ] } }, + "/api/v1beta/skills/content": { + "get": { + "description": "Retrieve the SKILL.md body and file listing from an artifact\nwithout installing it. Accepts OCI refs, git refs, or local tags.", + "parameters": [ + { + "description": "OCI reference or local build tag", + "in": "query", + "name": "ref", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SkillContent" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Gateway" + } + }, + "summary": "Get skill content", + "tags": [ + "skills" + ] + } + }, "/api/v1beta/skills/push": { "post": { "description": "Push a built skill artifact to a remote registry", diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 4145699631..8bb3ca0cbb 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1534,6 +1534,52 @@ "ScopeProject" ] }, + "github_com_stacklok_toolhive_pkg_skills.SkillContent": { + "properties": { + "body": { + "description": "Body is the raw SKILL.md markdown content.", + "type": "string" + }, + "description": { + "description": "Description is the skill description from the OCI config labels.", + "type": "string" + }, + "files": { + "description": "Files is the list of all files in the artifact with their sizes.", + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SkillFileEntry" + }, + "type": "array", + "uniqueItems": false + }, + "license": { + "description": "License is the SPDX license identifier from the OCI config labels.", + "type": "string" + }, + "name": { + "description": "Name is the skill name from the OCI config labels.", + "type": "string" + }, + "version": { + "description": "Version is the skill version from the OCI config labels.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_skills.SkillFileEntry": { + "properties": { + "path": { + "description": "Path is the file path within the artifact.", + "type": "string" + }, + "size": { + "description": "Size is the uncompressed file size in bytes.", + "type": "integer" + } + }, + "type": "object" + }, "github_com_stacklok_toolhive_pkg_skills.SkillInfo": { "properties": { "installed_skill": { @@ -5108,6 +5154,68 @@ ] } }, + "/api/v1beta/skills/content": { + "get": { + "description": "Retrieve the SKILL.md body and file listing from an artifact\nwithout installing it. Accepts OCI refs, git refs, or local tags.", + "parameters": [ + { + "description": "OCI reference or local build tag", + "in": "query", + "name": "ref", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SkillContent" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Request" + }, + "500": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Gateway" + } + }, + "summary": "Get skill content", + "tags": [ + "skills" + ] + } + }, "/api/v1beta/skills/push": { "post": { "description": "Push a built skill artifact to a remote registry", diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 9eb1f34734..8822cc6d2f 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1432,6 +1432,40 @@ components: x-enum-varnames: - ScopeUser - ScopeProject + github_com_stacklok_toolhive_pkg_skills.SkillContent: + properties: + body: + description: Body is the raw SKILL.md markdown content. + type: string + description: + description: Description is the skill description from the OCI config labels. + type: string + files: + description: Files is the list of all files in the artifact with their sizes. + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SkillFileEntry' + type: array + uniqueItems: false + license: + description: License is the SPDX license identifier from the OCI config + labels. + type: string + name: + description: Name is the skill name from the OCI config labels. + type: string + version: + description: Version is the skill version from the OCI config labels. + type: string + type: object + github_com_stacklok_toolhive_pkg_skills.SkillFileEntry: + properties: + path: + description: Path is the file path within the artifact. + type: string + size: + description: Size is the uncompressed file size in bytes. + type: integer + type: object github_com_stacklok_toolhive_pkg_skills.SkillInfo: properties: installed_skill: @@ -4160,6 +4194,46 @@ paths: summary: Delete a locally-built skill artifact tags: - skills + /api/v1beta/skills/content: + get: + description: |- + Retrieve the SKILL.md body and file listing from an artifact + without installing it. Accepts OCI refs, git refs, or local tags. + parameters: + - description: OCI reference or local build tag + in: query + name: ref + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SkillContent' + description: OK + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + "502": + content: + application/json: + schema: + type: string + description: Bad Gateway + summary: Get skill content + tags: + - skills /api/v1beta/skills/push: post: description: Push a built skill artifact to a remote registry diff --git a/pkg/api/v1/skills.go b/pkg/api/v1/skills.go index 7f39da4b9b..3ebd627837 100644 --- a/pkg/api/v1/skills.go +++ b/pkg/api/v1/skills.go @@ -36,6 +36,7 @@ func SkillsRouter(skillService skills.SkillService) http.Handler { r.Post("/push", apierrors.ErrorHandler(routes.pushSkill)) r.Get("/builds", apierrors.ErrorHandler(routes.listBuilds)) r.Delete("/builds/{tag}", apierrors.ErrorHandler(routes.deleteBuild)) + r.Get("/content", apierrors.ErrorHandler(routes.getSkillContent)) return r } @@ -322,3 +323,36 @@ func (s *SkillsRoutes) deleteBuild(w http.ResponseWriter, r *http.Request) error w.WriteHeader(http.StatusNoContent) return nil } + +// getSkillContent retrieves the SKILL.md body and file listing from an OCI artifact. +// +// @Summary Get skill content +// @Description Retrieve the SKILL.md body and file listing from an artifact +// @Description without installing it. Accepts OCI refs, git refs, or local tags. +// @Tags skills +// @Produce json +// @Param ref query string true "OCI reference or local build tag" +// @Success 200 {object} skills.SkillContent +// @Failure 400 {string} string "Bad Request" +// @Failure 502 {string} string "Bad Gateway" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1beta/skills/content [get] +func (s *SkillsRoutes) getSkillContent(w http.ResponseWriter, r *http.Request) error { + ref := r.URL.Query().Get("ref") + if ref == "" { + return httperr.WithCode( + fmt.Errorf("ref query parameter is required"), + http.StatusBadRequest, + ) + } + + content, err := s.skillService.GetContent(r.Context(), skills.ContentOptions{ + Reference: ref, + }) + if err != nil { + return err + } + + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(content) +} diff --git a/pkg/skills/client/client.go b/pkg/skills/client/client.go index 1285028fd6..504f4c4e9c 100644 --- a/pkg/skills/client/client.go +++ b/pkg/skills/client/client.go @@ -248,6 +248,17 @@ func (c *Client) DeleteBuild(ctx context.Context, tag string) error { return c.doJSONRequest(ctx, http.MethodDelete, "/builds/"+url.PathEscape(tag), nil, nil, nil) } +// GetContent retrieves the SKILL.md body and file listing from an OCI artifact without installing it. +func (c *Client) GetContent(ctx context.Context, opts skills.ContentOptions) (*skills.SkillContent, error) { + q := url.Values{} + q.Set("ref", opts.Reference) + var content skills.SkillContent + if err := c.doJSONRequest(ctx, http.MethodGet, "/content", q, nil, &content); err != nil { + return nil, err + } + return &content, nil +} + // --- internal helpers --- func (c *Client) buildURL(path string, query url.Values) string { diff --git a/pkg/skills/client/client_test.go b/pkg/skills/client/client_test.go index abe9c882d4..cf4be5e8e6 100644 --- a/pkg/skills/client/client_test.go +++ b/pkg/skills/client/client_test.go @@ -542,6 +542,84 @@ func TestPush(t *testing.T) { } } +func TestGetContent(t *testing.T) { + t.Parallel() + + response := skills.SkillContent{ + Name: "my-skill", + Description: "A test skill", + Version: "1.0.0", + License: "Apache-2.0", + Body: "# My Skill\nDoes things.", + Files: []skills.SkillFileEntry{{Path: "SKILL.md", Size: 42}}, + } + + tests := []struct { + name string + opts skills.ContentOptions + wantQuery string + response skills.SkillContent + statusCode int + wantErr bool + wantCode int + }{ + { + name: "success with local tag", + opts: skills.ContentOptions{Reference: "my-skill"}, + wantQuery: "my-skill", + response: response, + statusCode: http.StatusOK, + }, + { + name: "success with OCI reference", + opts: skills.ContentOptions{Reference: "ghcr.io/org/my-skill:v1"}, + wantQuery: "ghcr.io/org/my-skill:v1", + response: response, + statusCode: http.StatusOK, + }, + { + name: "server error propagates", + opts: skills.ContentOptions{Reference: "missing"}, + statusCode: http.StatusBadRequest, + wantErr: true, + wantCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, skillsBasePath+"/content", r.URL.Path) + if tt.wantQuery != "" { + assert.Equal(t, tt.wantQuery, r.URL.Query().Get("ref")) + } + + if tt.statusCode >= http.StatusBadRequest { + http.Error(w, "bad request", tt.statusCode) + return + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(tt.response)) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.GetContent(t.Context(), tt.opts) + + if tt.wantErr { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + require.NoError(t, err) + assert.Equal(t, tt.response, *got) + }) + } +} + func TestConnectionError(t *testing.T) { t.Parallel() diff --git a/pkg/skills/mocks/mock_service.go b/pkg/skills/mocks/mock_service.go index b37d3fb563..e176f22f44 100644 --- a/pkg/skills/mocks/mock_service.go +++ b/pkg/skills/mocks/mock_service.go @@ -70,6 +70,21 @@ func (mr *MockSkillServiceMockRecorder) DeleteBuild(ctx, tag any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBuild", reflect.TypeOf((*MockSkillService)(nil).DeleteBuild), ctx, tag) } +// GetContent mocks base method. +func (m *MockSkillService) GetContent(ctx context.Context, opts skills.ContentOptions) (*skills.SkillContent, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetContent", ctx, opts) + ret0, _ := ret[0].(*skills.SkillContent) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetContent indicates an expected call of GetContent. +func (mr *MockSkillServiceMockRecorder) GetContent(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContent", reflect.TypeOf((*MockSkillService)(nil).GetContent), ctx, opts) +} + // Info mocks base method. func (m *MockSkillService) Info(ctx context.Context, opts skills.InfoOptions) (*skills.SkillInfo, error) { m.ctrl.T.Helper() diff --git a/pkg/skills/options.go b/pkg/skills/options.go index e79f28563e..a822c8918f 100644 --- a/pkg/skills/options.go +++ b/pkg/skills/options.go @@ -73,6 +73,36 @@ type SkillInfo struct { InstalledSkill *InstalledSkill `json:"installed_skill,omitempty"` } +// ContentOptions configures the behavior of the GetContent operation. +type ContentOptions struct { + // Reference is an OCI reference (e.g. ghcr.io/org/skill:v1) or a local build tag. + Reference string `json:"reference"` +} + +// SkillFileEntry represents a single file within a skill artifact. +type SkillFileEntry struct { + // Path is the file path within the artifact. + Path string `json:"path"` + // Size is the uncompressed file size in bytes. + Size int `json:"size"` +} + +// SkillContent contains the SKILL.md body and file listing extracted from an OCI artifact. +type SkillContent struct { + // Name is the skill name from the OCI config labels. + Name string `json:"name"` + // Description is the skill description from the OCI config labels. + Description string `json:"description"` + // Version is the skill version from the OCI config labels. + Version string `json:"version,omitempty"` + // License is the SPDX license identifier from the OCI config labels. + License string `json:"license,omitempty"` + // Body is the raw SKILL.md markdown content. + Body string `json:"body"` + // Files is the list of all files in the artifact with their sizes. + Files []SkillFileEntry `json:"files"` +} + // ValidationResult contains the outcome of a Validate operation. type ValidationResult struct { // Valid indicates whether the skill definition is valid. diff --git a/pkg/skills/service.go b/pkg/skills/service.go index 6228eec2ed..53dcce75c4 100644 --- a/pkg/skills/service.go +++ b/pkg/skills/service.go @@ -27,4 +27,7 @@ type SkillService interface { ListBuilds(ctx context.Context) ([]LocalBuild, error) // DeleteBuild removes a locally-built OCI skill artifact from the local store. DeleteBuild(ctx context.Context, tag string) error + // GetContent retrieves the SKILL.md body and file listing from an OCI artifact + // without installing it. Works for both remote registry references and local build tags. + GetContent(ctx context.Context, opts ContentOptions) (*SkillContent, error) } diff --git a/pkg/skills/skillsvc/skillsvc.go b/pkg/skills/skillsvc/skillsvc.go index 91c43d1912..18ec509b12 100644 --- a/pkg/skills/skillsvc/skillsvc.go +++ b/pkg/skills/skillsvc/skillsvc.go @@ -412,6 +412,199 @@ func (s *service) Info(ctx context.Context, opts skills.InfoOptions) (*skills.Sk }, nil } +// GetContent retrieves the SKILL.md body and file listing from a skill artifact +// without installing it. The reference may be: +// - A local build tag (e.g. "my-skill") +// - A fully-qualified OCI reference (e.g. "ghcr.io/org/skill:v1") +// - A git:// reference (e.g. "git://github.com/org/repo#path/to/skill") +// - An https:// URL (converted to git:// internally) +// +// Resolution order: git (git:// and https://) → OCI (local store, then remote +// pull) → registry catalog lookup. +func (s *service) GetContent(ctx context.Context, opts skills.ContentOptions) (*skills.SkillContent, error) { + ref := opts.Reference + if ref == "" { + return nil, httperr.WithCode( + errors.New("reference is required"), + http.StatusBadRequest, + ) + } + + // Git references (git:// or https://) are dispatched first since their + // scheme prefix is unambiguous and cannot collide with OCI references. + if gitresolver.IsGitReference(ref) { + return s.getContentFromGit(ctx, ref) + } + if isHTTPURL(ref) { + gitURL, err := buildGitReferenceFromRegistryURL(ref) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("invalid URL %q: %w", ref, err), + http.StatusBadRequest, + ) + } + return s.getContentFromGit(ctx, gitURL) + } + + // Try OCI resolution (local store + remote pull). If this succeeds, return. + content, ociErr := s.getContentFromOCI(ctx, ref) + if ociErr == nil { + return content, nil + } + + // OCI failed — try resolving via registry name lookup (e.g. "skill-creator" + // or "io.github.stacklok/skill-creator" from the catalog index). + // Skip for refs that are clearly OCI references (contain : or @) to avoid + // a wasted network round-trip searching for e.g. "skill:v1". + if strings.ContainsAny(ref, ":@") { + return nil, ociErr + } + resolved, regErr := s.resolveFromRegistry(ref) + if regErr != nil { + return nil, regErr + } + if resolved != nil { + switch { + case resolved.OCIRef != nil: + return s.getContentFromOCI(ctx, resolved.OCIRef.String()) + case resolved.GitURL != "": + return s.getContentFromGit(ctx, resolved.GitURL) + } + } + + // Nothing matched — return the original OCI error. + return nil, ociErr +} + +// getContentFromGit clones a git repository and extracts the SKILL.md content. +func (s *service) getContentFromGit(ctx context.Context, ref string) (*skills.SkillContent, error) { + if s.gitResolver == nil { + return nil, httperr.WithCode( + errors.New("git resolver is not configured"), + http.StatusInternalServerError, + ) + } + + gitRef, err := gitresolver.ParseGitReference(ref) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("invalid git reference: %w", err), + http.StatusBadRequest, + ) + } + + resolved, err := s.gitResolver.Resolve(ctx, gitRef) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("resolving git skill: %w", err), + http.StatusBadGateway, + ) + } + + content := &skills.SkillContent{ + Name: resolved.SkillConfig.Name, + Description: resolved.SkillConfig.Description, + Version: resolved.SkillConfig.Version, + License: resolved.SkillConfig.License, + Body: string(resolved.SkillConfig.Body), + Files: make([]skills.SkillFileEntry, 0, len(resolved.Files)), + } + + for _, f := range resolved.Files { + content.Files = append(content.Files, skills.SkillFileEntry{ + Path: f.Path, + Size: len(f.Content), + }) + } + + return content, nil +} + +// getContentFromOCI resolves a reference from the local OCI store or pulls it +// from a remote registry, then extracts the SKILL.md content. +func (s *service) getContentFromOCI(ctx context.Context, ref string) (*skills.SkillContent, error) { + if s.ociStore == nil { + return nil, httperr.WithCode( + errors.New("OCI store is not configured"), + http.StatusInternalServerError, + ) + } + + // Try the local store first (covers local builds by tag name and + // previously pulled remote refs tagged by Pull). + d, resolveErr := s.ociStore.Resolve(ctx, ref) + if resolveErr != nil { + if s.registry == nil { + return nil, httperr.WithCode( + fmt.Errorf("reference %q not found in local store and OCI registry is not configured", ref), + http.StatusBadRequest, + ) + } + + ociRef, isOCI, parseErr := parseOCIReference(ref) + if parseErr != nil { + return nil, httperr.WithCode( + fmt.Errorf("invalid reference %q: %w", ref, parseErr), + http.StatusBadRequest, + ) + } + if !isOCI { + return nil, httperr.WithCode( + fmt.Errorf("reference %q not found in local store and is not a valid OCI reference", ref), + http.StatusBadRequest, + ) + } + + qualifiedRef := qualifiedOCIRef(ociRef) + pullCtx, cancel := context.WithTimeout(ctx, ociPullTimeout) + defer cancel() + + var pullErr error + d, pullErr = s.registry.Pull(pullCtx, s.ociStore, qualifiedRef) + if pullErr != nil { + return nil, httperr.WithCode( + fmt.Errorf("pulling OCI artifact %q: %w", qualifiedRef, pullErr), + http.StatusBadRequest, + ) + } + } + + layerData, skillConfig, err := s.extractOCIContent(ctx, d) + if err != nil { + return nil, err + } + + entries, err := ociskills.DecompressTar(layerData) + if err != nil { + return nil, fmt.Errorf("decompressing skill layer: %w", err) + } + + content := &skills.SkillContent{ + Name: skillConfig.Name, + Description: skillConfig.Description, + Version: skillConfig.Version, + License: skillConfig.License, + Files: make([]skills.SkillFileEntry, 0, len(entries)), + } + + for _, entry := range entries { + content.Files = append(content.Files, skills.SkillFileEntry{ + Path: entry.Path, + Size: len(entry.Content), + }) + if strings.EqualFold(filepath.Base(entry.Path), "SKILL.md") { + content.Body = string(entry.Content) + } + } + + return content, nil +} + +// isHTTPURL returns true if the reference starts with http:// or https://. +func isHTTPURL(ref string) bool { + return strings.HasPrefix(ref, "https://") || strings.HasPrefix(ref, "http://") +} + // Validate checks whether a skill definition is valid. func (*service) Validate(_ context.Context, path string) (*skills.ValidationResult, error) { if err := validateLocalPath(path); err != nil { @@ -970,27 +1163,37 @@ type registryResolveResult struct { GitURL string // raw git:// URL for installFromGit } -// resolveFromRegistry attempts to resolve a plain skill name by querying the -// configured skill registry/index. Returns (result, nil) on success, (nil, nil) -// when no match is found or no lookup is configured, or (nil, err) on ambiguity. +// resolveFromRegistry attempts to resolve a skill name by querying the +// configured skill registry/index. Accepts either a plain name ("skill-creator") +// or a qualified "namespace/name" ("io.github.stacklok/skill-creator"). +// Returns (result, nil) on success, (nil, nil) when no match is found or no +// lookup is configured, or (nil, err) on ambiguity. func (s *service) resolveFromRegistry(name string) (*registryResolveResult, error) { if s.skillLookup == nil { return nil, nil } - results, err := s.skillLookup.SearchSkills(name) + // Split qualified "namespace/name" if present. Use the last segment as + // the search query since SearchSkills matches on name substring. + wantNamespace, searchName := splitQualifiedName(name) + + results, err := s.skillLookup.SearchSkills(searchName) if err != nil { slog.Warn("registry skill lookup failed, falling back to not-found", "name", name, "error", err) return nil, nil } - // Filter for exact name match. Case-insensitive because registry data + // Filter for exact match. Case-insensitive because registry data // may not be normalized to lowercase even though local skill names are. var matches []regtypes.Skill for _, sk := range results { - if strings.EqualFold(sk.Name, name) { - matches = append(matches, sk) + if !strings.EqualFold(sk.Name, searchName) { + continue } + if wantNamespace != "" && !strings.EqualFold(sk.Namespace, wantNamespace) { + continue + } + matches = append(matches, sk) } if len(matches) == 0 { @@ -1018,6 +1221,16 @@ func (s *service) resolveFromRegistry(name string) (*registryResolveResult, erro return resolveRegistryPackages(name, matches[0].Packages) } +// splitQualifiedName splits "namespace/name" into (namespace, name). +// If the input has no "/" it returns ("", name) unchanged. +func splitQualifiedName(s string) (namespace, name string) { + idx := strings.LastIndex(s, "/") + if idx < 0 { + return "", s + } + return s[:idx], s[idx+1:] +} + // resolveRegistryPackages selects the best installable package from a registry // entry. OCI packages are preferred; git is the fallback. func resolveRegistryPackages(name string, packages []regtypes.SkillPackage) (*registryResolveResult, error) { @@ -1047,6 +1260,9 @@ func resolveRegistryPackages(name string, packages []regtypes.SkillPackage) (*re http.StatusUnprocessableEntity, ) } + if pkg.Subfolder != "" { + gitURL += "#" + pkg.Subfolder + } return ®istryResolveResult{GitURL: gitURL}, nil } } diff --git a/pkg/skills/skillsvc/skillsvc_test.go b/pkg/skills/skillsvc/skillsvc_test.go index d642c6a1ba..a924a86545 100644 --- a/pkg/skills/skillsvc/skillsvc_test.go +++ b/pkg/skills/skillsvc/skillsvc_test.go @@ -3276,6 +3276,269 @@ func TestListBuilds(t *testing.T) { }) } +func TestGetContent(t *testing.T) { + t.Parallel() + + t.Run("nil oci store returns 500", func(t *testing.T) { + t.Parallel() + svc := New(&storage.NoopSkillStore{}) + _, err := svc.GetContent(t.Context(), skills.ContentOptions{Reference: "my-skill"}) + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) + }) + + t.Run("empty reference returns 400", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: ""}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("local build tag resolves without registry", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + d := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), d, "my-skill")) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + content, err := svc.GetContent(t.Context(), skills.ContentOptions{Reference: "my-skill"}) + require.NoError(t, err) + + assert.Equal(t, "my-skill", content.Name) + assert.Equal(t, "1.0.0", content.Version) + assert.NotEmpty(t, content.Body) + assert.NotEmpty(t, content.Files) + + // SKILL.md must appear in the file list. + var skillMDFound bool + for _, f := range content.Files { + if strings.EqualFold(filepath.Base(f.Path), "SKILL.md") { + skillMDFound = true + assert.Greater(t, f.Size, 0) + } + } + assert.True(t, skillMDFound, "SKILL.md should be listed in Files") + }) + + t.Run("remote OCI reference triggers pull", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + indexDigest := buildTestArtifact(t, ociStore, "my-skill", "2.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v2"). + Return(indexDigest, nil) + + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithRegistryClient(reg), + ) + content, err := svc.GetContent(t.Context(), skills.ContentOptions{ + Reference: "ghcr.io/org/my-skill:v2", + }) + require.NoError(t, err) + + assert.Equal(t, "my-skill", content.Name) + assert.Equal(t, "2.0.0", content.Version) + assert.NotEmpty(t, content.Body) + }) + + t.Run("unqualified name not in store without registry returns 400", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: "nonexistent"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("nil registry with unresolvable remote ref returns 400", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + // "ghcr.io/org/skill:v1" is a valid OCI ref but registry is nil. + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: "ghcr.io/org/skill:v1"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("pull failure propagates as 400", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). + Return(godigest.Digest(""), fmt.Errorf("registry unreachable")) + + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithRegistryClient(reg), + ) + _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: "ghcr.io/org/my-skill:v1"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + assert.Contains(t, err.Error(), "registry unreachable") + }) + + t.Run("git reference resolves via git resolver", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{ + Name: "my-skill", + Description: "a git skill", + Version: "1.0.0", + Body: []byte("# My Skill\nHello from git"), + }, + Files: []gitresolver.FileEntry{ + {Path: "SKILL.md", Content: []byte("# My Skill\nHello from git"), Mode: 0644}, + {Path: "hooks.sh", Content: []byte("#!/bin/sh"), Mode: 0644}, + }, + CommitHash: testCommitHash, + }, nil) + + svc := New(&storage.NoopSkillStore{}, WithGitResolver(gr)) + content, err := svc.GetContent(t.Context(), skills.ContentOptions{ + Reference: "git://github.com/test/my-skill#skills/my-skill", + }) + require.NoError(t, err) + assert.Equal(t, "my-skill", content.Name) + assert.Equal(t, "a git skill", content.Description) + assert.Equal(t, "1.0.0", content.Version) + assert.Contains(t, content.Body, "Hello from git") + assert.Len(t, content.Files, 2) + }) + + t.Run("git resolve failure returns 502", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("clone failed")) + + svc := New(&storage.NoopSkillStore{}, WithGitResolver(gr)) + _, err := svc.GetContent(t.Context(), skills.ContentOptions{ + Reference: "git://github.com/test/my-skill", + }) + require.Error(t, err) + assert.Equal(t, http.StatusBadGateway, httperr.Code(err)) + assert.Contains(t, err.Error(), "resolving git skill") + }) + + t.Run("nil git resolver returns 500", func(t *testing.T) { + t.Parallel() + svc := &service{} + _, err := svc.GetContent(t.Context(), skills.ContentOptions{ + Reference: "git://github.com/test/my-skill", + }) + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) + }) + + t.Run("registry name falls back to git resolver", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("skill-creator").Return([]regtypes.Skill{ + { + Namespace: "io.github.stacklok", + Name: "skill-creator", + Packages: []regtypes.SkillPackage{ + { + RegistryType: "git", + URL: "https://github.com/stacklok/toolhive-catalog", + Subfolder: "registries/toolhive/skills/skill-creator", + }, + }, + }, + }, nil) + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, ref *gitresolver.GitReference) (*gitresolver.ResolveResult, error) { + assert.Equal(t, "registries/toolhive/skills/skill-creator", ref.Path) + return &gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{ + Name: "skill-creator", + Description: "creates skills", + Body: []byte("# Skill Creator"), + }, + Files: []gitresolver.FileEntry{ + {Path: "SKILL.md", Content: []byte("# Skill Creator"), Mode: 0644}, + }, + CommitHash: testCommitHash, + }, nil + }) + + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithSkillLookup(lookup), + WithGitResolver(gr), + ) + content, err := svc.GetContent(t.Context(), skills.ContentOptions{ + Reference: "io.github.stacklok/skill-creator", + }) + require.NoError(t, err) + assert.Equal(t, "skill-creator", content.Name) + assert.Contains(t, content.Body, "Skill Creator") + }) + + t.Run("qualified namespace/name filters registry matches", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + {Namespace: "io.github.alice", Name: "my-skill", + Packages: []regtypes.SkillPackage{{RegistryType: "git", URL: "https://github.com/alice/repo"}}}, + {Namespace: "io.github.bob", Name: "my-skill", + Packages: []regtypes.SkillPackage{{RegistryType: "git", URL: "https://github.com/bob/repo"}}}, + }, nil) + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{Name: "my-skill", Body: []byte("# Bob's skill")}, + Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("# Bob"), Mode: 0644}}, + CommitHash: testCommitHash, + }, nil) + + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithSkillLookup(lookup), + WithGitResolver(gr), + ) + content, err := svc.GetContent(t.Context(), skills.ContentOptions{ + Reference: "io.github.bob/my-skill", + }) + require.NoError(t, err) + assert.Equal(t, "my-skill", content.Name) + }) +} + func TestDeleteBuild(t *testing.T) { t.Parallel() @@ -3334,3 +3597,42 @@ func TestDeleteBuild(t *testing.T) { assert.Equal(t, "tag-b", builds[0].Tag) }) } + +func TestSplitQualifiedName(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + wantNS string + wantName string + }{ + {"skill-creator", "", "skill-creator"}, + {"io.github.stacklok/skill-creator", "io.github.stacklok", "skill-creator"}, + {"deep/nested/name", "deep/nested", "name"}, + {"", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + ns, name := splitQualifiedName(tt.input) + assert.Equal(t, tt.wantNS, ns) + assert.Equal(t, tt.wantName, name) + }) + } +} + +func TestResolveRegistryPackagesSubfolder(t *testing.T) { + t.Parallel() + + result, err := resolveRegistryPackages("my-skill", []regtypes.SkillPackage{ + { + RegistryType: "git", + URL: "https://github.com/org/repo", + Subfolder: "skills/my-skill", + }, + }) + require.NoError(t, err) + require.NotNil(t, result) + assert.Contains(t, result.GitURL, "#skills/my-skill") +} From 60108a1759585cb7daf21e9cf31423e76f4c22ab Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:00:09 +0100 Subject: [PATCH 040/117] Remove deprecated incomingAuth.oidcConfig from VirtualMCPServer (#4822) --- .../api/v1alpha1/virtualmcpserver_types.go | 24 +- .../api/v1alpha1/zz_generated.deepcopy.go | 5 - .../virtualmcpserver_controller_test.go | 86 ++++--- .../virtualmcpserver_deployment.go | 58 +---- .../virtualmcpserver_vmcpconfig_test.go | 24 +- cmd/thv-operator/pkg/vmcpconfig/converter.go | 67 +---- .../pkg/vmcpconfig/converter_test.go | 243 ++++++++++-------- ...onfig_virtualmcpserver_integration_test.go | 21 ++ ...olhive.stacklok.dev_virtualmcpservers.yaml | 242 +---------------- ...olhive.stacklok.dev_virtualmcpservers.yaml | 242 +---------------- docs/operator/crd-api.md | 23 +- .../vmcp_inline_incoming_auth.yaml | 15 +- .../virtual-mcps/vmcp_production_full.yaml | 14 +- .../vmcp/basic/chainsaw-test.yaml | 21 +- .../vmcp/controller-chainsaw-test.yaml | 4 + .../test-scenarios/vmcp/vmcp-oidc-config.yaml | 13 + .../test-scenarios/vmcp/vmcp-with-oidc.yaml | 14 +- .../virtualmcp_auth_discovery_test.go | 37 ++- .../virtualmcp_authserver_config_test.go | 22 +- .../virtualmcp_session_management_test.go | 28 +- 20 files changed, 352 insertions(+), 851 deletions(-) create mode 100644 test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-oidc-config.yaml diff --git a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go index 778eeb4a3f..98c3b192f5 100644 --- a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go +++ b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go @@ -89,7 +89,7 @@ type VirtualMCPServerSpec struct { // AuthServerConfig configures an embedded OAuth authorization server. // When set, the vMCP server acts as an OIDC issuer, drives users through // upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the - // IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfig + // IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfigRef // so that tokens it issues are accepted by the vMCP's incoming auth middleware. // When nil, IncomingAuth uses an external IDP and behavior is unchanged. // +optional @@ -121,8 +121,7 @@ type EmbeddingServerRef struct { // IncomingAuthConfig configures authentication for clients connecting to the Virtual MCP server // -// +kubebuilder:validation:XValidation:rule="self.type == 'oidc' ? (has(self.oidcConfig) || has(self.oidcConfigRef)) : true",message="spec.incomingAuth.oidcConfig or oidcConfigRef is required when type is oidc" -// +kubebuilder:validation:XValidation:rule="!(has(self.oidcConfig) && has(self.oidcConfigRef))",message="oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef to reference a shared MCPOIDCConfig" +// +kubebuilder:validation:XValidation:rule="self.type == 'oidc' ? has(self.oidcConfigRef) : true",message="spec.incomingAuth.oidcConfigRef is required when type is oidc" // //nolint:lll // CEL validation rules exceed line length limit type IncomingAuthConfig struct { @@ -132,16 +131,10 @@ type IncomingAuthConfig struct { // +kubebuilder:validation:Required Type string `json:"type"` - // OIDCConfig defines OIDC authentication configuration. - // Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead. - // This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. - // +optional - OIDCConfig *OIDCConfigRef `json:"oidcConfig,omitempty"` - // OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. // The referenced MCPOIDCConfig must exist in the same namespace as this VirtualMCPServer. // Per-server overrides (audience, scopes) are specified here; shared provider config - // lives in the MCPOIDCConfig resource. Mutually exclusive with oidcConfig. + // lives in the MCPOIDCConfig resource. // +optional OIDCConfigRef *MCPOIDCConfigReference `json:"oidcConfigRef,omitempty"` @@ -429,18 +422,7 @@ type VirtualMCPServerList struct { Items []VirtualMCPServer `json:"items"` } -// GetOIDCConfig returns the OIDC configuration reference for incoming auth. -// This implements the OIDCConfigurable interface to allow the OIDC resolver -// to resolve Kubernetes and ConfigMap OIDC configurations. -func (v *VirtualMCPServer) GetOIDCConfig() *OIDCConfigRef { - if v.Spec.IncomingAuth == nil { - return nil - } - return v.Spec.IncomingAuth.OIDCConfig -} - // GetProxyPort returns the proxy port for the VirtualMCPServer. -// This implements the OIDCConfigurable interface. // vMCP uses port 4483 by default. func (*VirtualMCPServer) GetProxyPort() int32 { return 4483 diff --git a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go index 7c914505c6..eb284d16c2 100644 --- a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -566,11 +566,6 @@ func (in *HeaderInjectionConfig) DeepCopy() *HeaderInjectionConfig { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IncomingAuthConfig) DeepCopyInto(out *IncomingAuthConfig) { *out = *in - if in.OIDCConfig != nil { - in, out := &in.OIDCConfig, &out.OIDCConfig - *out = new(OIDCConfigRef) - (*in).DeepCopyInto(*out) - } if in.OIDCConfigRef != nil { in, out := &in.OIDCConfigRef, &out.OIDCConfigRef *out = new(MCPOIDCConfigReference) diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go index 0317076175..06d95cc18c 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go @@ -1170,7 +1170,7 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { expectError: false, }, { - name: "OIDC with missing client secret", + name: "OIDC with missing client secret via MCPOIDCConfig", vmcp: &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, @@ -1179,29 +1179,33 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { Spec: mcpv1alpha1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{Group: testGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ - Type: "oidc", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://issuer.example.com", - Audience: "test-audience", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ - Name: "missing-secret", - Key: "client-secret", - }, + Type: "oidc", + OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, + }, + }, + }, + secrets: []client.Object{ + &mcpv1alpha1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: "https://issuer.example.com", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: "missing-secret", + Key: "client-secret", }, }, }, }, }, - secrets: []client.Object{}, expectAuthCondition: true, expectedAuthStatus: metav1.ConditionFalse, expectedAuthReason: mcpv1alpha1.ConditionReasonAuthInvalid, expectError: true, }, { - name: "OIDC with valid client secret", + name: "OIDC with valid client secret via MCPOIDCConfig", vmcp: &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, @@ -1210,22 +1214,25 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { Spec: mcpv1alpha1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{Group: testGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ - Type: "oidc", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://issuer.example.com", - Audience: "test-audience", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ - Name: "oidc-secret", - Key: "client-secret", - }, - }, - }, + Type: "oidc", + OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, }, }, }, secrets: []client.Object{ + &mcpv1alpha1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: "https://issuer.example.com", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: "oidc-secret", + Key: "client-secret", + }, + }, + }, + }, &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "oidc-secret", @@ -1242,7 +1249,7 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { expectError: false, }, { - name: "OIDC secret exists but missing required key", + name: "OIDC secret exists but missing required key via MCPOIDCConfig", vmcp: &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, @@ -1251,22 +1258,25 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { Spec: mcpv1alpha1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{Group: testGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ - Type: "oidc", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://issuer.example.com", - Audience: "test-audience", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ - Name: "oidc-secret", - Key: "client-secret", - }, - }, - }, + Type: "oidc", + OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, }, }, }, secrets: []client.Object{ + &mcpv1alpha1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: "https://issuer.example.com", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: "oidc-secret", + Key: "client-secret", + }, + }, + }, + }, &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "oidc-secret", diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go index 8cc58d86a1..f3ef83c58c 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go @@ -296,23 +296,17 @@ func (r *VirtualMCPServerReconciler) buildVolumesForVmcp( }) // Add OIDC CA bundle volume if configured - if vmcp.Spec.IncomingAuth != nil { - if vmcp.Spec.IncomingAuth.OIDCConfig != nil { - caVolumes, caMounts := ctrlutil.AddOIDCCABundleVolumes(vmcp.Spec.IncomingAuth.OIDCConfig) + if vmcp.Spec.IncomingAuth != nil && vmcp.Spec.IncomingAuth.OIDCConfigRef != nil { + oidcCfg, err := ctrlutil.GetOIDCConfigForServer( + ctx, r.Client, vmcp.Namespace, vmcp.Spec.IncomingAuth.OIDCConfigRef) + if err != nil { + return nil, nil, fmt.Errorf("failed to get MCPOIDCConfig %s for CA bundle: %w", + vmcp.Spec.IncomingAuth.OIDCConfigRef.Name, err) + } + if oidcCfg != nil { + caVolumes, caMounts := ctrlutil.AddOIDCConfigRefCABundleVolumes(oidcCfg) volumes = append(volumes, caVolumes...) volumeMounts = append(volumeMounts, caMounts...) - } else if vmcp.Spec.IncomingAuth.OIDCConfigRef != nil { - oidcCfg, err := ctrlutil.GetOIDCConfigForServer( - ctx, r.Client, vmcp.Namespace, vmcp.Spec.IncomingAuth.OIDCConfigRef) - if err != nil { - return nil, nil, fmt.Errorf("failed to get MCPOIDCConfig %s for CA bundle: %w", - vmcp.Spec.IncomingAuth.OIDCConfigRef.Name, err) - } - if oidcCfg != nil { - caVolumes, caMounts := ctrlutil.AddOIDCConfigRefCABundleVolumes(oidcCfg) - volumes = append(volumes, caVolumes...) - volumeMounts = append(volumeMounts, caMounts...) - } } } @@ -378,25 +372,7 @@ func (r *VirtualMCPServerReconciler) buildOIDCEnvVars( return env, nil } - // Legacy path: inline OIDCConfig client secret - if vmcp.Spec.IncomingAuth.OIDCConfig != nil && - vmcp.Spec.IncomingAuth.OIDCConfig.Inline != nil && - vmcp.Spec.IncomingAuth.OIDCConfig.Inline.ClientSecretRef != nil { - inline := vmcp.Spec.IncomingAuth.OIDCConfig.Inline - env = append(env, corev1.EnvVar{ - Name: "VMCP_OIDC_CLIENT_SECRET", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: inline.ClientSecretRef.Name, - }, - Key: inline.ClientSecretRef.Key, - }, - }, - }) - } - - // New path: MCPOIDCConfig inline client secret + // MCPOIDCConfig inline client secret if vmcp.Spec.IncomingAuth.OIDCConfigRef != nil { oidcCfg, err := ctrlutil.GetOIDCConfigForServer( ctx, r.Client, vmcp.Namespace, vmcp.Spec.IncomingAuth.OIDCConfigRef) @@ -833,7 +809,7 @@ func getVmcpImage() string { // at pod startup, providing faster feedback to users. // // Validated secrets include: -// - OIDC client secrets (IncomingAuth.OIDCConfig.Inline.ClientSecretRef) +// - OIDC client secrets (via MCPOIDCConfig inline ClientSecretRef) // - Service account credentials (OutgoingAuth.*.ServiceAccount.CredentialsRef) // // This follows the pattern from ctrlutil.GenerateOIDCClientSecretEnvVar() which validates secrets @@ -844,18 +820,6 @@ func (r *VirtualMCPServerReconciler) validateSecretReferences( ctx context.Context, vmcp *mcpv1alpha1.VirtualMCPServer, ) error { - // Validate OIDC client secret if configured (legacy inline path) - if vmcp.Spec.IncomingAuth != nil && - vmcp.Spec.IncomingAuth.OIDCConfig != nil && - vmcp.Spec.IncomingAuth.OIDCConfig.Inline != nil && - vmcp.Spec.IncomingAuth.OIDCConfig.Inline.ClientSecretRef != nil { - if err := r.validateSecretKeyRef(ctx, vmcp.Namespace, - vmcp.Spec.IncomingAuth.OIDCConfig.Inline.ClientSecretRef, - "OIDC client secret"); err != nil { - return err - } - } - // Validate MCPOIDCConfig inline client secret if configured if vmcp.Spec.IncomingAuth != nil && vmcp.Spec.IncomingAuth.OIDCConfigRef != nil { oidcCfg, err := ctrlutil.GetOIDCConfigForServer( diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go index 181ae6fcd0..0ffcee76c1 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go @@ -2104,15 +2104,8 @@ func TestEnsureVmcpConfigConfigMap_AuthServerIntegrationValidationError(t *testi Spec: mcpv1alpha1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{Group: "test-group"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ - Type: "oidc", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: incomingIssuer, - Audience: audience, - ClientID: clientID, - }, - }, + Type: "oidc", + OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: audience}, }, AuthServerConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ Issuer: authServerIssuer, @@ -2141,13 +2134,24 @@ func TestEnsureVmcpConfigConfigMap_AuthServerIntegrationValidationError(t *testi Spec: mcpv1alpha1.MCPGroupSpec{}, } + oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: incomingIssuer, + ClientID: clientID, + }, + }, + } + scheme := runtime.NewScheme() require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithObjects(testVmcp, mcpGroup). + WithObjects(testVmcp, mcpGroup, oidcConfig). Build() r := &VirtualMCPServerReconciler{ diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter.go b/cmd/thv-operator/pkg/vmcpconfig/converter.go index 3f08459de5..bfe08fdad7 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go @@ -192,8 +192,8 @@ func (c *Converter) convertIncomingAuth( return incoming, nil } -// resolveOIDCConfig resolves OIDC configuration from either an MCPOIDCConfig reference -// or legacy inline OIDCConfig. Returns nil when no OIDC config is present. +// resolveOIDCConfig resolves OIDC configuration from an MCPOIDCConfig reference. +// Returns nil when no OIDC config is present. // Fails closed: returns an error when OIDC is configured but resolution fails, // preventing deployment without authentication when OIDC is explicitly requested. func (c *Converter) resolveOIDCConfig( @@ -206,7 +206,7 @@ func (c *Converter) resolveOIDCConfig( ctxLogger := log.FromContext(ctx) - // New path: resolve from MCPOIDCConfig reference (preferred over legacy inline OIDCConfig) + // Resolve from MCPOIDCConfig reference if vmcp.Spec.IncomingAuth.OIDCConfigRef != nil { oidcCfg, err := controllerutil.GetOIDCConfigForServer( ctx, c.k8sClient, vmcp.Namespace, vmcp.Spec.IncomingAuth.OIDCConfigRef) @@ -228,70 +228,9 @@ func (c *Converter) resolveOIDCConfig( return mapResolvedOIDCToVmcpConfigFromRef(resolved, oidcCfg), nil } - // Legacy path: resolve from inline OIDCConfig - if vmcp.Spec.IncomingAuth.OIDCConfig != nil { - resolved, err := c.oidcResolver.Resolve(ctx, vmcp) - if err != nil { - ctxLogger.Error(err, "failed to resolve OIDC config", - "vmcp", vmcp.Name, - "namespace", vmcp.Namespace, - "oidcType", vmcp.Spec.IncomingAuth.OIDCConfig.Type) - return nil, fmt.Errorf("OIDC resolution failed for type %q: %w", - vmcp.Spec.IncomingAuth.OIDCConfig.Type, err) - } - return mapResolvedOIDCToVmcpConfig(resolved, vmcp.Spec.IncomingAuth.OIDCConfig), nil - } - return nil, nil } -// mapResolvedOIDCToVmcpConfig maps from oidc.OIDCConfig (resolved by the OIDC resolver) -// to vmcpconfig.OIDCConfig (used by the vmcp runtime). -// This keeps the vmcp config types separate from the operator's OIDC resolver types, -// maintaining clean architectural boundaries while enabling unified OIDC resolution. -func mapResolvedOIDCToVmcpConfig( - resolved *oidc.OIDCConfig, - oidcConfigRef *mcpv1alpha1.OIDCConfigRef, -) *vmcpconfig.OIDCConfig { - if resolved == nil { - return nil - } - - config := &vmcpconfig.OIDCConfig{ - Issuer: resolved.Issuer, - ClientID: resolved.ClientID, - Audience: resolved.Audience, - Resource: resolved.ResourceURL, - JWKSURL: resolved.JWKSURL, - IntrospectionURL: resolved.IntrospectionURL, - ProtectedResourceAllowPrivateIP: resolved.ProtectedResourceAllowPrivateIP, - JwksAllowPrivateIP: resolved.JWKSAllowPrivateIP, - InsecureAllowHTTP: resolved.InsecureAllowHTTP, - Scopes: resolved.Scopes, - } - - // Handle client secret - the deployment controller mounts secrets as environment variables - // We need to set ClientSecretEnv for all OIDC config types that may have a client secret - if oidcConfigRef != nil { - switch oidcConfigRef.Type { - case mcpv1alpha1.OIDCConfigTypeInline: - // Inline config: check if ClientSecretRef is set - if oidcConfigRef.Inline != nil && oidcConfigRef.Inline.ClientSecretRef != nil { - config.ClientSecretEnv = vmcpOIDCClientSecretEnvVar - } - case mcpv1alpha1.OIDCConfigTypeConfigMap: - // ConfigMap config: check if the resolved config has a client secret - // Note: Storing secrets in ConfigMaps is not recommended; use inline with SecretRef instead - if resolved.ClientSecret != "" { - config.ClientSecretEnv = vmcpOIDCClientSecretEnvVar - } - // OIDCConfigTypeKubernetes does not use client secrets (uses service account tokens) - } - } - - return config -} - // mapResolvedOIDCToVmcpConfigFromRef maps from oidc.OIDCConfig (resolved by the OIDC resolver) // to vmcpconfig.OIDCConfig when using an MCPOIDCConfig reference. // Client secret detection uses the MCPOIDCConfig's inline config rather than OIDCConfigRef. diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go index cb07eed0c7..88b7b22e35 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go @@ -30,11 +30,6 @@ import ( vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) -// Compile-time interface assertion to ensure VirtualMCPServer implements OIDCConfigurable. -// This catches interface drift at compile time rather than runtime. -// Placed here because api/v1alpha1 cannot import pkg/oidc (circular dependency). -var _ oidc.OIDCConfigurable = (*mcpv1alpha1.VirtualMCPServer)(nil) - // newNoOpMockResolver creates a mock resolver that returns (nil, nil) for all calls. // Use this in tests that don't care about OIDC configuration. func newNoOpMockResolver(t *testing.T) *oidcmocks.MockResolver { @@ -62,30 +57,64 @@ func newTestConverter(t *testing.T, resolver oidc.Resolver) *Converter { return converter } -// newTestVMCPServer creates a VirtualMCPServer with OIDC config for testing. -func newTestVMCPServer(oidcConfig *mcpv1alpha1.OIDCConfigRef) *mcpv1alpha1.VirtualMCPServer { +// newTestVMCPServer creates a VirtualMCPServer with an MCPOIDCConfigReference for testing. +func newTestVMCPServer(oidcConfigRef *mcpv1alpha1.MCPOIDCConfigReference) *mcpv1alpha1.VirtualMCPServer { return &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{Group: "test-group"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "oidc", OIDCConfig: oidcConfig}, + IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "oidc", OIDCConfigRef: oidcConfigRef}, + }, + } +} + +// newTestMCPOIDCConfig creates an MCPOIDCConfig resource for testing with the given spec type. +func newTestMCPOIDCConfig(specType mcpv1alpha1.MCPOIDCConfigSourceType) *mcpv1alpha1.MCPOIDCConfig { + return &mcpv1alpha1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: specType, + }, + } +} + +// newTestMCPOIDCConfigInline creates an MCPOIDCConfig resource with inline config for testing. +func newTestMCPOIDCConfigInline(inline *mcpv1alpha1.InlineOIDCSharedConfig) *mcpv1alpha1.MCPOIDCConfig { + return &mcpv1alpha1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Inline: inline, }, } } +// newTestConverterWithObjects creates a Converter with the given resolver and k8s objects. +func newTestConverterWithObjects(t *testing.T, resolver oidc.Resolver, objects ...client.Object) *Converter { + t.Helper() + k8sClient := newTestK8sClient(t, objects...) + converter, err := NewConverter(resolver, k8sClient) + require.NoError(t, err) + return converter +} + func TestConverter_OIDCResolution(t *testing.T) { t.Parallel() + const oidcConfigName = "test-oidc" + tests := []struct { - name string - oidcConfig *mcpv1alpha1.OIDCConfigRef - mockReturn *oidc.OIDCConfig - mockErr error - validate func(t *testing.T, config *vmcpconfig.Config, err error) + name string + oidcConfigRef *mcpv1alpha1.MCPOIDCConfigReference + oidcConfig *mcpv1alpha1.MCPOIDCConfig // MCPOIDCConfig object to add to fake client + mockReturn *oidc.OIDCConfig + mockErr error + validate func(t *testing.T, config *vmcpconfig.Config, err error) }{ { - name: "successful resolution maps all fields", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{Type: mcpv1alpha1.OIDCConfigTypeKubernetes}, + name: "successful resolution maps all fields", + oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "my-audience"}, + oidcConfig: newTestMCPOIDCConfig(mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount), mockReturn: &oidc.OIDCConfig{ Issuer: "https://issuer.example.com", Audience: "my-audience", ResourceURL: "https://resource.example.com", @@ -106,8 +135,9 @@ func TestConverter_OIDCResolution(t *testing.T) { }, }, { - name: "fields mapped independently - jwksAllowPrivateIP true, protectedResourceAllowPrivateIP false", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{Type: mcpv1alpha1.OIDCConfigTypeKubernetes}, + name: "fields mapped independently - jwksAllowPrivateIP true, protectedResourceAllowPrivateIP false", + oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "my-audience"}, + oidcConfig: newTestMCPOIDCConfig(mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount), mockReturn: &oidc.OIDCConfig{ Issuer: "https://issuer.example.com", Audience: "my-audience", JWKSAllowPrivateIP: true, ProtectedResourceAllowPrivateIP: false, @@ -121,9 +151,10 @@ func TestConverter_OIDCResolution(t *testing.T) { }, }, { - name: "resolution error returns error (fail-closed)", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{Type: mcpv1alpha1.OIDCConfigTypeConfigMap}, - mockErr: errors.New("configmap not found"), + name: "resolution error returns error (fail-closed)", + oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + oidcConfig: newTestMCPOIDCConfig(mcpv1alpha1.MCPOIDCConfigTypeInline), + mockErr: errors.New("configmap not found"), validate: func(t *testing.T, _ *vmcpconfig.Config, err error) { t.Helper() require.Error(t, err) @@ -131,9 +162,10 @@ func TestConverter_OIDCResolution(t *testing.T) { }, }, { - name: "nil resolved config results in nil OIDC", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{Type: mcpv1alpha1.OIDCConfigTypeInline}, - mockReturn: nil, + name: "nil resolved config results in nil OIDC", + oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + oidcConfig: newTestMCPOIDCConfig(mcpv1alpha1.MCPOIDCConfigTypeInline), + mockReturn: nil, validate: func(t *testing.T, config *vmcpconfig.Config, err error) { t.Helper() require.NoError(t, err) @@ -141,16 +173,15 @@ func TestConverter_OIDCResolution(t *testing.T) { }, }, { - name: "inline with client secret sets ClientSecretEnv", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ - Name: "oidc-secret", - Key: "client-secret", - }, + name: "inline with client secret sets ClientSecretEnv", + oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + oidcConfig: newTestMCPOIDCConfigInline(&mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: "https://issuer.example.com", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: "oidc-secret", + Key: "client-secret", }, - }, + }), mockReturn: &oidc.OIDCConfig{Issuer: "https://issuer.example.com"}, validate: func(t *testing.T, config *vmcpconfig.Config, err error) { t.Helper() @@ -159,22 +190,10 @@ func TestConverter_OIDCResolution(t *testing.T) { }, }, { - name: "configmap with client secret sets ClientSecretEnv", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{Name: "config"}, - }, - mockReturn: &oidc.OIDCConfig{Issuer: "https://issuer.example.com", ClientSecret: "secret"}, - validate: func(t *testing.T, config *vmcpconfig.Config, err error) { - t.Helper() - require.NoError(t, err) - assert.Equal(t, "VMCP_OIDC_CLIENT_SECRET", config.IncomingAuth.OIDC.ClientSecretEnv) - }, - }, - { - name: "kubernetes type does not set ClientSecretEnv", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{Type: mcpv1alpha1.OIDCConfigTypeKubernetes}, - mockReturn: &oidc.OIDCConfig{Issuer: "https://kubernetes.default.svc"}, + name: "non-inline type does not set ClientSecretEnv", + oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + oidcConfig: newTestMCPOIDCConfig(mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount), + mockReturn: &oidc.OIDCConfig{Issuer: "https://kubernetes.default.svc"}, validate: func(t *testing.T, config *vmcpconfig.Config, err error) { t.Helper() require.NoError(t, err) @@ -189,11 +208,13 @@ func TestConverter_OIDCResolution(t *testing.T) { ctrl := gomock.NewController(t) mockResolver := oidcmocks.NewMockResolver(ctrl) - mockResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(tt.mockReturn, tt.mockErr) + mockResolver.EXPECT().ResolveFromConfigRef( + gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), + ).Return(tt.mockReturn, tt.mockErr) - converter := newTestConverter(t, mockResolver) + converter := newTestConverterWithObjects(t, mockResolver, tt.oidcConfig) ctx := log.IntoContext(context.Background(), logr.Discard()) - config, _, err := converter.Convert(ctx, newTestVMCPServer(tt.oidcConfig), nil) + config, _, err := converter.Convert(ctx, newTestVMCPServer(tt.oidcConfigRef), nil) tt.validate(t, config, err) }) @@ -264,12 +285,16 @@ func TestConverter_CompositeToolsPassThrough(t *testing.T) { func TestConverter_IncomingAuthRequired(t *testing.T) { t.Parallel() + const oidcConfigName = "test-oidc" + tests := []struct { name string incomingAuth *mcpv1alpha1.IncomingAuthConfig + oidcConfig *mcpv1alpha1.MCPOIDCConfig // MCPOIDCConfig object to add to fake client expectedAuthType string expectedOIDCConfig *vmcpconfig.OIDCConfig expectNilAuth bool + mockReturn *oidc.OIDCConfig description string }{ { @@ -287,17 +312,19 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { description: "Should use anonymous auth when explicitly specified", }, { - name: "explicit oidc auth with inline config", + name: "explicit oidc auth via MCPOIDCConfigRef", incomingAuth: &mcpv1alpha1.IncomingAuthConfig{ - Type: "oidc", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - ClientID: "test-client", - Audience: "test-audience", - }, - }, + Type: "oidc", + OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + }, + oidcConfig: newTestMCPOIDCConfigInline(&mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: "https://example.com", + ClientID: "test-client", + }), + mockReturn: &oidc.OIDCConfig{ + Issuer: "https://example.com", + ClientID: "test-client", + Audience: "test-audience", }, expectedAuthType: "oidc", expectedOIDCConfig: &vmcpconfig.OIDCConfig{ @@ -305,21 +332,23 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { ClientID: "test-client", Audience: "test-audience", }, - description: "Should correctly convert OIDC auth config", + description: "Should correctly convert OIDC auth config via MCPOIDCConfigRef", }, { name: "oidc auth with scopes", incomingAuth: &mcpv1alpha1.IncomingAuthConfig{ - Type: "oidc", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://accounts.google.com", - ClientID: "google-client", - Audience: "google-audience", - Scopes: []string{"https://www.googleapis.com/auth/drive.readonly", "openid"}, - }, - }, + Type: "oidc", + OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "google-audience"}, + }, + oidcConfig: newTestMCPOIDCConfigInline(&mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: "https://accounts.google.com", + ClientID: "google-client", + }), + mockReturn: &oidc.OIDCConfig{ + Issuer: "https://accounts.google.com", + ClientID: "google-client", + Audience: "google-audience", + Scopes: []string{"https://www.googleapis.com/auth/drive.readonly", "openid"}, }, expectedAuthType: "oidc", expectedOIDCConfig: &vmcpconfig.OIDCConfig{ @@ -333,17 +362,21 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { { name: "oidc auth with jwksUrl and introspectionUrl", incomingAuth: &mcpv1alpha1.IncomingAuthConfig{ - Type: "oidc", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - ClientID: "test-client", - Audience: "test-audience", - JWKSURL: "https://auth.example.com/custom/jwks", - IntrospectionURL: "https://auth.example.com/custom/introspect", - }, - }, + Type: "oidc", + OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + }, + oidcConfig: newTestMCPOIDCConfigInline(&mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: "https://auth.example.com", + ClientID: "test-client", + JWKSURL: "https://auth.example.com/custom/jwks", + IntrospectionURL: "https://auth.example.com/custom/introspect", + }), + mockReturn: &oidc.OIDCConfig{ + Issuer: "https://auth.example.com", + ClientID: "test-client", + Audience: "test-audience", + JWKSURL: "https://auth.example.com/custom/jwks", + IntrospectionURL: "https://auth.example.com/custom/introspect", }, expectedAuthType: "oidc", expectedOIDCConfig: &vmcpconfig.OIDCConfig{ @@ -376,21 +409,24 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { ctrl := gomock.NewController(t) mockResolver := oidcmocks.NewMockResolver(ctrl) + // Build k8s client objects + var objects []client.Object + if tt.oidcConfig != nil { + objects = append(objects, tt.oidcConfig) + } + // Configure mock to return expected OIDC config - if tt.expectedOIDCConfig != nil { - mockResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&oidc.OIDCConfig{ - Issuer: tt.expectedOIDCConfig.Issuer, - ClientID: tt.expectedOIDCConfig.ClientID, - Audience: tt.expectedOIDCConfig.Audience, - JWKSURL: tt.expectedOIDCConfig.JWKSURL, - IntrospectionURL: tt.expectedOIDCConfig.IntrospectionURL, - Scopes: tt.expectedOIDCConfig.Scopes, - }, nil) + if tt.mockReturn != nil { + mockResolver.EXPECT().ResolveFromConfigRef( + gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), + ).Return(tt.mockReturn, nil) } else { - mockResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mockResolver.EXPECT().ResolveFromConfigRef( + gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), + ).Return(nil, nil).AnyTimes() } - converter := newTestConverter(t, mockResolver) + converter := newTestConverterWithObjects(t, mockResolver, objects...) ctx := log.IntoContext(context.Background(), logr.Discard()) config, _, err := converter.Convert(ctx, vmcpServer, nil) @@ -1684,13 +1720,18 @@ func TestConvert_AuthServerConfigIntegration(t *testing.T) { ctrl := gomock.NewController(t) mockResolver := oidcmocks.NewMockResolver(ctrl) - mockResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&oidc.OIDCConfig{ + mockResolver.EXPECT().ResolveFromConfigRef( + gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), + ).Return(&oidc.OIDCConfig{ Issuer: "https://incoming-issuer.example.com", Audience: "https://my-vmcp.example.com", ResourceURL: "https://resource.example.com", }, nil) - k8sClient := newTestK8sClient(t) + oidcCfg := newTestMCPOIDCConfigInline(&mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: "https://incoming-issuer.example.com", + }) + k8sClient := newTestK8sClient(t, oidcCfg) converter, err := NewConverter(mockResolver, k8sClient) require.NoError(t, err) @@ -1699,14 +1740,8 @@ func TestConvert_AuthServerConfigIntegration(t *testing.T) { Spec: mcpv1alpha1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{Group: "test-group"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ - Type: "oidc", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://incoming-issuer.example.com", - Audience: "https://my-vmcp.example.com", - }, - }, + Type: "oidc", + OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "https://my-vmcp.example.com"}, }, AuthServerConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ Issuer: "https://authserver.example.com", diff --git a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go index 6bb4651ff3..7b9da35888 100644 --- a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go @@ -11,6 +11,7 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/yaml" mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" @@ -155,6 +156,26 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration }, timeout, interval).Should(BeTrue()) }) + It("should produce a ConfigMap with OIDC config from the MCPOIDCConfig", func() { + configMapName := vmcpName + "-vmcp-config" + configMap := &corev1.ConfigMap{} + Eventually(func() error { + return k8sClient.Get(ctx, types.NamespacedName{ + Name: configMapName, + Namespace: namespace, + }, configMap) + }, timeout, interval).Should(Succeed()) + + Expect(configMap.Data).To(HaveKey("config.yaml")) + var config vmcpconfig.Config + Expect(yaml.Unmarshal([]byte(configMap.Data["config.yaml"]), &config)).To(Succeed()) + + Expect(config.IncomingAuth).NotTo(BeNil()) + Expect(config.IncomingAuth.OIDC).NotTo(BeNil(), "OIDC config from MCPOIDCConfig should be present in ConfigMap") + Expect(config.IncomingAuth.OIDC.Issuer).To(Equal("https://accounts.google.com")) + Expect(config.IncomingAuth.OIDC.Audience).To(Equal("test-vmcp-audience")) + }) + It("should track VirtualMCPServer in MCPOIDCConfig ReferencingWorkloads", func() { Eventually(func() bool { updated := &mcpv1alpha1.MCPOIDCConfig{} diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 813925b1be..3528c8cd2c 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -71,7 +71,7 @@ spec: AuthServerConfig configures an embedded OAuth authorization server. When set, the vMCP server acts as an OIDC issuer, drives users through upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the - IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfig + IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfigRef so that tokens it issues are accepted by the vMCP's incoming auth middleware. When nil, IncomingAuth uses an external IDP and behavior is unchanged. properties: @@ -1863,240 +1863,12 @@ spec: - message: inline must be set when type is 'inline', and must not be set otherwise rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' - oidcConfig: - description: |- - OIDCConfig defines OIDC authentication configuration. - Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead. - This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. - properties: - configMap: - description: |- - ConfigMap references a ConfigMap containing OIDC configuration - Only used when Type is "configmap" - properties: - caBundleRef: - description: |- - CABundleRef references a ConfigMap containing the CA certificate bundle. - When specified, ToolHive auto-mounts the ConfigMap and auto-computes ThvCABundlePath. - If the ConfigMap data contains an explicit thvCABundlePath key, it takes precedence. - properties: - configMapRef: - description: |- - ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or - its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - key: - default: oidc.json - description: Key is the key in the ConfigMap that contains - the OIDC configuration - type: string - name: - description: Name is the name of the ConfigMap - type: string - required: - - name - type: object - inline: - description: |- - Inline contains direct OIDC configuration - Only used when Type is "inline" - properties: - audience: - description: Audience is the expected audience for the - token - type: string - caBundleRef: - description: |- - CABundleRef references a ConfigMap containing the CA certificate bundle. - When specified, ToolHive auto-mounts the ConfigMap and auto-computes the CA bundle path. - properties: - configMapRef: - description: |- - ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or - its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - clientId: - description: ClientID is the OIDC client ID - type: string - clientSecretRef: - description: ClientSecretRef is a reference to a Kubernetes - Secret containing the client secret - properties: - key: - description: Key is the key within the secret - type: string - name: - description: Name is the name of the secret - type: string - required: - - key - - name - type: object - insecureAllowHTTP: - default: false - description: |- - InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing - WARNING: This is insecure and should NEVER be used in production - Only enable for local development, testing, or trusted internal networks - type: boolean - introspectionUrl: - description: IntrospectionURL is the URL for token introspection - endpoint - type: string - issuer: - description: Issuer is the OIDC issuer URL - type: string - jwksAllowPrivateIP: - default: false - description: |- - JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. - Use with caution - only enable for trusted internal IDPs. - Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP - is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). - type: boolean - jwksAuthTokenPath: - description: |- - JWKSAuthTokenPath is the path to file containing bearer token for JWKS/OIDC requests - The file must be mounted into the pod (e.g., via Secret volume) - type: string - jwksUrl: - description: JWKSURL is the URL to fetch the JWKS from - type: string - protectedResourceAllowPrivateIP: - default: false - description: |- - ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. - Use with caution - only enable for trusted internal IDPs or testing. - Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP - is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). - type: boolean - scopes: - description: |- - Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728) - If empty, defaults to ["openid"] - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - issuer - type: object - kubernetes: - description: |- - Kubernetes configures OIDC for Kubernetes service account token validation - Only used when Type is "kubernetes" - properties: - audience: - default: toolhive - description: Audience is the expected audience for the - token - type: string - introspectionUrl: - description: |- - IntrospectionURL is the URL for token introspection endpoint - If empty, OIDC discovery will be used to automatically determine the introspection URL - type: string - issuer: - default: https://kubernetes.default.svc - description: Issuer is the OIDC issuer URL - type: string - jwksUrl: - description: |- - JWKSURL is the URL to fetch the JWKS from - If empty, OIDC discovery will be used to automatically determine the JWKS URL - type: string - namespace: - description: |- - Namespace is the namespace of the service account - If empty, uses the MCPServer's namespace - type: string - serviceAccount: - description: |- - ServiceAccount is the name of the service account to validate tokens for - If empty, uses the pod's service account - type: string - useClusterAuth: - description: |- - UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token - When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification - and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication - Defaults to true if not specified - type: boolean - type: object - resourceUrl: - description: |- - ResourceURL is the explicit resource URL for OAuth discovery endpoint (RFC 9728) - If not specified, defaults to the in-cluster Kubernetes service URL - type: string - type: - default: kubernetes - description: Type is the type of OIDC configuration - enum: - - kubernetes - - configMap - - inline - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: configMap must be set when type is 'configMap', and - must not be set otherwise - rule: 'self.type == ''configMap'' ? has(self.configMap) : !has(self.configMap)' - - message: inline must be set when type is 'inline', and must - not be set otherwise - rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' - - message: kubernetes must not be set when type is not 'kubernetes' - rule: 'self.type != ''kubernetes'' ? !has(self.kubernetes) : - true' oidcConfigRef: description: |- OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. The referenced MCPOIDCConfig must exist in the same namespace as this VirtualMCPServer. Per-server overrides (audience, scopes) are specified here; shared provider config - lives in the MCPOIDCConfig resource. Mutually exclusive with oidcConfig. + lives in the MCPOIDCConfig resource. properties: audience: description: |- @@ -2132,13 +1904,9 @@ spec: - type type: object x-kubernetes-validations: - - message: spec.incomingAuth.oidcConfig or oidcConfigRef is required - when type is oidc - rule: 'self.type == ''oidc'' ? (has(self.oidcConfig) || has(self.oidcConfigRef)) - : true' - - message: oidcConfig and oidcConfigRef are mutually exclusive; use - oidcConfigRef to reference a shared MCPOIDCConfig - rule: '!(has(self.oidcConfig) && has(self.oidcConfigRef))' + - message: spec.incomingAuth.oidcConfigRef is required when type is + oidc + rule: 'self.type == ''oidc'' ? has(self.oidcConfigRef) : true' outgoingAuth: description: |- OutgoingAuth configures authentication from Virtual MCP to backend MCPServers. diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 8b00009e41..90b3b46b4b 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -74,7 +74,7 @@ spec: AuthServerConfig configures an embedded OAuth authorization server. When set, the vMCP server acts as an OIDC issuer, drives users through upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the - IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfig + IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfigRef so that tokens it issues are accepted by the vMCP's incoming auth middleware. When nil, IncomingAuth uses an external IDP and behavior is unchanged. properties: @@ -1866,240 +1866,12 @@ spec: - message: inline must be set when type is 'inline', and must not be set otherwise rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' - oidcConfig: - description: |- - OIDCConfig defines OIDC authentication configuration. - Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead. - This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. - properties: - configMap: - description: |- - ConfigMap references a ConfigMap containing OIDC configuration - Only used when Type is "configmap" - properties: - caBundleRef: - description: |- - CABundleRef references a ConfigMap containing the CA certificate bundle. - When specified, ToolHive auto-mounts the ConfigMap and auto-computes ThvCABundlePath. - If the ConfigMap data contains an explicit thvCABundlePath key, it takes precedence. - properties: - configMapRef: - description: |- - ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or - its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - key: - default: oidc.json - description: Key is the key in the ConfigMap that contains - the OIDC configuration - type: string - name: - description: Name is the name of the ConfigMap - type: string - required: - - name - type: object - inline: - description: |- - Inline contains direct OIDC configuration - Only used when Type is "inline" - properties: - audience: - description: Audience is the expected audience for the - token - type: string - caBundleRef: - description: |- - CABundleRef references a ConfigMap containing the CA certificate bundle. - When specified, ToolHive auto-mounts the ConfigMap and auto-computes the CA bundle path. - properties: - configMapRef: - description: |- - ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or - its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - clientId: - description: ClientID is the OIDC client ID - type: string - clientSecretRef: - description: ClientSecretRef is a reference to a Kubernetes - Secret containing the client secret - properties: - key: - description: Key is the key within the secret - type: string - name: - description: Name is the name of the secret - type: string - required: - - key - - name - type: object - insecureAllowHTTP: - default: false - description: |- - InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing - WARNING: This is insecure and should NEVER be used in production - Only enable for local development, testing, or trusted internal networks - type: boolean - introspectionUrl: - description: IntrospectionURL is the URL for token introspection - endpoint - type: string - issuer: - description: Issuer is the OIDC issuer URL - type: string - jwksAllowPrivateIP: - default: false - description: |- - JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. - Use with caution - only enable for trusted internal IDPs. - Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP - is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). - type: boolean - jwksAuthTokenPath: - description: |- - JWKSAuthTokenPath is the path to file containing bearer token for JWKS/OIDC requests - The file must be mounted into the pod (e.g., via Secret volume) - type: string - jwksUrl: - description: JWKSURL is the URL to fetch the JWKS from - type: string - protectedResourceAllowPrivateIP: - default: false - description: |- - ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. - Use with caution - only enable for trusted internal IDPs or testing. - Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP - is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). - type: boolean - scopes: - description: |- - Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728) - If empty, defaults to ["openid"] - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - issuer - type: object - kubernetes: - description: |- - Kubernetes configures OIDC for Kubernetes service account token validation - Only used when Type is "kubernetes" - properties: - audience: - default: toolhive - description: Audience is the expected audience for the - token - type: string - introspectionUrl: - description: |- - IntrospectionURL is the URL for token introspection endpoint - If empty, OIDC discovery will be used to automatically determine the introspection URL - type: string - issuer: - default: https://kubernetes.default.svc - description: Issuer is the OIDC issuer URL - type: string - jwksUrl: - description: |- - JWKSURL is the URL to fetch the JWKS from - If empty, OIDC discovery will be used to automatically determine the JWKS URL - type: string - namespace: - description: |- - Namespace is the namespace of the service account - If empty, uses the MCPServer's namespace - type: string - serviceAccount: - description: |- - ServiceAccount is the name of the service account to validate tokens for - If empty, uses the pod's service account - type: string - useClusterAuth: - description: |- - UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token - When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification - and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication - Defaults to true if not specified - type: boolean - type: object - resourceUrl: - description: |- - ResourceURL is the explicit resource URL for OAuth discovery endpoint (RFC 9728) - If not specified, defaults to the in-cluster Kubernetes service URL - type: string - type: - default: kubernetes - description: Type is the type of OIDC configuration - enum: - - kubernetes - - configMap - - inline - type: string - required: - - type - type: object - x-kubernetes-validations: - - message: configMap must be set when type is 'configMap', and - must not be set otherwise - rule: 'self.type == ''configMap'' ? has(self.configMap) : !has(self.configMap)' - - message: inline must be set when type is 'inline', and must - not be set otherwise - rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' - - message: kubernetes must not be set when type is not 'kubernetes' - rule: 'self.type != ''kubernetes'' ? !has(self.kubernetes) : - true' oidcConfigRef: description: |- OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. The referenced MCPOIDCConfig must exist in the same namespace as this VirtualMCPServer. Per-server overrides (audience, scopes) are specified here; shared provider config - lives in the MCPOIDCConfig resource. Mutually exclusive with oidcConfig. + lives in the MCPOIDCConfig resource. properties: audience: description: |- @@ -2135,13 +1907,9 @@ spec: - type type: object x-kubernetes-validations: - - message: spec.incomingAuth.oidcConfig or oidcConfigRef is required - when type is oidc - rule: 'self.type == ''oidc'' ? (has(self.oidcConfig) || has(self.oidcConfigRef)) - : true' - - message: oidcConfig and oidcConfigRef are mutually exclusive; use - oidcConfigRef to reference a shared MCPOIDCConfig - rule: '!(has(self.oidcConfig) && has(self.oidcConfigRef))' + - message: spec.incomingAuth.oidcConfigRef is required when type is + oidc + rule: 'self.type == ''oidc'' ? has(self.oidcConfigRef) : true' outgoingAuth: description: |- OutgoingAuth configures authentication from Virtual MCP to backend MCPServers. diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index e844fa4601..22b3ab779f 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -1284,8 +1284,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `type` _string_ | Type defines the authentication type: anonymous or oidc
When no authentication is required, explicitly set this to "anonymous" | | Enum: [anonymous oidc]
Required: \{\}
| -| `oidcConfig` _[api.v1alpha1.OIDCConfigRef](#apiv1alpha1oidcconfigref)_ | OIDCConfig defines OIDC authentication configuration.
Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead.
This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. | | Optional: \{\}
| -| `oidcConfigRef` _[api.v1alpha1.MCPOIDCConfigReference](#apiv1alpha1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.
The referenced MCPOIDCConfig must exist in the same namespace as this VirtualMCPServer.
Per-server overrides (audience, scopes) are specified here; shared provider config
lives in the MCPOIDCConfig resource. Mutually exclusive with oidcConfig. | | Optional: \{\}
| +| `oidcConfigRef` _[api.v1alpha1.MCPOIDCConfigReference](#apiv1alpha1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.
The referenced MCPOIDCConfig must exist in the same namespace as this VirtualMCPServer.
Per-server overrides (audience, scopes) are specified here; shared provider config
lives in the MCPOIDCConfig resource. | | Optional: \{\}
| | `authzConfig` _[api.v1alpha1.AuthzConfigRef](#apiv1alpha1authzconfigref)_ | AuthzConfig defines authorization policy configuration
Reuses MCPServer authz patterns | | Optional: \{\}
| @@ -2475,24 +2474,6 @@ _Appears in:_ | `tokenResponseMapping` _[api.v1alpha1.TokenResponseMapping](#apiv1alpha1tokenresponsemapping)_ | TokenResponseMapping configures custom field extraction from non-standard token responses.
Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths
instead of returning them at the top level. When set, ToolHive performs the token
exchange HTTP call directly and extracts fields using the configured dot-notation paths.
If nil, standard OAuth 2.0 token response parsing is used. | | Optional: \{\}
| -#### api.v1alpha1.OIDCConfigRef - - - -OIDCConfigRef defines a reference to OIDC configuration - - - -_Appears in:_ -- [api.v1alpha1.IncomingAuthConfig](#apiv1alpha1incomingauthconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `type` _string_ | Type is the type of OIDC configuration | kubernetes | Enum: [kubernetes configMap inline]
| -| `resourceUrl` _string_ | ResourceURL is the explicit resource URL for OAuth discovery endpoint (RFC 9728)
If not specified, defaults to the in-cluster Kubernetes service URL | | Optional: \{\}
| -| `kubernetes` _[api.v1alpha1.KubernetesOIDCConfig](#apiv1alpha1kubernetesoidcconfig)_ | Kubernetes configures OIDC for Kubernetes service account token validation
Only used when Type is "kubernetes" | | Optional: \{\}
| -| `configMap` _[api.v1alpha1.ConfigMapOIDCRef](#apiv1alpha1configmapoidcref)_ | ConfigMap references a ConfigMap containing OIDC configuration
Only used when Type is "configmap" | | Optional: \{\}
| -| `inline` _[api.v1alpha1.InlineOIDCConfig](#apiv1alpha1inlineoidcconfig)_ | Inline contains direct OIDC configuration
Only used when Type is "inline" | | Optional: \{\}
| #### api.v1alpha1.OIDCUpstreamConfig @@ -3395,7 +3376,7 @@ _Appears in:_ | `config` _[vmcp.config.Config](#vmcpconfigconfig)_ | Config is the Virtual MCP server configuration.
The audit config from here is also supported, but not required.
Note: config.groupRef is deprecated in favor of spec.groupRef. | | Type: object
Optional: \{\}
| | `telemetryConfigRef` _[api.v1alpha1.MCPTelemetryConfigReference](#apiv1alpha1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer.
Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\}
| | `embeddingServerRef` _[api.v1alpha1.EmbeddingServerRef](#apiv1alpha1embeddingserverref)_ | EmbeddingServerRef references an existing EmbeddingServer resource by name.
When the optimizer is enabled, this field is required to point to a ready EmbeddingServer
that provides embedding capabilities.
The referenced EmbeddingServer must exist in the same namespace and be ready. | | Optional: \{\}
| -| `authServerConfig` _[api.v1alpha1.EmbeddedAuthServerConfig](#apiv1alpha1embeddedauthserverconfig)_ | AuthServerConfig configures an embedded OAuth authorization server.
When set, the vMCP server acts as an OIDC issuer, drives users through
upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the
IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfig
so that tokens it issues are accepted by the vMCP's incoming auth middleware.
When nil, IncomingAuth uses an external IDP and behavior is unchanged. | | Optional: \{\}
| +| `authServerConfig` _[api.v1alpha1.EmbeddedAuthServerConfig](#apiv1alpha1embeddedauthserverconfig)_ | AuthServerConfig configures an embedded OAuth authorization server.
When set, the vMCP server acts as an OIDC issuer, drives users through
upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the
IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfigRef
so that tokens it issues are accepted by the vMCP's incoming auth middleware.
When nil, IncomingAuth uses an external IDP and behavior is unchanged. | | Optional: \{\}
| | `replicas` _integer_ | Replicas is the desired number of vMCP pod replicas.
VirtualMCPServer creates a single Deployment for the vMCP aggregator process,
so there is only one replicas field (unlike MCPServer which has separate
Replicas and BackendReplicas for its two Deployments).
When nil, the operator does not set Deployment.Spec.Replicas, leaving replica
management to an HPA or other external controller. | | Minimum: 0
Optional: \{\}
| | `sessionStorage` _[api.v1alpha1.SessionStorageConfig](#apiv1alpha1sessionstorageconfig)_ | SessionStorage configures session storage for stateful horizontal scaling.
When nil, no session storage is configured. | | Optional: \{\}
| diff --git a/examples/operator/virtual-mcps/vmcp_inline_incoming_auth.yaml b/examples/operator/virtual-mcps/vmcp_inline_incoming_auth.yaml index a4209a8d13..959068f2b1 100644 --- a/examples/operator/virtual-mcps/vmcp_inline_incoming_auth.yaml +++ b/examples/operator/virtual-mcps/vmcp_inline_incoming_auth.yaml @@ -141,19 +141,12 @@ spec: unhealthyThreshold: 3 partialFailureMode: best_effort - # Incoming authentication - inline OIDC configuration + # Incoming authentication via shared MCPOIDCConfig incomingAuth: type: oidc - oidcConfig: - type: inline - resourceUrl: http://inline-auth-vmcp.default.svc.cluster.local:4483 - inline: - issuer: https://auth.example.com - clientId: vmcp-client - audience: vmcp-api - clientSecretRef: - name: vmcp-oidc-client-secret - key: clientSecret + oidcConfigRef: + name: inline-auth-oidc-config + audience: vmcp-api authzConfig: type: inline inline: diff --git a/examples/operator/virtual-mcps/vmcp_production_full.yaml b/examples/operator/virtual-mcps/vmcp_production_full.yaml index 6df0d4d8b2..243b560591 100644 --- a/examples/operator/virtual-mcps/vmcp_production_full.yaml +++ b/examples/operator/virtual-mcps/vmcp_production_full.yaml @@ -166,17 +166,9 @@ spec: # Using OIDC for secure authentication incomingAuth: type: oidc - oidcConfig: - type: inline - resourceUrl: http://production-vmcp.production.svc.cluster.local:4483 - inline: - issuer: https://auth.example.com - clientId: production-vmcp-client - audience: vmcp-production - # Reference to Kubernetes Secret containing client secret - clientSecretRef: - name: oidc-client-secret - key: clientSecret + oidcConfigRef: + name: production-oidc-config + audience: vmcp-production authzConfig: type: inline inline: diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/basic/chainsaw-test.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/basic/chainsaw-test.yaml index bf3dcf2236..29041b0e4e 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/basic/chainsaw-test.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/basic/chainsaw-test.yaml @@ -16,6 +16,19 @@ spec: spec: description: "Test group for VirtualMCPServer" + - name: create-mcpoidcconfig + try: + - apply: + resource: + apiVersion: toolhive.stacklok.dev/v1alpha1 + kind: MCPOIDCConfig + metadata: + name: test-vmcp-oidc + spec: + type: inline + inline: + issuer: https://auth.example.com + - name: create-virtualmcpserver try: - apply: @@ -32,11 +45,9 @@ spec: conflictResolution: prefix incomingAuth: type: oidc - oidcConfig: - type: inline - inline: - issuer: https://auth.example.com - audience: virtual-mcp + oidcConfigRef: + name: test-vmcp-oidc + audience: virtual-mcp outgoingAuth: source: discovered - assert: diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/controller-chainsaw-test.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/controller-chainsaw-test.yaml index d312fc2103..d5a53825e6 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/controller-chainsaw-test.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/controller-chainsaw-test.yaml @@ -245,6 +245,10 @@ spec: - assert: file: oidc-client-secret.yaml + - apply: + file: vmcp-oidc-config.yaml + - assert: + file: vmcp-oidc-config.yaml - apply: file: vmcp-with-oidc.yaml - assert: diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-oidc-config.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-oidc-config.yaml new file mode 100644 index 0000000000..e9847eee8c --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-oidc-config.yaml @@ -0,0 +1,13 @@ +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPOIDCConfig +metadata: + name: test-vmcp-oidc-config + namespace: toolhive-system +spec: + type: inline + inline: + issuer: https://auth.example.com + clientID: test-client-id + clientSecretRef: + name: test-oidc-client-secret + key: clientSecret diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-with-oidc.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-with-oidc.yaml index b19153b3be..b732de713e 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-with-oidc.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-with-oidc.yaml @@ -13,16 +13,8 @@ spec: prefixFormat: "{workload}_" incomingAuth: type: oidc - oidcConfig: - type: inline - resourceUrl: http://test-vmcp-oidc.toolhive-system.svc.cluster.local:4483 - inline: - issuer: https://auth.example.com - clientId: test-client-id - audience: vmcp-test - # Using ClientSecretRef to reference a Kubernetes Secret (secure approach) - clientSecretRef: - name: test-oidc-client-secret - key: clientSecret + oidcConfigRef: + name: test-vmcp-oidc-config + audience: vmcp-test outgoingAuth: source: discovered diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go index ac0c315bfe..09b2048d7d 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go @@ -692,6 +692,26 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: } Expect(k8sClient.Create(ctx, oidcClientSecret)).To(Succeed()) + By("Creating MCPOIDCConfig for OIDC authentication") + oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "discovery-oidc-config", + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: fmt.Sprintf("http://mock-oidc-server.%s.svc.cluster.local", testNamespace), + ClientID: "vmcp-client", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: oidcClientSecretName, Key: "client-secret"}, + InsecureAllowHTTP: true, + JWKSAllowPrivateIP: true, + ProtectedResourceAllowPrivateIP: true, + }, + }, + } + Expect(k8sClient.Create(ctx, oidcConfig)).To(Succeed()) + By("Creating MCPExternalAuthConfig for token exchange") // Use the Kubernetes service URL for our mock auth server tokenURL := fmt.Sprintf("http://mock-auth-server.%s.svc.cluster.local/token", testNamespace) @@ -842,20 +862,9 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: // vMCP will validate tokens and then exchange them for backend-specific tokens IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "oidc", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: fmt.Sprintf("http://mock-oidc-server.%s.svc.cluster.local", testNamespace), - ClientID: "vmcp-client", - Audience: "vmcp-audience", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ - Name: oidcClientSecretName, - Key: "client-secret", - }, - InsecureAllowHTTP: true, - JWKSAllowPrivateIP: true, - ProtectedResourceAllowPrivateIP: true, - }, + OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + Name: "discovery-oidc-config", + Audience: "vmcp-audience", }, }, // DISCOVERED MODE: vMCP will discover outgoing auth from backend MCPServers diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_config_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_config_test.go index 84b3b0a1ea..c477044e20 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_config_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_config_test.go @@ -4,6 +4,7 @@ package virtualmcp import ( + "fmt" "time" . "github.com/onsi/ginkgo/v2" @@ -39,6 +40,18 @@ var _ = Describe("VirtualMCPServer AuthServerConfig Validation", Ordered, func() const vmcpName = "auth-server-valid-vmcp" BeforeAll(func() { + By("Creating MCPOIDCConfig for auth server test") + Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "authserver-oidc-config", Namespace: testNamespace}, + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: "http://localhost:9090", + InsecureAllowHTTP: true, + }, + }, + })).To(Succeed()) + By("Creating VirtualMCPServer with valid inline AuthServerConfig") vmcp := &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ @@ -48,11 +61,10 @@ var _ = Describe("VirtualMCPServer AuthServerConfig Validation", Ordered, func() Spec: mcpv1alpha1.VirtualMCPServerSpec{ IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "oidc", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "http://localhost:9090", - }, + OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + Name: "authserver-oidc-config", + // Audience must match the auth server's allowed audience (the vMCP service URL) + Audience: fmt.Sprintf("http://%s.%s.svc.cluster.local:4483", vmcpName, testNamespace), }, }, Config: vmcpconfig.Config{Group: mcpGroupName}, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go index 9ab740b550..4efe841927 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go @@ -449,8 +449,22 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { }, })).To(gomega.Succeed()) - // ---- Deploy VirtualMCPServer with OIDC incoming auth ---- + // ---- Create MCPOIDCConfig for OIDC auth ---- + ginkgo.By("Creating MCPOIDCConfig for OIDC incoming auth") + gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "session-oidc-config", Namespace: defaultNamespace}, + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: oidcIssuer, + InsecureAllowHTTP: true, + JWKSAllowPrivateIP: true, + ProtectedResourceAllowPrivateIP: true, + }, + }, + })).To(gomega.Succeed()) + // ---- Deploy VirtualMCPServer with OIDC incoming auth ---- ginkgo.By("Creating VirtualMCPServer with OIDC incoming auth") gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, @@ -460,15 +474,9 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { }, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "oidc", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: oidcIssuer, - Audience: "vmcp-audience", - InsecureAllowHTTP: true, - JWKSAllowPrivateIP: true, - ProtectedResourceAllowPrivateIP: true, - }, + OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + Name: "session-oidc-config", + Audience: "vmcp-audience", }, }, ServiceType: "NodePort", From cafa9638a805e53c6913188a26adc6cca5b9e94e Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:48:01 +0100 Subject: [PATCH 041/117] Remove config.groupRef fallback and external_auth_config_ref enum (#4834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove config.groupRef fallback and external_auth_config_ref enum Remove the deprecated config.groupRef fallback from ResolveGroupName() and Validate() on VirtualMCPServer. The converter now explicitly sets config.Group from spec.groupRef so downstream runtime code still works. Remove the deprecated DeprecatedBackendAuthTypeExternalAuthConfigRef constant ("external_auth_config_ref") from the kubebuilder enum, the converter's deprecation handling, and the Validate() switch case. Update all test fixtures to use spec.GroupRef instead of Config.Group. Co-Authored-By: Claude Opus 4.6 (1M context) * Regenerate CRD manifests and API docs Co-Authored-By: Claude Opus 4.6 (1M context) * Update Config.Group comment to reflect current usage The field is no longer deprecated — it is actively used by the standalone CLI and populated by the operator converter from spec.groupRef. Co-Authored-By: Claude Opus 4.6 (1M context) * Add GroupRef to E2E test fixtures The E2E tests were creating VirtualMCPServer objects with only Config.Group and no spec.groupRef, causing validation failures now that the config.groupRef fallback is removed. Co-Authored-By: Claude Opus 4.6 (1M context) * Address review findings: require GroupRef, add Validate test, clean fixtures Make GroupRef +kubebuilder:validation:Required on VirtualMCPServer (F6), matching the IncomingAuth pattern. The API server now rejects resources missing groupRef at admission time. Add TestVirtualMCPServer_Validate_RequiresGroupRef covering nil and empty-name rejection paths (F2). Fix stale DefaultValues and NamespaceIsolation fixtures to use GroupRef instead of Config.Group (F4). Remove redundant Config.Group from all controller test fixtures that already have GroupRef, and replace direct Spec.Config.Group reads with ResolveGroupName() (F5). Co-Authored-By: Claude Opus 4.6 (1M context) * Add GroupRef to remaining E2E test fixtures 22 E2E test files were creating VirtualMCPServer objects directly (not via helpers) without GroupRef. Now that the CRD schema requires groupRef at admission time, these all fail. Co-Authored-By: Claude Opus 4.6 (1M context) * Add GroupRef to operator integration test fixtures 21 integration test fixtures in test-integration/ were creating VirtualMCPServer objects without spec.groupRef, causing admission failures now that the field is required. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../api/v1alpha1/virtualmcpserver_types.go | 30 ++---- .../v1alpha1/virtualmcpserver_types_test.go | 92 ++++++++++++------- .../virtualmcpserver_controller_test.go | 83 ++++++++--------- .../virtualmcpserver_deployment_test.go | 16 ++-- .../virtualmcpserver_externalauth_test.go | 22 ++--- ...cpserver_podtemplatespec_reconcile_test.go | 9 +- .../virtualmcpserver_vmcpconfig_test.go | 54 ++++++----- .../virtualmcpserver_watch_test.go | 64 ++++++------- cmd/thv-operator/pkg/vmcpconfig/converter.go | 17 +--- .../pkg/vmcpconfig/converter_test.go | 38 ++++---- ...onfig_virtualmcpserver_integration_test.go | 15 ++- ...rtualmcpserver_compositetool_watch_test.go | 5 +- ...lmcpserver_elicitation_integration_test.go | 3 + ...irtualmcpserver_externalauth_watch_test.go | 3 +- ...server_podtemplatespec_integration_test.go | 9 +- ...tualmcpserver_replicas_integration_test.go | 6 +- ...irtualmcpserver_sessionstorage_cel_test.go | 1 + ...server_telemetryconfig_integration_test.go | 9 +- ...olhive.stacklok.dev_virtualmcpservers.yaml | 13 +-- ...olhive.stacklok.dev_virtualmcpservers.yaml | 13 +-- docs/operator/crd-api.md | 10 +- docs/operator/virtualmcpserver-api.md | 10 +- pkg/vmcp/config/config.go | 4 +- .../virtualmcp_aggregation_filtering_test.go | 1 + .../virtualmcp_aggregation_overrides_test.go | 1 + .../virtualmcp_auth_discovery_test.go | 3 +- .../virtualmcp_authserver_config_test.go | 3 +- .../virtualmcp_circuit_breaker_test.go | 1 + ...irtualmcp_composite_defaultresults_test.go | 1 + .../virtualmcp_composite_hidden_tools_test.go | 1 + .../virtualmcp_composite_parallel_test.go | 1 + .../virtualmcp_composite_referenced_test.go | 1 + .../virtualmcp_composite_sequential_test.go | 1 + .../virtualmcp_composite_validation_test.go | 1 + .../virtualmcp_conflict_resolution_test.go | 1 + .../virtualmcp_discovered_mode_test.go | 1 + .../virtualmcp_excludeall_global_test.go | 1 + .../virtualmcp_external_auth_test.go | 10 +- ...rtualmcp_optimizer_circuit_breaker_test.go | 1 + .../virtualmcp_optimizer_composite_test.go | 1 + .../virtualmcp_optimizer_multibackend_test.go | 1 + .../virtualmcp/virtualmcp_optimizer_test.go | 1 + .../virtualmcp_redis_session_test.go | 9 +- .../virtualmcp_session_management_test.go | 4 +- .../virtualmcp/virtualmcp_telemetry_test.go | 1 + .../virtualmcp/virtualmcp_toolconfig_test.go | 2 + .../virtualmcp_yardstick_base_test.go | 1 + .../virtualmcpserver_scaling_test.go | 5 +- 48 files changed, 313 insertions(+), 267 deletions(-) diff --git a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go index 98c3b192f5..50509d1d1b 100644 --- a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go +++ b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go @@ -63,13 +63,11 @@ type VirtualMCPServerSpec struct { // GroupRef references the MCPGroup that defines backend workloads. // The referenced MCPGroup must exist in the same namespace. - // This field takes precedence over config.groupRef and should be preferred. - // +optional - GroupRef *MCPGroupRef `json:"groupRef,omitempty"` + // +kubebuilder:validation:Required + GroupRef *MCPGroupRef `json:"groupRef"` // Config is the Virtual MCP server configuration. // The audit config from here is also supported, but not required. - // Note: config.groupRef is deprecated in favor of spec.groupRef. // +optional Config config.Config `json:"config,omitempty"` @@ -167,12 +165,12 @@ type OutgoingAuthConfig struct { // BackendAuthConfig defines authentication configuration for a backend MCPServer type BackendAuthConfig struct { // Type defines the authentication type - // +kubebuilder:validation:Enum=discovered;externalAuthConfigRef;external_auth_config_ref + // +kubebuilder:validation:Enum=discovered;externalAuthConfigRef // +kubebuilder:validation:Required Type string `json:"type"` // ExternalAuthConfigRef references an MCPExternalAuthConfig resource - // Only used when Type is "externalAuthConfigRef" (or deprecated "external_auth_config_ref") + // Only used when Type is "externalAuthConfigRef" // +optional ExternalAuthConfigRef *ExternalAuthConfigRef `json:"externalAuthConfigRef,omitempty"` } @@ -367,10 +365,6 @@ const ( // BackendAuthTypeExternalAuthConfigRef references an MCPExternalAuthConfig resource BackendAuthTypeExternalAuthConfigRef = "externalAuthConfigRef" - - // DeprecatedBackendAuthTypeExternalAuthConfigRef is the old snake_case value. - // Deprecated: Use BackendAuthTypeExternalAuthConfigRef ("externalAuthConfigRef") instead. - DeprecatedBackendAuthTypeExternalAuthConfigRef = "external_auth_config_ref" ) // Workflow step types @@ -428,22 +422,18 @@ func (*VirtualMCPServer) GetProxyPort() int32 { return 4483 } -// ResolveGroupName returns the effective group name, preferring spec.groupRef -// over the deprecated config.groupRef string. +// ResolveGroupName returns the group name from spec.groupRef. func (r *VirtualMCPServer) ResolveGroupName() string { - if name := r.Spec.GroupRef.GetName(); name != "" { - return name - } - return r.Spec.Config.Group + return r.Spec.GroupRef.GetName() } // Validate performs validation for VirtualMCPServer // This method is called by the controller during reconciliation func (r *VirtualMCPServer) Validate() error { - // Validate Group is set — prefer spec.groupRef, fall back to config.groupRef + // Validate Group is set — spec.groupRef.name is required // Note: CEL cannot validate embedded types from other packages - if r.Spec.GroupRef.GetName() == "" && r.Spec.Config.Group == "" { - return fmt.Errorf("either spec.groupRef.name or config.groupRef must be set") + if r.Spec.GroupRef.GetName() == "" { + return fmt.Errorf("spec.groupRef.name is required") } // Note: IncomingAuth validation is handled by kubebuilder markers and CEL rules @@ -522,7 +512,7 @@ func (*VirtualMCPServer) validateBackendAuth(backendName string, auth BackendAut // Validate type-specific configurations switch auth.Type { - case BackendAuthTypeExternalAuthConfigRef, DeprecatedBackendAuthTypeExternalAuthConfigRef: + case BackendAuthTypeExternalAuthConfigRef: if auth.ExternalAuthConfigRef == nil { return fmt.Errorf( "spec.outgoingAuth.backends[%s].externalAuthConfigRef is required when type is externalAuthConfigRef", diff --git a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go index c39ccc6d64..5d0e033b0d 100644 --- a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go +++ b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go @@ -146,8 +146,8 @@ func TestVirtualMCPServerDefaultValues(t *testing.T) { Namespace: "default", }, Spec: VirtualMCPServerSpec{ + GroupRef: &MCPGroupRef{Name: "test-group"}, Config: config.Config{ - Group: "test-group", Aggregation: &config.AggregationConfig{ ConflictResolution: "", // Should default to "prefix" }, @@ -174,7 +174,7 @@ func TestVirtualMCPServerNamespaceIsolation(t *testing.T) { Namespace: "team-a", }, Spec: VirtualMCPServerSpec{ - Config: config.Config{Group: "backend-group"}, // Must be in team-a namespace + GroupRef: &MCPGroupRef{Name: "backend-group"}, }, } @@ -185,7 +185,7 @@ func TestVirtualMCPServerNamespaceIsolation(t *testing.T) { Namespace: "team-b", }, Spec: VirtualMCPServerSpec{ - Config: config.Config{Group: "backend-group"}, // Different group in team-b namespace + GroupRef: &MCPGroupRef{Name: "backend-group"}, }, } @@ -195,8 +195,8 @@ func TestVirtualMCPServerNamespaceIsolation(t *testing.T) { assert.NotEqual(t, vmcpTeamA.Namespace, vmcpTeamB.Namespace) // Group names can be the same but refer to different groups in different namespaces - assert.Equal(t, "backend-group", vmcpTeamA.Spec.Config.Group) - assert.Equal(t, "backend-group", vmcpTeamB.Spec.Config.Group) + assert.Equal(t, "backend-group", vmcpTeamA.ResolveGroupName()) + assert.Equal(t, "backend-group", vmcpTeamB.ResolveGroupName()) } func TestConflictResolutionStrategies(t *testing.T) { @@ -238,8 +238,8 @@ func TestConflictResolutionStrategies(t *testing.T) { vmcpServer := &VirtualMCPServer{ Spec: VirtualMCPServerSpec{ + GroupRef: &MCPGroupRef{Name: "test-group"}, Config: config.Config{ - Group: "test-group", Aggregation: &config.AggregationConfig{ ConflictResolution: tt.strategy, ConflictResolutionConfig: tt.configValue, @@ -293,7 +293,7 @@ func TestBackendAuthConfigTypes(t *testing.T) { vmcp := &VirtualMCPServer{ Spec: VirtualMCPServerSpec{ - Config: config.Config{Group: "test-group"}, + GroupRef: &MCPGroupRef{Name: "test-group"}, OutgoingAuth: &OutgoingAuthConfig{ Backends: map[string]BackendAuthConfig{ "test-backend": tt.authConfig, @@ -358,8 +358,8 @@ func TestCompositeToolStepDependencies(t *testing.T) { server := &VirtualMCPServer{ Spec: VirtualMCPServerSpec{ + GroupRef: &MCPGroupRef{Name: "test-group"}, Config: config.Config{ - Group: "test-group", CompositeTools: []config.CompositeToolConfig{ { Name: "test-workflow", @@ -398,7 +398,7 @@ func TestValidateEmbeddingServer(t *testing.T) { name: "ref_without_optimizer_auto_populates_defaults", server: &VirtualMCPServer{ Spec: VirtualMCPServerSpec{ - Config: config.Config{Group: "test-group"}, + GroupRef: &MCPGroupRef{Name: "test-group"}, EmbeddingServerRef: &EmbeddingServerRef{ Name: "my-embedding", }, @@ -410,8 +410,8 @@ func TestValidateEmbeddingServer(t *testing.T) { name: "ref_with_optimizer_keeps_existing", server: &VirtualMCPServer{ Spec: VirtualMCPServerSpec{ + GroupRef: &MCPGroupRef{Name: "test-group"}, Config: config.Config{ - Group: "test-group", Optimizer: &config.OptimizerConfig{}, }, EmbeddingServerRef: &EmbeddingServerRef{ @@ -425,8 +425,8 @@ func TestValidateEmbeddingServer(t *testing.T) { name: "optimizer_without_ref_or_service_errors", server: &VirtualMCPServer{ Spec: VirtualMCPServerSpec{ + GroupRef: &MCPGroupRef{Name: "test-group"}, Config: config.Config{ - Group: "test-group", Optimizer: &config.OptimizerConfig{}, }, }, @@ -438,7 +438,7 @@ func TestValidateEmbeddingServer(t *testing.T) { name: "empty_ref_name_errors", server: &VirtualMCPServer{ Spec: VirtualMCPServerSpec{ - Config: config.Config{Group: "test-group"}, + GroupRef: &MCPGroupRef{Name: "test-group"}, EmbeddingServerRef: &EmbeddingServerRef{Name: ""}, }, }, @@ -449,7 +449,7 @@ func TestValidateEmbeddingServer(t *testing.T) { name: "no_ref_no_optimizer_succeeds", server: &VirtualMCPServer{ Spec: VirtualMCPServerSpec{ - Config: config.Config{Group: "test-group"}, + GroupRef: &MCPGroupRef{Name: "test-group"}, }, }, expectOptimizer: false, @@ -556,37 +556,68 @@ func TestMCPGroupRef_GetName(t *testing.T) { } } -func TestVirtualMCPServer_ResolveGroupName(t *testing.T) { +func TestVirtualMCPServer_Validate_RequiresGroupRef(t *testing.T) { t.Parallel() tests := []struct { - name string - groupRef *MCPGroupRef - cfgGroup string - want string + name string + groupRef *MCPGroupRef + expectErr bool + errMsg string }{ { - name: "spec.groupRef takes precedence over config.groupRef", - groupRef: &MCPGroupRef{Name: "from-spec"}, - cfgGroup: "from-config", - want: "from-spec", + name: "valid with groupRef set", + groupRef: &MCPGroupRef{Name: "my-group"}, + expectErr: false, }, { - name: "falls back to config.groupRef when spec.groupRef is nil", - groupRef: nil, - cfgGroup: "from-config", - want: "from-config", + name: "rejected when groupRef is nil", + groupRef: nil, + expectErr: true, + errMsg: "spec.groupRef.name is required", + }, + { + name: "rejected when groupRef name is empty", + groupRef: &MCPGroupRef{Name: ""}, + expectErr: true, + errMsg: "spec.groupRef.name is required", }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + vmcp := &VirtualMCPServer{ + Spec: VirtualMCPServerSpec{ + GroupRef: tt.groupRef, + }, + } + err := vmcp.Validate() + if tt.expectErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestVirtualMCPServer_ResolveGroupName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + groupRef *MCPGroupRef + want string + }{ { - name: "only spec.groupRef set", + name: "returns spec.groupRef name", groupRef: &MCPGroupRef{Name: "from-spec"}, - cfgGroup: "", want: "from-spec", }, { - name: "neither set returns empty", + name: "returns empty when spec.groupRef is nil", groupRef: nil, - cfgGroup: "", want: "", }, } @@ -596,7 +627,6 @@ func TestVirtualMCPServer_ResolveGroupName(t *testing.T) { vmcp := &VirtualMCPServer{ Spec: VirtualMCPServerSpec{ GroupRef: tt.groupRef, - Config: config.Config{Group: tt.cfgGroup}, }, } assert.Equal(t, tt.want, vmcp.ResolveGroupName()) diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go index 06d95cc18c..2aa6d3cb6c 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go @@ -66,7 +66,7 @@ func TestVirtualMCPServerValidateGroupRef(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, }, mcpGroup: &mcpv1alpha1.MCPGroup{ @@ -112,7 +112,7 @@ func TestVirtualMCPServerValidateGroupRef(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "missing-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "missing-group"}, }, }, expectError: true, @@ -127,7 +127,7 @@ func TestVirtualMCPServerValidateGroupRef(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "pending-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "pending-group"}, }, }, mcpGroup: &mcpv1alpha1.MCPGroup{ @@ -219,7 +219,7 @@ func TestVirtualMCPServerEnsureRBACResources(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } @@ -298,7 +298,7 @@ func TestVirtualMCPServerEnsureRBACResources_Update(t *testing.T) { UID: "test-uid", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, } @@ -381,7 +381,7 @@ func TestVirtualMCPServerEnsureRBACResources_Idempotency(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, } @@ -444,7 +444,7 @@ func TestVirtualMCPServerEnsureRBACResources_InlineMode(t *testing.T) { UID: "test-uid", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "inline", }, @@ -514,7 +514,7 @@ func TestVirtualMCPServerEnsureRBACResources_DiscoveredMode(t *testing.T) { UID: "test-uid", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", }, @@ -582,7 +582,7 @@ func TestVirtualMCPServerEnsureRBACResources_CustomServiceAccount(t *testing.T) UID: "test-uid", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, ServiceAccount: &customSA, }, } @@ -641,7 +641,7 @@ func TestVirtualMCPServerEnsureDeployment(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } @@ -724,7 +724,7 @@ func TestVirtualMCPServerEnsureService(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } @@ -803,7 +803,7 @@ func TestVirtualMCPServerServiceType(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, ServiceType: tt.serviceType, }, } @@ -834,7 +834,7 @@ func TestVirtualMCPServerServiceNeedsUpdate(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, ServiceType: "ClusterIP", }, } @@ -1157,7 +1157,7 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, @@ -1177,7 +1177,7 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, @@ -1212,7 +1212,7 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, @@ -1256,7 +1256,7 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, @@ -1394,7 +1394,7 @@ func TestVirtualMCPServerApplyStatusUpdates(t *testing.T) { Generation: 1, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } }, @@ -1417,7 +1417,7 @@ func TestVirtualMCPServerApplyStatusUpdates(t *testing.T) { Generation: 1, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } }, @@ -1437,7 +1437,7 @@ func TestVirtualMCPServerApplyStatusUpdates(t *testing.T) { Generation: 1, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } }, @@ -1521,7 +1521,7 @@ func TestVirtualMCPServerApplyStatusUpdates_ResourceNotFound(t *testing.T) { Generation: 1, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } @@ -1563,7 +1563,7 @@ func TestVirtualMCPServerEnsureAllResources_Errors(t *testing.T) { Generation: 1, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } }, @@ -1636,7 +1636,7 @@ func TestVirtualMCPServerContainerNeedsUpdate(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } @@ -1812,6 +1812,7 @@ func TestVirtualMCPServerContainerNeedsUpdate(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, Config: vmcpconfig.Config{ Group: testGroupName, Operational: &vmcpconfig.OperationalConfig{ @@ -2119,7 +2120,7 @@ func TestVirtualMCPServerDeploymentNeedsUpdate(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } @@ -2291,7 +2292,7 @@ func TestVirtualMCPServerReconcile_HappyPath(t *testing.T) { Generation: 1, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } @@ -2424,7 +2425,7 @@ func TestVirtualMCPServerReconcile_ValidateGroupRefError(t *testing.T) { Generation: 1, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "nonexistent-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "nonexistent-group"}, }, } @@ -2480,7 +2481,7 @@ func TestVirtualMCPServerReconcile_GroupNotReady(t *testing.T) { Generation: 1, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } @@ -2574,7 +2575,7 @@ func TestVirtualMCPServerEnsureDeployment_ConfigMapNotFound(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } @@ -2610,7 +2611,7 @@ func TestVirtualMCPServerEnsureDeployment_CreateDeployment(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } @@ -2667,7 +2668,7 @@ func TestVirtualMCPServerEnsureDeployment_UpdateDeployment(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } @@ -2750,7 +2751,7 @@ func TestVirtualMCPServerEnsureDeployment_NoUpdateNeeded(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } @@ -2836,7 +2837,7 @@ func TestVirtualMCPServerEnsureService_CreateService(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } @@ -2878,7 +2879,7 @@ func TestVirtualMCPServerEnsureService_UpdateService(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, ServiceType: "LoadBalancer", }, } @@ -2940,7 +2941,7 @@ func TestVirtualMCPServerEnsureService_NoUpdateNeeded(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, } @@ -3002,7 +3003,7 @@ func TestVirtualMCPServerValidateEmbeddingServerRef(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, }, }, expectError: false, @@ -3015,7 +3016,7 @@ func TestVirtualMCPServerValidateEmbeddingServerRef(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ Name: "shared-embedding", }, @@ -3041,7 +3042,7 @@ func TestVirtualMCPServerValidateEmbeddingServerRef(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ Name: "missing-embedding", }, @@ -3059,7 +3060,7 @@ func TestVirtualMCPServerValidateEmbeddingServerRef(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ Name: "pending-embedding", }, @@ -3085,7 +3086,7 @@ func TestVirtualMCPServerValidateEmbeddingServerRef(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ Name: "no-replicas-embedding", }, @@ -3171,7 +3172,7 @@ func TestVirtualMCPServerEnsureDeployment_ReplicaSync_SpecDriven(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, Replicas: &specReplicas, }, } @@ -3250,7 +3251,7 @@ func TestVirtualMCPServerEnsureDeployment_ReplicaSync_NilPassthrough(t *testing. Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, Replicas: nil, // HPA manages replicas }, } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go index 89db8ecd03..fb4729f679 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go @@ -46,7 +46,7 @@ func TestDeploymentForVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, } @@ -104,7 +104,7 @@ func TestDeploymentForVirtualMCPServer_WithRedisPassword(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, SessionStorage: &mcpv1alpha1.SessionStorageConfig{ Provider: mcpv1alpha1.SessionStorageProviderRedis, Address: "redis:6379", @@ -157,7 +157,7 @@ func TestBuildContainerArgsForVmcp(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, wantArgs: []string{"serve", "--config=/etc/vmcp-config/config.yaml", "--host=0.0.0.0", "--port=4483"}, @@ -170,8 +170,8 @@ func TestBuildContainerArgsForVmcp(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", Operational: &vmcpconfig.OperationalConfig{ LogLevel: "debug", }, @@ -204,7 +204,7 @@ func TestBuildVolumesForVmcp(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, } @@ -234,7 +234,7 @@ func TestBuildEnvVarsForVmcp(t *testing.T) { Namespace: "test-namespace", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, } @@ -409,7 +409,7 @@ func TestServiceForVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, } @@ -449,7 +449,7 @@ func TestServiceForVirtualMCPServerSessionAffinityNone(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, SessionAffinity: string(corev1.ServiceAffinityNone), }, } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go index eac97e6e23..372251b982 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go @@ -231,7 +231,7 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", }, @@ -302,7 +302,7 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", Backends: map[string]mcpv1alpha1.BackendAuthConfig{ @@ -412,7 +412,7 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "inline", Backends: map[string]mcpv1alpha1.BackendAuthConfig{ @@ -476,7 +476,7 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", Default: &mcpv1alpha1.BackendAuthConfig{ @@ -518,7 +518,7 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "inline", Backends: map[string]mcpv1alpha1.BackendAuthConfig{ @@ -566,7 +566,7 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", }, @@ -616,7 +616,7 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, // No OutgoingAuth specified }, }, @@ -634,7 +634,7 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", Default: &mcpv1alpha1.BackendAuthConfig{ @@ -671,7 +671,7 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", Backends: map[string]mcpv1alpha1.BackendAuthConfig{ @@ -1140,7 +1140,7 @@ func TestBuildOutgoingAuthConfig_SubjectProviderInjection(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", // Default references an MCPExternalAuthConfig (the only supported form @@ -1226,7 +1226,7 @@ func TestBuildOutgoingAuthConfig_InlineBackendSubjectProviderInjection(t *testin Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", // Inline Backends override — the path exercised by this test. diff --git a/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go b/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go index 502bacacc7..3b90731626 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go @@ -19,7 +19,6 @@ import ( mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/pkg/vmcp/workloads" ) @@ -64,7 +63,7 @@ func TestVirtualMCPServerPodTemplateSpecDeterministic(t *testing.T) { Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: groupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, PodTemplateSpec: podTemplateSpecToRawExtension(t, podTemplate), }, } @@ -138,7 +137,7 @@ func TestVirtualMCPServerPodTemplateSpecPreservesContainer(t *testing.T) { Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: groupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, PodTemplateSpec: &runtime.RawExtension{ Raw: []byte(`{"spec":{"nodeSelector":{"disktype":"ssd"}}}`), }, @@ -273,7 +272,7 @@ func TestVirtualMCPServerPodTemplateSpecNeedsUpdate(t *testing.T) { Namespace: testPodTemplateNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testPodTemplateGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testPodTemplateGroupName}, PodTemplateSpec: tt.newPodTemplateSpec, }, } @@ -316,7 +315,7 @@ func TestVirtualMCPServerPodTemplateSpecResourceOverride(t *testing.T) { Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: groupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, PodTemplateSpec: &runtime.RawExtension{ Raw: []byte(`{"spec":{"containers":[{"name":"vmcp","resources":{"requests":{"cpu":"200m","memory":"256Mi"},"limits":{"cpu":"1","memory":"1Gi"}}}]}}`), }, diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go index 0ffcee76c1..3d168d57b2 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go @@ -71,7 +71,7 @@ func TestCreateVmcpConfigFromVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, expectedName: "test-vmcp", @@ -150,7 +150,7 @@ func TestConvertOutgoingAuth(t *testing.T) { vmcpServer := &mcpv1alpha1.VirtualMCPServer{ Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: tt.outgoingAuth, }, } @@ -212,7 +212,7 @@ func TestConvertBackendAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Default: tt.authConfig, }, @@ -333,8 +333,8 @@ func TestConvertAggregation(t *testing.T) { vmcpServer := &mcpv1alpha1.VirtualMCPServer{ Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", Aggregation: tt.aggregation, }, }, @@ -426,8 +426,8 @@ func TestConvertCompositeTools(t *testing.T) { vmcpServer := &mcpv1alpha1.VirtualMCPServer{ Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeTools: tt.compositeTools, }, }, @@ -459,7 +459,7 @@ func TestEnsureVmcpConfigConfigMap(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, } @@ -489,7 +489,7 @@ func TestEnsureVmcpConfigConfigMap(t *testing.T) { // Fetch workload names (matching production behavior) ctx := context.Background() workloadDiscoverer := workloads.NewK8SDiscovererWithClient(fakeClient, testVmcp.Namespace) - workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, testVmcp.Spec.Config.Group) + workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, testVmcp.ResolveGroupName()) require.NoError(t, err, "should successfully list workloads in group") // Create a status collector (we don't validate status in this test) @@ -1012,8 +1012,8 @@ func TestYAMLMarshalingDeterminism(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", // Aggregation with tool overrides (map) Aggregation: &vmcpconfig.AggregationConfig{ ConflictResolution: vmcp.ConflictStrategyPrefix, @@ -1163,8 +1163,8 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_EndToEnd(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "test-composite-tool"}, }, @@ -1189,7 +1189,7 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_EndToEnd(t *testing.T) { // Fetch workload names (matching production behavior) workloadDiscoverer := workloads.NewK8SDiscovererWithClient(fakeClient, vmcpServer.Namespace) - workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.Spec.Config.Group) + workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.ResolveGroupName()) require.NoError(t, err, "should successfully list workloads in group") // Test the ensureVmcpConfigConfigMap function @@ -1277,8 +1277,8 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_MergeInlineAndReferenced(t Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeTools: []vmcpconfig.CompositeToolConfig{ { Name: "inline-tool", @@ -1316,7 +1316,7 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_MergeInlineAndReferenced(t // Fetch workload names (matching production behavior) workloadDiscoverer := workloads.NewK8SDiscovererWithClient(fakeClient, vmcpServer.Namespace) - workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.Spec.Config.Group) + workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.ResolveGroupName()) require.NoError(t, err, "should successfully list workloads in group") // Test the ensureVmcpConfigConfigMap function @@ -1374,8 +1374,8 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_NotFound(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "non-existent-tool"}, }, @@ -1400,7 +1400,7 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_NotFound(t *testing.T) { // Fetch workload names (matching production behavior) workloadDiscoverer := workloads.NewK8SDiscovererWithClient(fakeClient, vmcpServer.Namespace) - workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.Spec.Config.Group) + workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.ResolveGroupName()) require.NoError(t, err, "should successfully list workloads in group") // Test should fail with not found error @@ -1437,7 +1437,7 @@ func TestConfigMapContent_DynamicMode(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, @@ -1459,7 +1459,7 @@ func TestConfigMapContent_DynamicMode(t *testing.T) { // Discover workloads workloadDiscoverer := workloads.NewK8SDiscovererWithClient(fakeClient, vmcpServer.Namespace) - workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.Spec.Config.Group) + workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.ResolveGroupName()) require.NoError(t, err) // Create ConfigMap @@ -1538,7 +1538,7 @@ func TestConfigMapContent_StaticMode_InlineOverrides(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, @@ -1566,7 +1566,7 @@ func TestConfigMapContent_StaticMode_InlineOverrides(t *testing.T) { // Discover workloads workloadDiscoverer := workloads.NewK8SDiscovererWithClient(fakeClient, vmcpServer.Namespace) - workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.Spec.Config.Group) + workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.ResolveGroupName()) require.NoError(t, err) // Create ConfigMap @@ -1658,7 +1658,7 @@ func TestConfigMapContent_StaticModeWithDiscovery(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, @@ -1681,7 +1681,7 @@ func TestConfigMapContent_StaticModeWithDiscovery(t *testing.T) { // Discover workloads workloadDiscoverer := workloads.NewK8SDiscovererWithClient(fakeClient, vmcpServer.Namespace) - workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.Spec.Config.Group) + workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.ResolveGroupName()) require.NoError(t, err) require.NotEmpty(t, workloadNames, "should have discovered the MCPServer") @@ -1835,8 +1835,8 @@ func TestOptimizerEmbeddingServiceURL(t *testing.T) { Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroup}, Config: vmcpconfig.Config{ - Group: testGroup, Optimizer: &vmcpconfig.OptimizerConfig{}, }, EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ @@ -1856,10 +1856,8 @@ func TestOptimizerEmbeddingServiceURL(t *testing.T) { Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{ - Group: testGroup, - // No Optimizer — validation auto-populates it when ref is set - }, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroup}, + // No Optimizer — validation auto-populates it when ref is set EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ Name: "shared-embedding", }, @@ -2038,7 +2036,7 @@ func TestConfigMapContent_SessionStorage(t *testing.T) { vmcpServer := &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp-session", Namespace: testNamespace}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testGroup}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroup}, SessionStorage: tt.sessionStorage, }, } @@ -2102,7 +2100,7 @@ func TestEnsureVmcpConfigConfigMap_AuthServerIntegrationValidationError(t *testi Generation: 3, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: audience}, @@ -2161,7 +2159,7 @@ func TestEnsureVmcpConfigConfigMap_AuthServerIntegrationValidationError(t *testi ctx := context.Background() workloadDiscoverer := workloads.NewK8SDiscovererWithClient(fakeClient, testVmcp.Namespace) - workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, testVmcp.Spec.Config.Group) + workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, testVmcp.ResolveGroupName()) require.NoError(t, err) // Use a mock StatusManager so we can verify the exact conditions set on failure. diff --git a/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go b/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go index 782bfb1a34..a73330772b 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go @@ -55,7 +55,7 @@ func TestMapMCPGroupToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -77,7 +77,7 @@ func TestMapMCPGroupToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, { @@ -86,7 +86,7 @@ func TestMapMCPGroupToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -108,7 +108,7 @@ func TestMapMCPGroupToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "other-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, }, }, }, @@ -130,7 +130,7 @@ func TestMapMCPGroupToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, { @@ -139,7 +139,7 @@ func TestMapMCPGroupToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "other-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, }, }, }, @@ -257,7 +257,7 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -290,7 +290,7 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -323,7 +323,7 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "other-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, }, }, }, @@ -365,7 +365,7 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "group-1"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-1"}, }, }, { @@ -374,7 +374,7 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "group-2"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-2"}, }, }, { @@ -383,7 +383,7 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "group-3"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-3"}, }, }, }, @@ -507,7 +507,7 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -540,7 +540,7 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -573,7 +573,7 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "other-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, }, }, }, @@ -615,7 +615,7 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "group-1"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-1"}, }, }, { @@ -624,7 +624,7 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "group-2"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-2"}, }, }, { @@ -633,7 +633,7 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "group-3"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-3"}, }, }, }, @@ -877,7 +877,7 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", }, @@ -924,7 +924,7 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", }, @@ -971,7 +971,7 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", }, @@ -1018,7 +1018,7 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", }, @@ -1509,7 +1509,7 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", }, @@ -1548,7 +1548,7 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", }, @@ -1587,7 +1587,7 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "nonexistent-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "nonexistent-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", }, @@ -1604,7 +1604,7 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", }, @@ -1664,7 +1664,7 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", }, @@ -1703,7 +1703,7 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ Source: "discovered", }, @@ -1818,7 +1818,7 @@ func TestMapEmbeddingServerToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{Name: "shared-embedding"}, }, }, @@ -1841,7 +1841,7 @@ func TestMapEmbeddingServerToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{Name: "shared-embedding"}, }, }, @@ -1851,7 +1851,7 @@ func TestMapEmbeddingServerToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{Name: "shared-embedding"}, }, }, @@ -1874,7 +1874,7 @@ func TestMapEmbeddingServerToVirtualMCPServer(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{Name: "other-embedding"}, }, }, diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter.go b/cmd/thv-operator/pkg/vmcpconfig/converter.go index bfe08fdad7..be35dd1d5f 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go @@ -88,6 +88,9 @@ func (c *Converter) Convert( // Override name with the CR name (authoritative source) config.Name = vmcp.Name + // Set group from spec.groupRef (authoritative source for operator) + config.Group = vmcp.ResolveGroupName() + // Convert IncomingAuth - required field, no defaults if vmcp.Spec.IncomingAuth != nil { incomingAuth, err := c.convertIncomingAuth(ctx, vmcp) @@ -428,18 +431,8 @@ func (c *Converter) convertBackendAuthConfig( }, nil } - // Handle deprecated snake_case value - if crdConfig.Type == mcpv1alpha1.DeprecatedBackendAuthTypeExternalAuthConfigRef { - log.FromContext(ctx).Info( - "backend auth type \"external_auth_config_ref\" is deprecated,"+ - " use \"externalAuthConfigRef\" instead", - "backend", backendName, "vmcp", vmcp.Name, - ) - } - - // If type is "externalAuthConfigRef" (or deprecated "external_auth_config_ref"), resolve the MCPExternalAuthConfig - if crdConfig.Type == mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef || - crdConfig.Type == mcpv1alpha1.DeprecatedBackendAuthTypeExternalAuthConfigRef { + // If type is "externalAuthConfigRef", resolve the MCPExternalAuthConfig + if crdConfig.Type == mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef { if crdConfig.ExternalAuthConfigRef == nil { return nil, fmt.Errorf("backend %s: externalAuthConfigRef type requires externalAuthConfigRef field", backendName) } diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go index 88b7b22e35..af3120f259 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go @@ -62,7 +62,7 @@ func newTestVMCPServer(oidcConfigRef *mcpv1alpha1.MCPOIDCConfigReference) *mcpv1 return &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "oidc", OIDCConfigRef: oidcConfigRef}, }, } @@ -233,8 +233,8 @@ func TestConverter_CompositeToolsPassThrough(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeTools: []vmcpconfig.CompositeToolConfig{ { Name: "test-composite-tool", @@ -400,7 +400,7 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, IncomingAuth: tt.incomingAuth, }, } @@ -482,8 +482,8 @@ func TestConverter_CompositeToolRefs(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "referenced-tool"}, }, @@ -530,8 +530,8 @@ func TestConverter_CompositeToolRefs(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeTools: []vmcpconfig.CompositeToolConfig{ { Name: "inline-tool", @@ -593,8 +593,8 @@ func TestConverter_CompositeToolRefs(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "non-existent-tool"}, }, @@ -613,8 +613,8 @@ func TestConverter_CompositeToolRefs(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeTools: []vmcpconfig.CompositeToolConfig{ { Name: "duplicate-tool", @@ -666,7 +666,7 @@ func TestConverter_CompositeToolRefs(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, compositeDefs: []*mcpv1alpha1.VirtualMCPCompositeToolDefinition{}, @@ -682,8 +682,8 @@ func TestConverter_CompositeToolRefs(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "tool1"}, {Name: "tool2"}, @@ -751,8 +751,8 @@ func TestConverter_CompositeToolRefs(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "referenced-tool"}, }, @@ -927,8 +927,8 @@ func TestConverter_CompositeToolDefinitionFieldsPreserved(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "comprehensive-tool"}, }, @@ -1387,8 +1387,8 @@ func TestConvert_MCPToolConfigFailClosed(t *testing.T) { vmcp: &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", Aggregation: &vmcpconfig.AggregationConfig{ Tools: []*vmcpconfig.WorkloadToolConfig{{ Workload: "backend1", @@ -1407,8 +1407,8 @@ func TestConvert_MCPToolConfigFailClosed(t *testing.T) { vmcp: &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", Aggregation: &vmcpconfig.AggregationConfig{ Tools: []*vmcpconfig.WorkloadToolConfig{{ Workload: "backend1", @@ -1426,7 +1426,7 @@ func TestConvert_MCPToolConfigFailClosed(t *testing.T) { vmcp: &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, }, }, existingConfig: nil, @@ -1474,11 +1474,11 @@ func TestConverter_InlineTelemetryIgnored(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, Config: vmcpconfig.Config{ - Group: "test-group", Telemetry: &telemetry.Config{ Endpoint: "otlp-collector:4317", ServiceName: "should-be-ignored", @@ -1506,11 +1506,11 @@ func TestConverter_TelemetryNil(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, Config: vmcpconfig.Config{ - Group: "test-group", Telemetry: nil, // No telemetry config }, }, @@ -1582,8 +1582,8 @@ func TestConverter_SessionStorage(t *testing.T) { Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", SessionStorage: tt.inlineConfig, }, SessionStorage: tt.sessionStorage, @@ -1738,7 +1738,7 @@ func TestConvert_AuthServerConfigIntegration(t *testing.T) { vmcp := &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "https://my-vmcp.example.com"}, @@ -1818,8 +1818,8 @@ func TestConverter_TelemetryConfigRef(t *testing.T) { vmcp := &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, - Config: vmcpconfig.Config{Group: "test-group"}, TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ Name: "shared-telemetry", ServiceName: "custom-svc", diff --git a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go index 7b9da35888..8037603da1 100644 --- a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go @@ -102,7 +102,8 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: groupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, + Config: vmcpconfig.Config{Group: groupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ @@ -268,7 +269,8 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: groupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, + Config: vmcpconfig.Config{Group: groupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ @@ -403,7 +405,8 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: groupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, + Config: vmcpconfig.Config{Group: groupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ @@ -528,7 +531,8 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: groupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, + Config: vmcpconfig.Config{Group: groupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ @@ -667,7 +671,8 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: groupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, + Config: vmcpconfig.Config{Group: groupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_compositetool_watch_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_compositetool_watch_test.go index 994ae71f12..5a9cf22d49 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_compositetool_watch_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_compositetool_watch_test.go @@ -72,6 +72,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, CompositeToolRefs: []vmcpconfig.CompositeToolRef{ @@ -277,6 +278,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, CompositeToolRefs: []vmcpconfig.CompositeToolRef{ @@ -399,7 +401,8 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Config: vmcpconfig.Config{Group: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_elicitation_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_elicitation_integration_test.go index 29218fbdbe..7865ae8204 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_elicitation_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_elicitation_integration_test.go @@ -133,6 +133,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, CompositeToolRefs: []vmcpconfig.CompositeToolRef{ @@ -336,6 +337,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, CompositeToolRefs: []vmcpconfig.CompositeToolRef{ @@ -522,6 +524,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, CompositeToolRefs: []vmcpconfig.CompositeToolRef{ diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go index ef925d39dd..58c7c60f0b 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go @@ -108,7 +108,8 @@ var _ = Describe("VirtualMCPServer ExternalAuthConfig Watch Integration Tests", Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Config: vmcpconfig.Config{Group: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_podtemplatespec_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_podtemplatespec_integration_test.go index 11255e8dd5..b505bf9d13 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_podtemplatespec_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_podtemplatespec_integration_test.go @@ -72,7 +72,8 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Config: vmcpconfig.Config{Group: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, @@ -211,7 +212,8 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Config: vmcpconfig.Config{Group: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, @@ -314,7 +316,8 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Config: vmcpconfig.Config{Group: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_replicas_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_replicas_integration_test.go index 3643bf46e0..5d0a165cbf 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_replicas_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_replicas_integration_test.go @@ -60,7 +60,8 @@ var _ = Describe("VirtualMCPServer Replicas Integration Tests", Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group-replicas"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group-replicas"}, + Config: vmcpconfig.Config{Group: "test-group-replicas"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, @@ -113,7 +114,8 @@ var _ = Describe("VirtualMCPServer Replicas Integration Tests", Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group-nil-replicas"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group-nil-replicas"}, + Config: vmcpconfig.Config{Group: "test-group-nil-replicas"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_sessionstorage_cel_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_sessionstorage_cel_test.go index c7383854d3..08573e7329 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_sessionstorage_cel_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_sessionstorage_cel_test.go @@ -20,6 +20,7 @@ func newVirtualMCPServerWithSessionStorage(name string, ss *mcpv1alpha1.SessionS Namespace: "default", }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go index 7bfac36288..54f0356305 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go @@ -85,7 +85,8 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group-telemetry-hash"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group-telemetry-hash"}, + Config: vmcpconfig.Config{Group: "test-group-telemetry-hash"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, @@ -219,7 +220,8 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group-telemetry-update"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group-telemetry-update"}, + Config: vmcpconfig.Config{Group: "test-group-telemetry-update"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, @@ -328,7 +330,8 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", Namespace: namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group-telemetry-notfound"}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group-telemetry-notfound"}, + Config: vmcpconfig.Config{Group: "test-group-telemetry-notfound"}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 3528c8cd2c..ec8c65f4f8 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -640,7 +640,6 @@ spec: description: |- Config is the Virtual MCP server configuration. The audit config from here is also supported, but not required. - Note: config.groupRef is deprecated in favor of spec.groupRef. properties: aggregation: description: |- @@ -1169,8 +1168,8 @@ spec: groupRef: description: |- Group references an existing MCPGroup that defines backend workloads. - In Kubernetes, the referenced MCPGroup must exist in the same namespace. - Deprecated: Use spec.groupRef on VirtualMCPServerSpec instead. + In standalone CLI mode, this is set from the YAML config file. + In Kubernetes, the operator populates this from spec.groupRef during conversion. type: string incomingAuth: description: |- @@ -1787,7 +1786,6 @@ spec: description: |- GroupRef references the MCPGroup that defines backend workloads. The referenced MCPGroup must exist in the same namespace. - This field takes precedence over config.groupRef and should be preferred. properties: name: description: Name is the name of the MCPGroup resource in the @@ -1922,7 +1920,7 @@ spec: externalAuthConfigRef: description: |- ExternalAuthConfigRef references an MCPExternalAuthConfig resource - Only used when Type is "externalAuthConfigRef" (or deprecated "external_auth_config_ref") + Only used when Type is "externalAuthConfigRef" properties: name: description: Name is the name of the MCPExternalAuthConfig @@ -1936,7 +1934,6 @@ spec: enum: - discovered - externalAuthConfigRef - - external_auth_config_ref type: string required: - type @@ -1952,7 +1949,7 @@ spec: externalAuthConfigRef: description: |- ExternalAuthConfigRef references an MCPExternalAuthConfig resource - Only used when Type is "externalAuthConfigRef" (or deprecated "external_auth_config_ref") + Only used when Type is "externalAuthConfigRef" properties: name: description: Name is the name of the MCPExternalAuthConfig @@ -1966,7 +1963,6 @@ spec: enum: - discovered - externalAuthConfigRef - - external_auth_config_ref type: string required: - type @@ -2093,6 +2089,7 @@ spec: - name type: object required: + - groupRef - incomingAuth type: object status: diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 90b3b46b4b..2045597aae 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -643,7 +643,6 @@ spec: description: |- Config is the Virtual MCP server configuration. The audit config from here is also supported, but not required. - Note: config.groupRef is deprecated in favor of spec.groupRef. properties: aggregation: description: |- @@ -1172,8 +1171,8 @@ spec: groupRef: description: |- Group references an existing MCPGroup that defines backend workloads. - In Kubernetes, the referenced MCPGroup must exist in the same namespace. - Deprecated: Use spec.groupRef on VirtualMCPServerSpec instead. + In standalone CLI mode, this is set from the YAML config file. + In Kubernetes, the operator populates this from spec.groupRef during conversion. type: string incomingAuth: description: |- @@ -1790,7 +1789,6 @@ spec: description: |- GroupRef references the MCPGroup that defines backend workloads. The referenced MCPGroup must exist in the same namespace. - This field takes precedence over config.groupRef and should be preferred. properties: name: description: Name is the name of the MCPGroup resource in the @@ -1925,7 +1923,7 @@ spec: externalAuthConfigRef: description: |- ExternalAuthConfigRef references an MCPExternalAuthConfig resource - Only used when Type is "externalAuthConfigRef" (or deprecated "external_auth_config_ref") + Only used when Type is "externalAuthConfigRef" properties: name: description: Name is the name of the MCPExternalAuthConfig @@ -1939,7 +1937,6 @@ spec: enum: - discovered - externalAuthConfigRef - - external_auth_config_ref type: string required: - type @@ -1955,7 +1952,7 @@ spec: externalAuthConfigRef: description: |- ExternalAuthConfigRef references an MCPExternalAuthConfig resource - Only used when Type is "externalAuthConfigRef" (or deprecated "external_auth_config_ref") + Only used when Type is "externalAuthConfigRef" properties: name: description: Name is the name of the MCPExternalAuthConfig @@ -1969,7 +1966,6 @@ spec: enum: - discovered - externalAuthConfigRef - - external_auth_config_ref type: string required: - type @@ -2096,6 +2092,7 @@ spec: - name type: object required: + - groupRef - incomingAuth type: object status: diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 22b3ab779f..1a0214cc14 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -263,7 +263,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `name` _string_ | Name is the virtual MCP server name. | | Optional: \{\}
| -| `groupRef` _string_ | Group references an existing MCPGroup that defines backend workloads.
In Kubernetes, the referenced MCPGroup must exist in the same namespace.
Deprecated: Use spec.groupRef on VirtualMCPServerSpec instead. | | Optional: \{\}
| +| `groupRef` _string_ | Group references an existing MCPGroup that defines backend workloads.
In standalone CLI mode, this is set from the YAML config file.
In Kubernetes, the operator populates this from spec.groupRef during conversion. | | Optional: \{\}
| | `backends` _[vmcp.config.StaticBackendConfig](#vmcpconfigstaticbackendconfig) array_ | Backends defines pre-configured backend servers for static mode.
When OutgoingAuth.Source is "inline", this field contains the full list of backend
servers with their URLs and transport types, eliminating the need for K8s API access.
When OutgoingAuth.Source is "discovered", this field is empty and backends are
discovered at runtime via Kubernetes API. | | Optional: \{\}
| | `incomingAuth` _[vmcp.config.IncomingAuthConfig](#vmcpconfigincomingauthconfig)_ | IncomingAuth configures how clients authenticate to the virtual MCP server.
When using the Kubernetes operator, this is populated by the converter from
VirtualMCPServerSpec.IncomingAuth and any values set here will be superseded. | | Optional: \{\}
| | `outgoingAuth` _[vmcp.config.OutgoingAuthConfig](#vmcpconfigoutgoingauthconfig)_ | OutgoingAuth configures how the virtual MCP server authenticates to backends.
When using the Kubernetes operator, this is populated by the converter from
VirtualMCPServerSpec.OutgoingAuth and any values set here will be superseded. | | Optional: \{\}
| @@ -888,8 +888,8 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `type` _string_ | Type defines the authentication type | | Enum: [discovered externalAuthConfigRef external_auth_config_ref]
Required: \{\}
| -| `externalAuthConfigRef` _[api.v1alpha1.ExternalAuthConfigRef](#apiv1alpha1externalauthconfigref)_ | ExternalAuthConfigRef references an MCPExternalAuthConfig resource
Only used when Type is "externalAuthConfigRef" (or deprecated "external_auth_config_ref") | | Optional: \{\}
| +| `type` _string_ | Type defines the authentication type | | Enum: [discovered externalAuthConfigRef]
Required: \{\}
| +| `externalAuthConfigRef` _[api.v1alpha1.ExternalAuthConfigRef](#apiv1alpha1externalauthconfigref)_ | ExternalAuthConfigRef references an MCPExternalAuthConfig resource
Only used when Type is "externalAuthConfigRef" | | Optional: \{\}
| #### api.v1alpha1.BearerTokenConfig @@ -3372,8 +3372,8 @@ _Appears in:_ | `sessionAffinity` _string_ | SessionAffinity controls whether the Service routes repeated client connections to the same pod.
MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default.
Set to "None" for stateless servers or when using an external load balancer with its own affinity. | ClientIP | Enum: [ClientIP None]
Optional: \{\}
| | `serviceAccount` _string_ | ServiceAccount is the name of an already existing service account to use by the Virtual MCP server.
If not specified, a ServiceAccount will be created automatically and used by the Virtual MCP server. | | Optional: \{\}
| | `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the Virtual MCP server
This allows for customizing the pod configuration beyond what is provided by the other fields.
Note that to modify the specific container the Virtual MCP server runs in, you must specify
the 'vmcp' container name in the PodTemplateSpec.
This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object
Optional: \{\}
| -| `groupRef` _[api.v1alpha1.MCPGroupRef](#apiv1alpha1mcpgroupref)_ | GroupRef references the MCPGroup that defines backend workloads.
The referenced MCPGroup must exist in the same namespace.
This field takes precedence over config.groupRef and should be preferred. | | Optional: \{\}
| -| `config` _[vmcp.config.Config](#vmcpconfigconfig)_ | Config is the Virtual MCP server configuration.
The audit config from here is also supported, but not required.
Note: config.groupRef is deprecated in favor of spec.groupRef. | | Type: object
Optional: \{\}
| +| `groupRef` _[api.v1alpha1.MCPGroupRef](#apiv1alpha1mcpgroupref)_ | GroupRef references the MCPGroup that defines backend workloads.
The referenced MCPGroup must exist in the same namespace. | | Required: \{\}
| +| `config` _[vmcp.config.Config](#vmcpconfigconfig)_ | Config is the Virtual MCP server configuration.
The audit config from here is also supported, but not required. | | Type: object
Optional: \{\}
| | `telemetryConfigRef` _[api.v1alpha1.MCPTelemetryConfigReference](#apiv1alpha1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer.
Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\}
| | `embeddingServerRef` _[api.v1alpha1.EmbeddingServerRef](#apiv1alpha1embeddingserverref)_ | EmbeddingServerRef references an existing EmbeddingServer resource by name.
When the optimizer is enabled, this field is required to point to a ready EmbeddingServer
that provides embedding capabilities.
The referenced EmbeddingServer must exist in the same namespace and be ready. | | Optional: \{\}
| | `authServerConfig` _[api.v1alpha1.EmbeddedAuthServerConfig](#apiv1alpha1embeddedauthserverconfig)_ | AuthServerConfig configures an embedded OAuth authorization server.
When set, the vMCP server acts as an OIDC issuer, drives users through
upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the
IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfigRef
so that tokens it issues are accepted by the vMCP's incoming auth middleware.
When nil, IncomingAuth uses an external IDP and behavior is unchanged. | | Optional: \{\}
| diff --git a/docs/operator/virtualmcpserver-api.md b/docs/operator/virtualmcpserver-api.md index 516707025b..56888d3fd3 100644 --- a/docs/operator/virtualmcpserver-api.md +++ b/docs/operator/virtualmcpserver-api.md @@ -31,8 +31,6 @@ The referenced MCPGroup must exist in the same namespace. **Type**: `MCPGroupRef` (object with `name` field) -> **Note**: `spec.config.groupRef` (bare string) is deprecated. Use `spec.groupRef` instead. - **Example**: ```yaml spec: @@ -167,7 +165,7 @@ spec: source: inline backends: github: - type: external_auth_config_ref + type: externalAuthConfigRef externalAuthConfigRef: name: github-token-exchange slack: @@ -185,8 +183,8 @@ spec: **Fields**: - `type` (string, required): Authentication type - `discovered`: Automatically discover from backend - - `external_auth_config_ref`: Reference an MCPExternalAuthConfig resource -- `externalAuthConfigRef` (ExternalAuthConfigRef, optional): Auth config reference (when type=external_auth_config_ref) + - `externalAuthConfigRef`: Reference an MCPExternalAuthConfig resource +- `externalAuthConfigRef` (ExternalAuthConfigRef, optional): Auth config reference (when type=externalAuthConfigRef) ### `.spec.config.aggregation` (optional) @@ -614,7 +612,7 @@ status: The VirtualMCPServer CRD includes comprehensive validation: 1. **Required Fields**: - - `spec.groupRef.name` must be specified (or `spec.config.groupRef` for backwards compatibility) + - `spec.groupRef.name` must be specified - `spec.incomingAuth.type` must be explicitly specified (use `anonymous` when no auth is needed) 2. **Reference Validation**: All references (groupRef, authConfigRef, toolConfigRef) must be valid 3. **Conflict Resolution**: Priority strategy requires `priorityOrder` configuration diff --git a/pkg/vmcp/config/config.go b/pkg/vmcp/config/config.go index 7e464f9708..7a2c699290 100644 --- a/pkg/vmcp/config/config.go +++ b/pkg/vmcp/config/config.go @@ -98,8 +98,8 @@ type Config struct { Name string `json:"name,omitempty" yaml:"name,omitempty"` // Group references an existing MCPGroup that defines backend workloads. - // In Kubernetes, the referenced MCPGroup must exist in the same namespace. - // Deprecated: Use spec.groupRef on VirtualMCPServerSpec instead. + // In standalone CLI mode, this is set from the YAML config file. + // In Kubernetes, the operator populates this from spec.groupRef during conversion. // +optional Group string `json:"groupRef,omitempty" yaml:"groupRef,omitempty"` diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_filtering_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_filtering_test.go index 534ce5ad2e..aa0e743cb0 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_filtering_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_filtering_test.go @@ -49,6 +49,7 @@ var _ = Describe("VirtualMCPServer Aggregation Filtering", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_overrides_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_overrides_test.go index 19bd5d27d5..bcd195f1d2 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_overrides_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_overrides_test.go @@ -50,6 +50,7 @@ var _ = Describe("VirtualMCPServer Tool Overrides", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go index 09b2048d7d..fef4d837ae 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go @@ -851,6 +851,7 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, // No TokenCache configured - tokens should be fetched on each request @@ -1521,7 +1522,7 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_config_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_config_test.go index c477044e20..7e6ebac341 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_config_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_config_test.go @@ -12,7 +12,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) var _ = Describe("VirtualMCPServer AuthServerConfig Validation", Ordered, func() { @@ -67,7 +66,7 @@ var _ = Describe("VirtualMCPServer AuthServerConfig Validation", Ordered, func() Audience: fmt.Sprintf("http://%s.%s.svc.cluster.local:4483", vmcpName, testNamespace), }, }, - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, AuthServerConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ Issuer: "http://localhost:9090", UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go index 332c4abc39..7dc83c9a3c 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go @@ -154,6 +154,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_defaultresults_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_defaultresults_test.go index 22ee2ebcc8..9a070dc958 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_defaultresults_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_defaultresults_test.go @@ -48,6 +48,7 @@ var _ = Describe("VirtualMCPServer Composite Tool DefaultResults", Ordered, func Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_hidden_tools_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_hidden_tools_test.go index 72ed1be84a..63a9d84709 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_hidden_tools_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_hidden_tools_test.go @@ -66,6 +66,7 @@ var _ = Describe("VirtualMCPServer Composite with Hidden Backend Tools", Ordered Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_parallel_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_parallel_test.go index fe5c275c37..8e95003ef5 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_parallel_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_parallel_test.go @@ -52,6 +52,7 @@ var _ = Describe("VirtualMCPServer Composite Parallel Workflow", Ordered, func() Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_referenced_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_referenced_test.go index fed6093a33..5e04bba2bf 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_referenced_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_referenced_test.go @@ -106,6 +106,7 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_sequential_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_sequential_test.go index 6eed911d9c..33795aad9e 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_sequential_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_sequential_test.go @@ -49,6 +49,7 @@ var _ = Describe("VirtualMCPServer Composite Sequential Workflow", Ordered, func Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_validation_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_validation_test.go index 573824eab7..4a0c100429 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_validation_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_validation_test.go @@ -107,6 +107,7 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_conflict_resolution_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_conflict_resolution_test.go index d28b60f018..625feb518f 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_conflict_resolution_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_conflict_resolution_test.go @@ -53,6 +53,7 @@ func setupConflictResolutionTest(setup conflictResolutionTestSetup) int32 { Namespace: setup.namespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: setup.groupName}, Config: vmcpconfig.Config{ Group: setup.groupName, Aggregation: setup.aggregation, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go index c37939831f..57ee46d892 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go @@ -126,6 +126,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, // Discovered mode is the default - tools from all backends in the group are automatically discovered diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_excludeall_global_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_excludeall_global_test.go index 2e20c7f675..c6d4b2591e 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_excludeall_global_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_excludeall_global_test.go @@ -48,6 +48,7 @@ var _ = Describe("VirtualMCPServer Global ExcludeAllTools", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go index cdb8e2cfb8..161c31255e 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go @@ -101,7 +101,7 @@ var _ = Describe("VirtualMCPServer Unauthenticated Backend Auth", Ordered, func( Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, @@ -272,7 +272,7 @@ var _ = Describe("VirtualMCPServer Inline Unauthenticated Backend Auth", Ordered Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, @@ -452,7 +452,7 @@ var _ = Describe("VirtualMCPServer HeaderInjection Backend Auth", Ordered, func( Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, @@ -671,7 +671,7 @@ var _ = Describe("VirtualMCPServer Inline HeaderInjection Backend Auth", Ordered Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", }, @@ -867,6 +867,7 @@ var _ = Describe("VirtualMCPServer Health Check with HeaderInjection Auth", Orde Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Operational: &vmcpconfig.OperationalConfig{ @@ -1083,6 +1084,7 @@ var _ = Describe("VirtualMCPServer Health Check with TokenExchange Auth", Ordere Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Operational: &vmcpconfig.OperationalConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go index d8eed32917..c9035547a4 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go @@ -66,6 +66,7 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, ServiceType: "NodePort", IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_composite_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_composite_test.go index 5bd875cdd9..0b4e344e47 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_composite_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_composite_test.go @@ -84,6 +84,7 @@ var _ = Describe("VirtualMCPServer Optimizer Composite Tools", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, ServiceType: "NodePort", IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_multibackend_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_multibackend_test.go index 5b2f72a09f..dc48b99c27 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_multibackend_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_multibackend_test.go @@ -183,6 +183,7 @@ var _ = Describe("VirtualMCPServer Optimizer Multi-Backend", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, ServiceType: "NodePort", IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_test.go index 26469b1e7e..9023555c9a 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_test.go @@ -82,6 +82,7 @@ var _ = Describe("VirtualMCPServer Optimizer Mode", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, ServiceType: "NodePort", IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go index 6ecf7a5364..94a71097a6 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go @@ -22,7 +22,6 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -92,7 +91,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, Replicas: &replicas, SessionAffinity: "None", @@ -203,7 +202,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, Replicas: &replicas, SessionAffinity: "None", @@ -384,7 +383,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, Replicas: &replicas, ServiceType: "NodePort", @@ -575,7 +574,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, Replicas: &replicas, SessionAffinity: "None", diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go index 4efe841927..96f5d6667c 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go @@ -79,6 +79,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: virtualMCPName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, }, @@ -308,7 +309,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: virtualMCPName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, }, })).To(gomega.Succeed()) @@ -469,6 +470,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, }, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go index 5a66540b30..21edae2c9f 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go @@ -108,6 +108,7 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, ServiceType: "NodePort", IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "anonymous", diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_toolconfig_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_toolconfig_test.go index 107d922f74..c1745b294b 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_toolconfig_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_toolconfig_test.go @@ -71,6 +71,7 @@ var _ = Describe("VirtualMCPServer Tool Filtering via MCPToolConfig", Ordered, f Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -361,6 +362,7 @@ var _ = Describe("VirtualMCPServer MCPToolConfig Dynamic Updates", Ordered, func Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go index ba3dde3d58..dc461a23de 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go @@ -111,6 +111,7 @@ var _ = Describe("VirtualMCPServer Yardstick Base", Ordered, func() { Namespace: testNamespace, }, Spec: mcpv1alpha1.VirtualMCPServerSpec{ + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go index 9cc8a817b0..a34a3eec32 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go @@ -17,7 +17,6 @@ import ( "k8s.io/apimachinery/pkg/types" mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -87,7 +86,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, Replicas: &replicas, }, @@ -187,7 +186,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: mcpGroupName}, + GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, Replicas: &replicas, ServiceType: "NodePort", From caa31f523227cbe0bdbeb199b73b68b2c9171985 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:48:34 +0100 Subject: [PATCH 042/117] Remove orphaned OIDCConfigRef type and OIDCConfigurable interface (#4846) * Remove orphaned OIDCConfigRef type and OIDCConfigurable interface After the inline oidcConfig field was removed from all three CRD types (MCPServer, MCPRemoteProxy, VirtualMCPServer), several types and interfaces became orphaned with zero production callers. This removes: - OIDCConfigRef struct and sub-types (KubernetesOIDCConfig, ConfigMapOIDCRef, InlineOIDCConfig) - OIDCConfigType* constants (kubernetes, configMap, inline) - OIDCConfigurable interface and Resolve() method from Resolver - resolveKubernetesConfig, resolveConfigMapConfig, resolveInlineConfig helper methods - AddOIDCCABundleVolumes function (replaced by AddOIDCConfigRefCABundleVolumes) - MockOIDCConfigurable and related test infrastructure - All tests for removed types and methods Closes #4832 Co-Authored-By: Claude Opus 4.6 (1M context) * Fix doc reference to ClientSecretRef instead of ClientID The secrets management doc references the field for OIDC client secrets, which is ClientSecretRef (a *SecretKeyRef), not ClientID (a plain string). Co-Authored-By: Claude Opus 4.6 (1M context) * Remove stale deprecated oidcConfig YAML example from docs The example block showed oidcConfig inline usage which no longer exists in the CRD schema after #4830. Leaving it would cause CRD validation errors for users who copy it. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../api/v1alpha1/mcpserver_types.go | 170 ---- .../api/v1alpha1/zz_generated.deepcopy.go | 100 -- .../virtualmcpserver_vmcpconfig_test.go | 1 - .../pkg/controllerutil/oidc_volumes.go | 51 - .../pkg/controllerutil/oidc_volumes_test.go | 105 -- .../pkg/oidc/mocks/mock_resolver.go | 95 -- cmd/thv-operator/pkg/oidc/resolver.go | 226 +---- cmd/thv-operator/pkg/oidc/resolver_test.go | 913 ------------------ .../pkg/vmcpconfig/converter_test.go | 1 - docs/arch/04-secrets-management.md | 4 +- docs/operator/crd-api.md | 72 -- docs/operator/virtualmcpserver-api.md | 24 +- 12 files changed, 4 insertions(+), 1758 deletions(-) delete mode 100644 cmd/thv-operator/pkg/controllerutil/oidc_volumes_test.go delete mode 100644 cmd/thv-operator/pkg/oidc/resolver_test.go diff --git a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go b/cmd/thv-operator/api/v1alpha1/mcpserver_types.go index f5f3b6262d..0f1740f170 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpserver_types.go @@ -576,18 +576,6 @@ const ( PermissionProfileTypeConfigMap = "configmap" ) -// OIDC configuration types -const ( - // OIDCConfigTypeKubernetes is the type for Kubernetes service account token validation - OIDCConfigTypeKubernetes = "kubernetes" - - // OIDCConfigTypeConfigMap is the type for OIDC configuration stored in ConfigMaps - OIDCConfigTypeConfigMap = "configMap" - - // OIDCConfigTypeInline is the type for inline OIDC configuration - OIDCConfigTypeInline = "inline" -) - // Authorization configuration types const ( // AuthzConfigTypeConfigMap is the type for authorization configuration stored in ConfigMaps @@ -663,98 +651,6 @@ type OutboundNetworkPermissions struct { AllowPort []int32 `json:"allowPort,omitempty"` } -// OIDCConfigRef defines a reference to OIDC configuration -// -// +kubebuilder:validation:XValidation:rule="self.type == 'configMap' ? has(self.configMap) : !has(self.configMap)",message="configMap must be set when type is 'configMap', and must not be set otherwise" -// +kubebuilder:validation:XValidation:rule="self.type == 'inline' ? has(self.inline) : !has(self.inline)",message="inline must be set when type is 'inline', and must not be set otherwise" -// +kubebuilder:validation:XValidation:rule="self.type != 'kubernetes' ? !has(self.kubernetes) : true",message="kubernetes must not be set when type is not 'kubernetes'" -// -//nolint:lll // CEL validation rules exceed line length limit -type OIDCConfigRef struct { - // Type is the type of OIDC configuration - // +kubebuilder:validation:Enum=kubernetes;configMap;inline - // +kubebuilder:default=kubernetes - Type string `json:"type"` - - // ResourceURL is the explicit resource URL for OAuth discovery endpoint (RFC 9728) - // If not specified, defaults to the in-cluster Kubernetes service URL - // +optional - ResourceURL string `json:"resourceUrl,omitempty"` - - // Kubernetes configures OIDC for Kubernetes service account token validation - // Only used when Type is "kubernetes" - // +optional - Kubernetes *KubernetesOIDCConfig `json:"kubernetes,omitempty"` - - // ConfigMap references a ConfigMap containing OIDC configuration - // Only used when Type is "configmap" - // +optional - ConfigMap *ConfigMapOIDCRef `json:"configMap,omitempty"` - - // Inline contains direct OIDC configuration - // Only used when Type is "inline" - // +optional - Inline *InlineOIDCConfig `json:"inline,omitempty"` -} - -// KubernetesOIDCConfig configures OIDC for Kubernetes service account token validation -type KubernetesOIDCConfig struct { - // ServiceAccount is the name of the service account to validate tokens for - // If empty, uses the pod's service account - // +optional - ServiceAccount string `json:"serviceAccount,omitempty"` - - // Namespace is the namespace of the service account - // If empty, uses the MCPServer's namespace - // +optional - Namespace string `json:"namespace,omitempty"` - - // Audience is the expected audience for the token - // +kubebuilder:default=toolhive - // +optional - Audience string `json:"audience,omitempty"` - - // Issuer is the OIDC issuer URL - // +kubebuilder:default="https://kubernetes.default.svc" - // +optional - Issuer string `json:"issuer,omitempty"` - - // JWKSURL is the URL to fetch the JWKS from - // If empty, OIDC discovery will be used to automatically determine the JWKS URL - // +optional - JWKSURL string `json:"jwksUrl,omitempty"` - - // IntrospectionURL is the URL for token introspection endpoint - // If empty, OIDC discovery will be used to automatically determine the introspection URL - // +optional - IntrospectionURL string `json:"introspectionUrl,omitempty"` - - // UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token - // When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification - // and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication - // Defaults to true if not specified - // +optional - UseClusterAuth *bool `json:"useClusterAuth"` -} - -// ConfigMapOIDCRef references a ConfigMap containing OIDC configuration -type ConfigMapOIDCRef struct { - // Name is the name of the ConfigMap - // +kubebuilder:validation:Required - Name string `json:"name"` - - // Key is the key in the ConfigMap that contains the OIDC configuration - // +kubebuilder:default=oidc.json - // +optional - Key string `json:"key,omitempty"` - - // CABundleRef references a ConfigMap containing the CA certificate bundle. - // When specified, ToolHive auto-mounts the ConfigMap and auto-computes ThvCABundlePath. - // If the ConfigMap data contains an explicit thvCABundlePath key, it takes precedence. - // +optional - CABundleRef *CABundleSource `json:"caBundleRef,omitempty"` -} - // CABundleSource defines a source for CA certificate bundles. type CABundleSource struct { // ConfigMapRef references a ConfigMap containing the CA certificate bundle. @@ -763,72 +659,6 @@ type CABundleSource struct { ConfigMapRef *corev1.ConfigMapKeySelector `json:"configMapRef,omitempty"` } -// InlineOIDCConfig contains direct OIDC configuration -type InlineOIDCConfig struct { - // Issuer is the OIDC issuer URL - // +kubebuilder:validation:Required - Issuer string `json:"issuer"` - - // Audience is the expected audience for the token - // +optional - Audience string `json:"audience,omitempty"` - - // JWKSURL is the URL to fetch the JWKS from - // +optional - JWKSURL string `json:"jwksUrl,omitempty"` - - // IntrospectionURL is the URL for token introspection endpoint - // +optional - IntrospectionURL string `json:"introspectionUrl,omitempty"` - - // ClientID is the OIDC client ID - // +optional - ClientID string `json:"clientId,omitempty"` - - // ClientSecretRef is a reference to a Kubernetes Secret containing the client secret - // +optional - ClientSecretRef *SecretKeyRef `json:"clientSecretRef,omitempty"` - - // CABundleRef references a ConfigMap containing the CA certificate bundle. - // When specified, ToolHive auto-mounts the ConfigMap and auto-computes the CA bundle path. - // +optional - CABundleRef *CABundleSource `json:"caBundleRef,omitempty"` - - // JWKSAuthTokenPath is the path to file containing bearer token for JWKS/OIDC requests - // The file must be mounted into the pod (e.g., via Secret volume) - // +optional - JWKSAuthTokenPath string `json:"jwksAuthTokenPath,omitempty"` - - // JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. - // Use with caution - only enable for trusted internal IDPs. - // Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP - // is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). - // +kubebuilder:default=false - // +optional - JWKSAllowPrivateIP bool `json:"jwksAllowPrivateIP"` - - // ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. - // Use with caution - only enable for trusted internal IDPs or testing. - // Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP - // is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). - // +kubebuilder:default=false - // +optional - ProtectedResourceAllowPrivateIP bool `json:"protectedResourceAllowPrivateIP"` - - // InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing - // WARNING: This is insecure and should NEVER be used in production - // Only enable for local development, testing, or trusted internal networks - // +kubebuilder:default=false - // +optional - InsecureAllowHTTP bool `json:"insecureAllowHTTP"` - - // Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728) - // If empty, defaults to ["openid"] - // +listType=atomic - // +optional - Scopes []string `json:"scopes,omitempty"` -} - // AuthzConfigRef defines a reference to authorization configuration // // +kubebuilder:validation:XValidation:rule="self.type == 'configMap' ? has(self.configMap) : !has(self.configMap)",message="configMap must be set when type is 'configMap', and must not be set otherwise" diff --git a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go index eb284d16c2..f52c305e52 100644 --- a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -204,26 +204,6 @@ func (in *ConfigMapAuthzRef) DeepCopy() *ConfigMapAuthzRef { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ConfigMapOIDCRef) DeepCopyInto(out *ConfigMapOIDCRef) { - *out = *in - if in.CABundleRef != nil { - in, out := &in.CABundleRef, &out.CABundleRef - *out = new(CABundleSource) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapOIDCRef. -func (in *ConfigMapOIDCRef) DeepCopy() *ConfigMapOIDCRef { - if in == nil { - return nil - } - out := new(ConfigMapOIDCRef) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EmbeddedAuthServerConfig) DeepCopyInto(out *EmbeddedAuthServerConfig) { *out = *in @@ -608,36 +588,6 @@ func (in *InlineAuthzConfig) DeepCopy() *InlineAuthzConfig { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *InlineOIDCConfig) DeepCopyInto(out *InlineOIDCConfig) { - *out = *in - if in.ClientSecretRef != nil { - in, out := &in.ClientSecretRef, &out.ClientSecretRef - *out = new(SecretKeyRef) - **out = **in - } - if in.CABundleRef != nil { - in, out := &in.CABundleRef, &out.CABundleRef - *out = new(CABundleSource) - (*in).DeepCopyInto(*out) - } - if in.Scopes != nil { - in, out := &in.Scopes, &out.Scopes - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InlineOIDCConfig. -func (in *InlineOIDCConfig) DeepCopy() *InlineOIDCConfig { - if in == nil { - return nil - } - out := new(InlineOIDCConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InlineOIDCSharedConfig) DeepCopyInto(out *InlineOIDCSharedConfig) { *out = *in @@ -663,26 +613,6 @@ func (in *InlineOIDCSharedConfig) DeepCopy() *InlineOIDCSharedConfig { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *KubernetesOIDCConfig) DeepCopyInto(out *KubernetesOIDCConfig) { - *out = *in - if in.UseClusterAuth != nil { - in, out := &in.UseClusterAuth, &out.UseClusterAuth - *out = new(bool) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesOIDCConfig. -func (in *KubernetesOIDCConfig) DeepCopy() *KubernetesOIDCConfig { - if in == nil { - return nil - } - out := new(KubernetesOIDCConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KubernetesServiceAccountOIDCConfig) DeepCopyInto(out *KubernetesServiceAccountOIDCConfig) { *out = *in @@ -2039,36 +1969,6 @@ func (in *OAuth2UpstreamConfig) DeepCopy() *OAuth2UpstreamConfig { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *OIDCConfigRef) DeepCopyInto(out *OIDCConfigRef) { - *out = *in - if in.Kubernetes != nil { - in, out := &in.Kubernetes, &out.Kubernetes - *out = new(KubernetesOIDCConfig) - (*in).DeepCopyInto(*out) - } - if in.ConfigMap != nil { - in, out := &in.ConfigMap, &out.ConfigMap - *out = new(ConfigMapOIDCRef) - (*in).DeepCopyInto(*out) - } - if in.Inline != nil { - in, out := &in.Inline, &out.Inline - *out = new(InlineOIDCConfig) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OIDCConfigRef. -func (in *OIDCConfigRef) DeepCopy() *OIDCConfigRef { - if in == nil { - return nil - } - out := new(OIDCConfigRef) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCUpstreamConfig) DeepCopyInto(out *OIDCUpstreamConfig) { *out = *in diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go index 3d168d57b2..5f934a0d47 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go @@ -38,7 +38,6 @@ func newNoOpMockResolver(t *testing.T) *oidcmocks.MockResolver { t.Helper() ctrl := gomock.NewController(t) mockResolver := oidcmocks.NewMockResolver(ctrl) - mockResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() return mockResolver } diff --git a/cmd/thv-operator/pkg/controllerutil/oidc_volumes.go b/cmd/thv-operator/pkg/controllerutil/oidc_volumes.go index f6f9f78054..60e692be14 100644 --- a/cmd/thv-operator/pkg/controllerutil/oidc_volumes.go +++ b/cmd/thv-operator/pkg/controllerutil/oidc_volumes.go @@ -12,57 +12,6 @@ import ( "github.com/stacklok/toolhive/cmd/thv-operator/pkg/validation" ) -// AddOIDCCABundleVolumes returns volumes and volume mounts for OIDC CA bundle. -// Returns nil slices if no CA bundle is configured. -func AddOIDCCABundleVolumes( - oidcConfig *mcpv1alpha1.OIDCConfigRef, -) ([]corev1.Volume, []corev1.VolumeMount) { - if oidcConfig == nil { - return nil, nil - } - - // Get CABundleRef based on config type - var caBundleRef *mcpv1alpha1.CABundleSource - switch oidcConfig.Type { - case mcpv1alpha1.OIDCConfigTypeInline: - if oidcConfig.Inline != nil { - caBundleRef = oidcConfig.Inline.CABundleRef - } - case mcpv1alpha1.OIDCConfigTypeConfigMap: - if oidcConfig.ConfigMap != nil { - caBundleRef = oidcConfig.ConfigMap.CABundleRef - } - } - - if caBundleRef == nil || caBundleRef.ConfigMapRef == nil { - return nil, nil - } - - ref := caBundleRef.ConfigMapRef - key := ref.Key - if key == "" { - key = validation.OIDCCABundleDefaultKey - } - volumeName := fmt.Sprintf("%s%s", validation.OIDCCABundleVolumePrefix, ref.Name) - mountPath := fmt.Sprintf("%s/%s", validation.OIDCCABundleMountBasePath, ref.Name) - - volume := corev1.Volume{ - Name: volumeName, - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: ref.Name}, - Items: []corev1.KeyToPath{{Key: key, Path: key}}, - }, - }, - } - volumeMount := corev1.VolumeMount{ - Name: volumeName, - MountPath: mountPath, - ReadOnly: true, - } - return []corev1.Volume{volume}, []corev1.VolumeMount{volumeMount} -} - // AddOIDCConfigRefCABundleVolumes returns volumes and volume mounts for OIDC CA bundle // from an MCPOIDCConfig's inline configuration. Returns nil slices if no CA bundle is configured. func AddOIDCConfigRefCABundleVolumes( diff --git a/cmd/thv-operator/pkg/controllerutil/oidc_volumes_test.go b/cmd/thv-operator/pkg/controllerutil/oidc_volumes_test.go deleted file mode 100644 index 631c63a91d..0000000000 --- a/cmd/thv-operator/pkg/controllerutil/oidc_volumes_test.go +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package controllerutil - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" -) - -func TestAddOIDCCABundleVolumes(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - oidcConfig *mcpv1alpha1.OIDCConfigRef - wantVolumeName string - wantConfigMap string - wantKey string - wantMountPath string - }{ - { - name: "nil OIDCConfig returns nil", - oidcConfig: nil, - }, - { - name: "missing CABundleRef returns nil", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{Name: "oidc-config"}, - }, - }, - { - name: "configMap type with caBundleRef uses default key", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "oidc-config", - CABundleRef: &mcpv1alpha1.CABundleSource{ - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-ca-bundle"}, - }, - }, - }, - }, - wantVolumeName: "oidc-ca-bundle-my-ca-bundle", - wantConfigMap: "my-ca-bundle", - wantKey: "ca.crt", - wantMountPath: "/config/certs/my-ca-bundle", - }, - { - name: "inline type with custom key", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://issuer.example.com", - CABundleRef: &mcpv1alpha1.CABundleSource{ - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "custom-ca"}, - Key: "custom.pem", - }, - }, - }, - }, - wantVolumeName: "oidc-ca-bundle-custom-ca", - wantConfigMap: "custom-ca", - wantKey: "custom.pem", - wantMountPath: "/config/certs/custom-ca", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - volumes, mounts := AddOIDCCABundleVolumes(tt.oidcConfig) - - if tt.wantVolumeName == "" { - assert.Empty(t, volumes) - assert.Empty(t, mounts) - return - } - - require.Len(t, volumes, 1) - require.Len(t, mounts, 1) - - vol := volumes[0] - assert.Equal(t, tt.wantVolumeName, vol.Name) - require.NotNil(t, vol.ConfigMap) - assert.Equal(t, tt.wantConfigMap, vol.ConfigMap.Name) - require.Len(t, vol.ConfigMap.Items, 1) - assert.Equal(t, tt.wantKey, vol.ConfigMap.Items[0].Key) - - mount := mounts[0] - assert.Equal(t, tt.wantVolumeName, mount.Name) - assert.Equal(t, tt.wantMountPath, mount.MountPath) - assert.True(t, mount.ReadOnly) - }) - } -} diff --git a/cmd/thv-operator/pkg/oidc/mocks/mock_resolver.go b/cmd/thv-operator/pkg/oidc/mocks/mock_resolver.go index 1367ee8684..1175c8155e 100644 --- a/cmd/thv-operator/pkg/oidc/mocks/mock_resolver.go +++ b/cmd/thv-operator/pkg/oidc/mocks/mock_resolver.go @@ -18,86 +18,6 @@ import ( gomock "go.uber.org/mock/gomock" ) -// MockOIDCConfigurable is a mock of OIDCConfigurable interface. -type MockOIDCConfigurable struct { - ctrl *gomock.Controller - recorder *MockOIDCConfigurableMockRecorder - isgomock struct{} -} - -// MockOIDCConfigurableMockRecorder is the mock recorder for MockOIDCConfigurable. -type MockOIDCConfigurableMockRecorder struct { - mock *MockOIDCConfigurable -} - -// NewMockOIDCConfigurable creates a new mock instance. -func NewMockOIDCConfigurable(ctrl *gomock.Controller) *MockOIDCConfigurable { - mock := &MockOIDCConfigurable{ctrl: ctrl} - mock.recorder = &MockOIDCConfigurableMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockOIDCConfigurable) EXPECT() *MockOIDCConfigurableMockRecorder { - return m.recorder -} - -// GetName mocks base method. -func (m *MockOIDCConfigurable) GetName() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetName") - ret0, _ := ret[0].(string) - return ret0 -} - -// GetName indicates an expected call of GetName. -func (mr *MockOIDCConfigurableMockRecorder) GetName() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetName", reflect.TypeOf((*MockOIDCConfigurable)(nil).GetName)) -} - -// GetNamespace mocks base method. -func (m *MockOIDCConfigurable) GetNamespace() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetNamespace") - ret0, _ := ret[0].(string) - return ret0 -} - -// GetNamespace indicates an expected call of GetNamespace. -func (mr *MockOIDCConfigurableMockRecorder) GetNamespace() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNamespace", reflect.TypeOf((*MockOIDCConfigurable)(nil).GetNamespace)) -} - -// GetOIDCConfig mocks base method. -func (m *MockOIDCConfigurable) GetOIDCConfig() *v1alpha1.OIDCConfigRef { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetOIDCConfig") - ret0, _ := ret[0].(*v1alpha1.OIDCConfigRef) - return ret0 -} - -// GetOIDCConfig indicates an expected call of GetOIDCConfig. -func (mr *MockOIDCConfigurableMockRecorder) GetOIDCConfig() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOIDCConfig", reflect.TypeOf((*MockOIDCConfigurable)(nil).GetOIDCConfig)) -} - -// GetProxyPort mocks base method. -func (m *MockOIDCConfigurable) GetProxyPort() int32 { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetProxyPort") - ret0, _ := ret[0].(int32) - return ret0 -} - -// GetProxyPort indicates an expected call of GetProxyPort. -func (mr *MockOIDCConfigurableMockRecorder) GetProxyPort() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyPort", reflect.TypeOf((*MockOIDCConfigurable)(nil).GetProxyPort)) -} - // MockResolver is a mock of Resolver interface. type MockResolver struct { ctrl *gomock.Controller @@ -122,21 +42,6 @@ func (m *MockResolver) EXPECT() *MockResolverMockRecorder { return m.recorder } -// Resolve mocks base method. -func (m *MockResolver) Resolve(ctx context.Context, resource oidc.OIDCConfigurable) (*oidc.OIDCConfig, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Resolve", ctx, resource) - ret0, _ := ret[0].(*oidc.OIDCConfig) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Resolve indicates an expected call of Resolve. -func (mr *MockResolverMockRecorder) Resolve(ctx, resource any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockResolver)(nil).Resolve), ctx, resource) -} - // ResolveFromConfigRef mocks base method. func (m *MockResolver) ResolveFromConfigRef(ctx context.Context, oidcConfigRef *v1alpha1.MCPOIDCConfigReference, oidcConfig *v1alpha1.MCPOIDCConfig, serverName, namespace string, proxyPort int32) (*oidc.OIDCConfig, error) { m.ctrl.T.Helper() diff --git a/cmd/thv-operator/pkg/oidc/resolver.go b/cmd/thv-operator/pkg/oidc/resolver.go index 22a6fbdff2..48a441aaa0 100644 --- a/cmd/thv-operator/pkg/oidc/resolver.go +++ b/cmd/thv-operator/pkg/oidc/resolver.go @@ -1,17 +1,13 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -// Package oidc provides utilities for resolving OIDC configuration from various sources -// including Kubernetes service accounts, ConfigMaps, and inline configurations. +// Package oidc provides utilities for resolving OIDC configuration from MCPOIDCConfig resources. package oidc import ( "context" "fmt" - "strings" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -24,8 +20,6 @@ const ( defaultK8sCABundlePath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" defaultK8sTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec defaultK8sIssuer = "https://kubernetes.default.svc" - defaultK8sAudience = "toolhive" - configMapBoolTrue = "true" ) // OIDCConfig represents the resolved OIDC configuration values @@ -45,23 +39,10 @@ type OIDCConfig struct { //nolint:revive // Keeping OIDCConfig name for backward Scopes []string } -// OIDCConfigurable is an interface for resources that have OIDC configuration -// -//nolint:revive // Intentionally named OIDCConfigurable for clarity -type OIDCConfigurable interface { - GetName() string - GetNamespace() string - GetOIDCConfig() *mcpv1alpha1.OIDCConfigRef - GetProxyPort() int32 -} - //go:generate mockgen -destination=mocks/mock_resolver.go -package=mocks -source=resolver.go Resolver // Resolver is the interface for resolving OIDC configuration from various sources type Resolver interface { - // Resolve takes any resource implementing OIDCConfigurable and resolves its OIDC config - Resolve(ctx context.Context, resource OIDCConfigurable) (*OIDCConfig, error) - // ResolveFromConfigRef resolves OIDC configuration from an MCPOIDCConfig reference. // It fetches the MCPOIDCConfig resource and merges shared provider config with // per-server overrides (audience, scopes) from the reference. @@ -87,31 +68,6 @@ type resolver struct { client client.Client } -// Resolve resolves the OIDC configuration from any resource implementing OIDCConfigurable -func (r *resolver) Resolve(ctx context.Context, resource OIDCConfigurable) (*OIDCConfig, error) { - oidcConfig := resource.GetOIDCConfig() - if oidcConfig == nil { - return nil, nil - } - - // Calculate resource URL for RFC 9728 compliance - resourceURL := oidcConfig.ResourceURL - if resourceURL == "" { - resourceURL = createServiceURL(resource.GetName(), resource.GetNamespace(), resource.GetProxyPort()) - } - - switch oidcConfig.Type { - case mcpv1alpha1.OIDCConfigTypeKubernetes: - return r.resolveKubernetesConfig(ctx, oidcConfig.Kubernetes, resourceURL, resource.GetNamespace()) - case mcpv1alpha1.OIDCConfigTypeConfigMap: - return r.resolveConfigMapConfig(ctx, oidcConfig.ConfigMap, resourceURL, resource.GetNamespace()) - case mcpv1alpha1.OIDCConfigTypeInline: - return r.resolveInlineConfig(oidcConfig.Inline, resourceURL) - default: - return nil, fmt.Errorf("unknown OIDC config type: %s", oidcConfig.Type) - } -} - // ResolveFromConfigRef resolves OIDC configuration from an MCPOIDCConfig reference. // It merges shared provider config from the MCPOIDCConfig with per-server overrides // (audience, scopes) from the MCPOIDCConfigReference. @@ -215,164 +171,6 @@ func (*resolver) resolveFromInlineSharedConfig( }, nil } -// resolveKubernetesConfig resolves Kubernetes OIDC config using namespace directly -func (*resolver) resolveKubernetesConfig( - ctx context.Context, - config *mcpv1alpha1.KubernetesOIDCConfig, - resourceURL string, - namespace string, -) (*OIDCConfig, error) { - if config == nil { - ctxLogger := log.FromContext(ctx) - ctxLogger.Info("Kubernetes OIDCConfig is nil, using default configuration", "namespace", namespace) - defaultUseClusterAuth := true - config = &mcpv1alpha1.KubernetesOIDCConfig{ - UseClusterAuth: &defaultUseClusterAuth, - } - } - - // Handle UseClusterAuth with default of true if nil - useClusterAuth := true - if config.UseClusterAuth != nil { - useClusterAuth = *config.UseClusterAuth - } - - result := &OIDCConfig{ - ResourceURL: resourceURL, - } - - // Set issuer with default - result.Issuer = config.Issuer - if result.Issuer == "" { - result.Issuer = defaultK8sIssuer - } - - // Set audience with default - result.Audience = config.Audience - if result.Audience == "" { - result.Audience = defaultK8sAudience - } - - // Set JWKS and introspection URLs - result.JWKSURL = config.JWKSURL - result.IntrospectionURL = config.IntrospectionURL - - // Apply cluster auth settings if enabled - if useClusterAuth { - result.ThvCABundlePath = defaultK8sCABundlePath - result.JWKSAuthTokenPath = defaultK8sTokenPath - result.JWKSAllowPrivateIP = true - } - - return result, nil -} - -// resolveConfigMapConfig resolves ConfigMap OIDC config using namespace directly -func (r *resolver) resolveConfigMapConfig( - ctx context.Context, - configRef *mcpv1alpha1.ConfigMapOIDCRef, - resourceURL string, - namespace string, -) (*OIDCConfig, error) { - if configRef == nil { - return nil, nil - } - - if r.client == nil { - return nil, fmt.Errorf("kubernetes client is required for ConfigMap OIDC resolution") - } - - // Validate CABundleRef if present - if err := validation.ValidateCABundleSource(configRef.CABundleRef); err != nil { - return nil, err - } - - // Read the ConfigMap - configMap := &corev1.ConfigMap{} - err := r.client.Get(ctx, types.NamespacedName{ - Name: configRef.Name, - Namespace: namespace, - }, configMap) - if err != nil { - return nil, fmt.Errorf("failed to get OIDC ConfigMap %s/%s: %w", - namespace, configRef.Name, err) - } - - config := &OIDCConfig{ - ResourceURL: resourceURL, - } - - // Extract string values - config.Issuer = getMapValue(configMap.Data, "issuer") - config.Audience = getMapValue(configMap.Data, "audience") - config.JWKSURL = getMapValue(configMap.Data, "jwksUrl") - config.IntrospectionURL = getMapValue(configMap.Data, "introspectionUrl") - config.ClientID = getMapValue(configMap.Data, "clientId") - config.ClientSecret = getMapValue(configMap.Data, "clientSecret") - config.ThvCABundlePath = getMapValue(configMap.Data, "thvCABundlePath") - //nolint:gosec // This is just a config key name, not a credential - config.JWKSAuthTokenPath = getMapValue(configMap.Data, "jwksAuthTokenPath") - - // Handle boolean values - if v, exists := configMap.Data["jwksAllowPrivateIP"]; exists && v == configMapBoolTrue { - config.JWKSAllowPrivateIP = true - } - if v, exists := configMap.Data["protectedResourceAllowPrivateIP"]; exists && v == configMapBoolTrue { - config.ProtectedResourceAllowPrivateIP = true - } - if v, exists := configMap.Data["insecureAllowHTTP"]; exists && v == configMapBoolTrue { - config.InsecureAllowHTTP = true - } - - // Handle scopes as comma-separated values - config.Scopes = parseCommaSeparatedList(getMapValue(configMap.Data, "scopes")) - - // Compute ThvCABundlePath from CABundleRef if not explicitly set in ConfigMap - if config.ThvCABundlePath == "" { - config.ThvCABundlePath = computeCABundlePath(configRef.CABundleRef) - } - - return config, nil -} - -// resolveInlineConfig resolves inline OIDC configuration -func (*resolver) resolveInlineConfig( - config *mcpv1alpha1.InlineOIDCConfig, - resourceURL string, -) (*OIDCConfig, error) { - if config == nil { - return nil, nil - } - - // Validate CABundleRef if present - if err := validation.ValidateCABundleSource(config.CABundleRef); err != nil { - return nil, err - } - - return &OIDCConfig{ - Issuer: config.Issuer, - Audience: config.Audience, - JWKSURL: config.JWKSURL, - IntrospectionURL: config.IntrospectionURL, - ClientID: config.ClientID, - ThvCABundlePath: computeCABundlePath(config.CABundleRef), - JWKSAuthTokenPath: config.JWKSAuthTokenPath, - ResourceURL: resourceURL, - JWKSAllowPrivateIP: config.JWKSAllowPrivateIP, - ProtectedResourceAllowPrivateIP: config.ProtectedResourceAllowPrivateIP, - InsecureAllowHTTP: config.InsecureAllowHTTP, - Scopes: config.Scopes, - }, nil -} - -// getMapValue is a helper to extract string values from a map -func getMapValue(data map[string]string, key string) string { - if v, exists := data[key]; exists && v != "" { - return v - } - return "" -} - // computeCABundlePath computes the CA bundle mount path from a CABundleSource. // Returns empty string if caBundleRef is nil or has no ConfigMapRef. func computeCABundlePath(caBundleRef *mcpv1alpha1.CABundleSource) string { @@ -387,28 +185,6 @@ func computeCABundlePath(caBundleRef *mcpv1alpha1.CABundleSource) string { return fmt.Sprintf("%s/%s/%s", validation.OIDCCABundleMountBasePath, ref.Name, key) } -// parseCommaSeparatedList parses a comma-separated string into a slice of strings. -// It trims whitespace from each element and filters out empty strings. -func parseCommaSeparatedList(value string) []string { - if value == "" { - return nil - } - - parts := strings.Split(value, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed != "" { - result = append(result, trimmed) - } - } - - if len(result) == 0 { - return nil - } - return result -} - // createServiceURL creates a service URL from MCPServer details func createServiceURL(name, namespace string, port int32) string { if port == 0 { diff --git a/cmd/thv-operator/pkg/oidc/resolver_test.go b/cmd/thv-operator/pkg/oidc/resolver_test.go deleted file mode 100644 index 080808dc9c..0000000000 --- a/cmd/thv-operator/pkg/oidc/resolver_test.go +++ /dev/null @@ -1,913 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package oidc - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" -) - -// testResource is a minimal OIDCConfigurable implementation for testing. -type testResource struct { - name string - namespace string - proxyPort int32 - oidcConfig *mcpv1alpha1.OIDCConfigRef -} - -func (t *testResource) GetName() string { return t.name } -func (t *testResource) GetNamespace() string { return t.namespace } -func (t *testResource) GetProxyPort() int32 { return t.proxyPort } -func (t *testResource) GetOIDCConfig() *mcpv1alpha1.OIDCConfigRef { return t.oidcConfig } - -func TestResolve_KubernetesType(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - resource *testResource - expected *OIDCConfig - }{ - { - name: "kubernetes with defaults", - resource: &testResource{ - name: "test-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeKubernetes, - Kubernetes: nil, // nil config should use defaults - }, - }, - expected: &OIDCConfig{ - Issuer: defaultK8sIssuer, - Audience: defaultK8sAudience, - ThvCABundlePath: defaultK8sCABundlePath, - JWKSAuthTokenPath: defaultK8sTokenPath, - ResourceURL: "http://test-server.test-ns.svc.cluster.local:8080", - JWKSAllowPrivateIP: true, - }, - }, - { - name: "kubernetes with custom values", - resource: &testResource{ - name: "custom-server", - namespace: "custom-ns", - proxyPort: 9090, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeKubernetes, - ResourceURL: "https://custom-resource.example.com", - Kubernetes: &mcpv1alpha1.KubernetesOIDCConfig{ - Issuer: "https://custom-issuer.example.com", - Audience: "custom-audience", - JWKSURL: "https://custom-issuer.example.com/.well-known/jwks.json", - IntrospectionURL: "https://custom-issuer.example.com/introspect", - UseClusterAuth: func(b bool) *bool { return &b }(true), - }, - }, - }, - expected: &OIDCConfig{ - Issuer: "https://custom-issuer.example.com", - Audience: "custom-audience", - JWKSURL: "https://custom-issuer.example.com/.well-known/jwks.json", - IntrospectionURL: "https://custom-issuer.example.com/introspect", - ThvCABundlePath: defaultK8sCABundlePath, - JWKSAuthTokenPath: defaultK8sTokenPath, - ResourceURL: "https://custom-resource.example.com", - JWKSAllowPrivateIP: true, - }, - }, - { - name: "kubernetes with UseClusterAuth false", - resource: &testResource{ - name: "no-cluster-auth-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeKubernetes, - Kubernetes: &mcpv1alpha1.KubernetesOIDCConfig{ - UseClusterAuth: func(b bool) *bool { return &b }(false), - }, - }, - }, - expected: &OIDCConfig{ - Issuer: defaultK8sIssuer, - Audience: defaultK8sAudience, - ResourceURL: "http://no-cluster-auth-server.test-ns.svc.cluster.local:8080", - JWKSAllowPrivateIP: false, // Should be false when UseClusterAuth is false - // CA bundle and token paths should be empty - ThvCABundlePath: "", - JWKSAuthTokenPath: "", - }, - }, - { - name: "nil oidc config returns nil", - resource: &testResource{ - name: "test-server", - namespace: "test-ns", - oidcConfig: nil, - }, - expected: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - resolver := NewResolver(nil) - config, err := resolver.Resolve(context.Background(), tt.resource) - require.NoError(t, err) - assert.Equal(t, tt.expected, config) - }) - } -} - -func TestResolve_ConfigMapType(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - resource *testResource - configMap *corev1.ConfigMap - expected *OIDCConfig - expectErr bool - }{ - { - name: "configmap with all values", - resource: &testResource{ - name: "test-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "oidc-config", - }, - }, - }, - configMap: &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "oidc-config", - Namespace: "test-ns", - }, - Data: map[string]string{ - "issuer": "https://auth.example.com", - "audience": "test-audience", - "jwksUrl": "https://auth.example.com/.well-known/jwks.json", - "introspectionUrl": "https://auth.example.com/introspect", - "clientId": "test-client", - "clientSecret": "test-secret", - "thvCABundlePath": "/etc/ssl/ca.pem", - "jwksAuthTokenPath": "/etc/auth/token", - "jwksAllowPrivateIP": "true", - "protectedResourceAllowPrivateIP": "true", - }, - }, - expected: &OIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - JWKSURL: "https://auth.example.com/.well-known/jwks.json", - IntrospectionURL: "https://auth.example.com/introspect", - ClientID: "test-client", - ClientSecret: "test-secret", - ThvCABundlePath: "/etc/ssl/ca.pem", - JWKSAuthTokenPath: "/etc/auth/token", - ResourceURL: "http://test-server.test-ns.svc.cluster.local:8080", - JWKSAllowPrivateIP: true, - ProtectedResourceAllowPrivateIP: true, - }, - }, - { - name: "configmap with partial values", - resource: &testResource{ - name: "partial-server", - namespace: "test-ns", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ResourceURL: "https://custom-resource.example.com", - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "partial-config", - }, - }, - }, - configMap: &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "partial-config", - Namespace: "test-ns", - }, - Data: map[string]string{ - "issuer": "https://partial.example.com", - "audience": "partial-audience", - "jwksAllowPrivateIP": "false", // explicitly false - }, - }, - expected: &OIDCConfig{ - Issuer: "https://partial.example.com", - Audience: "partial-audience", - ResourceURL: "https://custom-resource.example.com", - JWKSAllowPrivateIP: false, - }, - }, - { - name: "configmap with jwksAllowPrivateIP independent of protectedResourceAllowPrivateIP", - resource: &testResource{ - name: "independent-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "independent-config", - }, - }, - }, - configMap: &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "independent-config", - Namespace: "test-ns", - }, - Data: map[string]string{ - "issuer": "https://auth.example.com", - "audience": "test-audience", - "jwksAllowPrivateIP": "true", - // protectedResourceAllowPrivateIP intentionally absent - }, - }, - expected: &OIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - ResourceURL: "http://independent-server.test-ns.svc.cluster.local:8080", - JWKSAllowPrivateIP: true, - ProtectedResourceAllowPrivateIP: false, - }, - }, - { - name: "configmap with insecureAllowHTTP enabled", - resource: &testResource{ - name: "insecure-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "insecure-config", - }, - }, - }, - configMap: &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "insecure-config", - Namespace: "test-ns", - }, - Data: map[string]string{ - "issuer": "http://localhost:8080/realms/test", - "audience": "test-audience", - "jwksAllowPrivateIP": "true", - "insecureAllowHTTP": "true", - }, - }, - expected: &OIDCConfig{ - Issuer: "http://localhost:8080/realms/test", - Audience: "test-audience", - ResourceURL: "http://insecure-server.test-ns.svc.cluster.local:8080", - JWKSAllowPrivateIP: true, - InsecureAllowHTTP: true, - }, - }, - { - name: "configmap with scopes", - resource: &testResource{ - name: "scopes-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "scopes-config", - }, - }, - }, - configMap: &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "scopes-config", - Namespace: "test-ns", - }, - Data: map[string]string{ - "issuer": "https://auth.example.com", - "audience": "test-audience", - "scopes": "https://www.googleapis.com/auth/drive.readonly,https://www.googleapis.com/auth/documents.readonly", - }, - }, - expected: &OIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - ResourceURL: "http://scopes-server.test-ns.svc.cluster.local:8080", - Scopes: []string{ - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/documents.readonly", - }, - }, - }, - { - name: "configmap with scopes containing whitespace", - resource: &testResource{ - name: "whitespace-scopes-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "whitespace-scopes-config", - }, - }, - }, - configMap: &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "whitespace-scopes-config", - Namespace: "test-ns", - }, - Data: map[string]string{ - "issuer": "https://auth.example.com", - "audience": "test-audience", - "scopes": "scope1 , scope2, scope3 ", - }, - }, - expected: &OIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - ResourceURL: "http://whitespace-scopes-server.test-ns.svc.cluster.local:8080", - Scopes: []string{"scope1", "scope2", "scope3"}, - }, - }, - { - name: "configmap with empty scopes", - resource: &testResource{ - name: "empty-scopes-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "empty-scopes-config", - }, - }, - }, - configMap: &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "empty-scopes-config", - Namespace: "test-ns", - }, - Data: map[string]string{ - "issuer": "https://auth.example.com", - "audience": "test-audience", - "scopes": "", - }, - }, - expected: &OIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - ResourceURL: "http://empty-scopes-server.test-ns.svc.cluster.local:8080", - Scopes: nil, - }, - }, - { - name: "configmap not found", - resource: &testResource{ - name: "test-server", - namespace: "test-ns", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "missing-config", - }, - }, - }, - expectErr: true, - }, - { - name: "nil configmap ref returns nil", - resource: &testResource{ - name: "test-server", - namespace: "test-ns", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: nil, - }, - }, - expected: nil, - }, - { - name: "configmap with caBundleRef", - resource: &testResource{ - name: "test-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "oidc-config", - CABundleRef: &mcpv1alpha1.CABundleSource{ - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "ca-bundle"}, - }, - }, - }, - }, - }, - configMap: &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "oidc-config", - Namespace: "test-ns", - }, - Data: map[string]string{ - "issuer": "https://auth.example.com", - "audience": "test-audience", - }, - }, - expected: &OIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - ResourceURL: "http://test-server.test-ns.svc.cluster.local:8080", - ThvCABundlePath: "/config/certs/ca-bundle/ca.crt", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // Create fake client with ConfigMap if provided - scheme := runtime.NewScheme() - _ = corev1.AddToScheme(scheme) - _ = mcpv1alpha1.AddToScheme(scheme) - - var objects []runtime.Object - if tt.configMap != nil { - objects = append(objects, tt.configMap) - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithRuntimeObjects(objects...). - Build() - - resolver := NewResolver(fakeClient) - config, err := resolver.Resolve(context.Background(), tt.resource) - - if tt.expectErr { - assert.Error(t, err) - } else { - require.NoError(t, err) - assert.Equal(t, tt.expected, config) - } - }) - } -} - -func TestResolve_InlineType(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - resource *testResource - expected *OIDCConfig - }{ - { - name: "inline with all values", - resource: &testResource{ - name: "test-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://inline.example.com", - Audience: "inline-audience", - JWKSURL: "https://inline.example.com/.well-known/jwks.json", - IntrospectionURL: "https://inline.example.com/introspect", - ClientID: "inline-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ - Name: "inline-secret", - Key: "client-secret", - }, - CABundleRef: &mcpv1alpha1.CABundleSource{ - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "inline-ca"}, - }, - }, - JWKSAuthTokenPath: "/etc/auth/inline-token", - JWKSAllowPrivateIP: true, - ProtectedResourceAllowPrivateIP: true, - }, - }, - }, - expected: &OIDCConfig{ - Issuer: "https://inline.example.com", - Audience: "inline-audience", - JWKSURL: "https://inline.example.com/.well-known/jwks.json", - IntrospectionURL: "https://inline.example.com/introspect", - ClientID: "inline-client", - ThvCABundlePath: "/config/certs/inline-ca/ca.crt", - JWKSAuthTokenPath: "/etc/auth/inline-token", - ResourceURL: "http://test-server.test-ns.svc.cluster.local:8080", - JWKSAllowPrivateIP: true, - ProtectedResourceAllowPrivateIP: true, - }, - }, - { - name: "inline with custom resource URL", - resource: &testResource{ - name: "custom-server", - namespace: "custom-ns", - proxyPort: 9090, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - ResourceURL: "https://custom-resource.example.com", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://inline.example.com", - Audience: "inline-audience", - }, - }, - }, - expected: &OIDCConfig{ - Issuer: "https://inline.example.com", - Audience: "inline-audience", - ResourceURL: "https://custom-resource.example.com", - }, - }, - { - name: "inline with insecureAllowHTTP enabled", - resource: &testResource{ - name: "insecure-inline-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "http://localhost:8080/realms/test", - Audience: "test-audience", - JWKSURL: "http://localhost:8080/realms/test/protocol/openid-connect/certs", - JWKSAllowPrivateIP: true, - InsecureAllowHTTP: true, - }, - }, - }, - expected: &OIDCConfig{ - Issuer: "http://localhost:8080/realms/test", - Audience: "test-audience", - JWKSURL: "http://localhost:8080/realms/test/protocol/openid-connect/certs", - ResourceURL: "http://insecure-inline-server.test-ns.svc.cluster.local:8080", - JWKSAllowPrivateIP: true, - InsecureAllowHTTP: true, - }, - }, - { - name: "inline with protectedResourceAllowPrivateIP independent of jwksAllowPrivateIP", - resource: &testResource{ - name: "protected-resource-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - ProtectedResourceAllowPrivateIP: true, - JWKSAllowPrivateIP: false, - }, - }, - }, - expected: &OIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - ResourceURL: "http://protected-resource-server.test-ns.svc.cluster.local:8080", - ProtectedResourceAllowPrivateIP: true, - JWKSAllowPrivateIP: false, - }, - }, - { - name: "inline with scopes", - resource: &testResource{ - name: "scopes-inline-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - Scopes: []string{"openid", "profile", "email"}, - }, - }, - }, - expected: &OIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - ResourceURL: "http://scopes-inline-server.test-ns.svc.cluster.local:8080", - Scopes: []string{"openid", "profile", "email"}, - }, - }, - { - name: "nil inline config returns nil", - resource: &testResource{ - name: "test-server", - namespace: "test-ns", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: nil, - }, - }, - expected: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - resolver := NewResolver(nil) - config, err := resolver.Resolve(context.Background(), tt.resource) - require.NoError(t, err) - assert.Equal(t, tt.expected, config) - }) - } -} - -func TestResolve_UnknownType(t *testing.T) { - t.Parallel() - - res := &testResource{ - name: "test-server", - namespace: "test-ns", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "unknown-type", - }, - } - - resolver := NewResolver(nil) - config, err := resolver.Resolve(context.Background(), res) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown OIDC config type") - assert.Nil(t, config) -} - -func TestResolve_NoClientForConfigMap(t *testing.T) { - t.Parallel() - - res := &testResource{ - name: "test-server", - namespace: "test-ns", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapOIDCRef{ - Name: "test-config", - }, - }, - } - - resolver := NewResolver(nil) // No client provided - config, err := resolver.Resolve(context.Background(), res) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "kubernetes client is required") - assert.Nil(t, config) -} - -func TestResolve_InlineWithClientSecretRef(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - resource *testResource - expected *OIDCConfig - }{ - { - name: "clientSecretRef set - clientSecret should be empty", - resource: &testResource{ - name: "test-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - Audience: "test-audience", - ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ - Name: "oidc-secret", - Key: "client-secret", - }, - }, - }, - }, - expected: &OIDCConfig{ - Issuer: "https://example.com", - Audience: "test-audience", - ClientID: "test-client", - ClientSecret: "", // Should be empty when ClientSecretRef is set - ResourceURL: "http://test-server.test-ns.svc.cluster.local:8080", - }, - }, - { - name: "clientSecretRef with clientID - clientSecret not in resolved config", - resource: &testResource{ - name: "test-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - Audience: "test-audience", - ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ - Name: "oidc-secret", - Key: "client-secret", - }, - }, - }, - }, - expected: &OIDCConfig{ - Issuer: "https://example.com", - Audience: "test-audience", - ClientID: "test-client", - ResourceURL: "http://test-server.test-ns.svc.cluster.local:8080", - }, - }, - { - name: "no clientSecretRef - clientSecret empty in resolved config", - resource: &testResource{ - name: "test-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - Audience: "test-audience", - ClientID: "test-client", - }, - }, - }, - expected: &OIDCConfig{ - Issuer: "https://example.com", - Audience: "test-audience", - ClientID: "test-client", - ResourceURL: "http://test-server.test-ns.svc.cluster.local:8080", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - resolver := NewResolver(nil) - config, err := resolver.Resolve(context.Background(), tt.resource) - require.NoError(t, err) - assert.Equal(t, tt.expected, config) - }) - } -} - -func TestResolve_InlineWithCABundleRef(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - resource *testResource - expected *OIDCConfig - expectErr bool - errMsg string - }{ - { - name: "valid caBundleRef", - resource: &testResource{ - name: "test-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - Audience: "test-audience", - CABundleRef: &mcpv1alpha1.CABundleSource{ - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "ca-bundle"}, - }, - }, - }, - }, - }, - expected: &OIDCConfig{ - Issuer: "https://example.com", - Audience: "test-audience", - ResourceURL: "http://test-server.test-ns.svc.cluster.local:8080", - ThvCABundlePath: "/config/certs/ca-bundle/ca.crt", - }, - }, - { - name: "caBundleRef without configMapRef fails", - resource: &testResource{ - name: "test-server", - namespace: "test-ns", - proxyPort: 8080, - oidcConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - Audience: "test-audience", - CABundleRef: &mcpv1alpha1.CABundleSource{}, - }, - }, - }, - expectErr: true, - errMsg: "configMapRef must be specified in caBundleRef", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - resolver := NewResolver(nil) - config, err := resolver.Resolve(context.Background(), tt.resource) - - if tt.expectErr { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errMsg) - } else { - require.NoError(t, err) - assert.Equal(t, tt.expected, config) - } - }) - } -} - -func TestParseCommaSeparatedList(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - expected []string - }{ - { - name: "empty string", - input: "", - expected: nil, - }, - { - name: "single value", - input: "scope1", - expected: []string{"scope1"}, - }, - { - name: "multiple values", - input: "scope1,scope2,scope3", - expected: []string{"scope1", "scope2", "scope3"}, - }, - { - name: "values with whitespace", - input: " scope1 , scope2 , scope3 ", - expected: []string{"scope1", "scope2", "scope3"}, - }, - { - name: "values with URLs", - input: "https://www.googleapis.com/auth/drive.readonly,https://www.googleapis.com/auth/documents.readonly", - expected: []string{"https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/documents.readonly"}, - }, - { - name: "empty values filtered out", - input: "scope1,,scope2, ,scope3", - expected: []string{"scope1", "scope2", "scope3"}, - }, - { - name: "only commas and whitespace", - input: ", , , ", - expected: nil, - }, - { - name: "single whitespace-only value", - input: " ", - expected: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - result := parseCommaSeparatedList(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go index af3120f259..aad297da44 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go @@ -36,7 +36,6 @@ func newNoOpMockResolver(t *testing.T) *oidcmocks.MockResolver { t.Helper() ctrl := gomock.NewController(t) mockResolver := oidcmocks.NewMockResolver(ctrl) - mockResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() return mockResolver } diff --git a/docs/arch/04-secrets-management.md b/docs/arch/04-secrets-management.md index 526c849b15..7d1e412916 100644 --- a/docs/arch/04-secrets-management.md +++ b/docs/arch/04-secrets-management.md @@ -78,8 +78,8 @@ OAuth/OIDC client secrets are stored in Kubernetes Secrets and referenced using - **Implementation**: `cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go` - **Secret injection**: `cmd/thv-operator/pkg/controllerutil/tokenexchange.go` -2. **OIDC Authentication (InlineOIDCConfig)**: OIDC client secrets for token introspection - - **CRD field**: `InlineOIDCConfig.ClientSecretRef` in `cmd/thv-operator/api/v1alpha1/mcpserver_types.go` +2. **OIDC Authentication (MCPOIDCConfig)**: OIDC client secrets for token introspection + - **CRD field**: `InlineOIDCSharedConfig.ClientSecretRef` in `cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go` - **Secret injection**: `cmd/thv-operator/pkg/controllerutil/oidc.go` - **Runtime loading**: `pkg/auth/token.go` (via `TOOLHIVE_OIDC_CLIENT_SECRET` environment variable) diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 1a0214cc14..66a03fd325 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -919,8 +919,6 @@ CABundleSource defines a source for CA certificate bundles. _Appears in:_ -- [api.v1alpha1.ConfigMapOIDCRef](#apiv1alpha1configmapoidcref) -- [api.v1alpha1.InlineOIDCConfig](#apiv1alpha1inlineoidcconfig) - [api.v1alpha1.InlineOIDCSharedConfig](#apiv1alpha1inlineoidcsharedconfig) - [api.v1alpha1.MCPServerEntrySpec](#apiv1alpha1mcpserverentryspec) - [api.v1alpha1.MCPTelemetryOTelConfig](#apiv1alpha1mcptelemetryotelconfig) @@ -947,24 +945,6 @@ _Appears in:_ | `key` _string_ | Key is the key in the ConfigMap that contains the authorization configuration | authz.json | Optional: \{\}
| -#### api.v1alpha1.ConfigMapOIDCRef - - - -ConfigMapOIDCRef references a ConfigMap containing OIDC configuration - - - -_Appears in:_ -- [api.v1alpha1.OIDCConfigRef](#apiv1alpha1oidcconfigref) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _string_ | Name is the name of the ConfigMap | | Required: \{\}
| -| `key` _string_ | Key is the key in the ConfigMap that contains the OIDC configuration | oidc.json | Optional: \{\}
| -| `caBundleRef` _[api.v1alpha1.CABundleSource](#apiv1alpha1cabundlesource)_ | CABundleRef references a ConfigMap containing the CA certificate bundle.
When specified, ToolHive auto-mounts the ConfigMap and auto-computes ThvCABundlePath.
If the ConfigMap data contains an explicit thvCABundlePath key, it takes precedence. | | Optional: \{\}
| - - #### api.v1alpha1.EmbeddedAuthServerConfig @@ -1305,33 +1285,6 @@ _Appears in:_ | `entitiesJson` _string_ | EntitiesJSON is a JSON string representing Cedar entities | [] | Optional: \{\}
| -#### api.v1alpha1.InlineOIDCConfig - - - -InlineOIDCConfig contains direct OIDC configuration - - - -_Appears in:_ -- [api.v1alpha1.OIDCConfigRef](#apiv1alpha1oidcconfigref) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `issuer` _string_ | Issuer is the OIDC issuer URL | | Required: \{\}
| -| `audience` _string_ | Audience is the expected audience for the token | | Optional: \{\}
| -| `jwksUrl` _string_ | JWKSURL is the URL to fetch the JWKS from | | Optional: \{\}
| -| `introspectionUrl` _string_ | IntrospectionURL is the URL for token introspection endpoint | | Optional: \{\}
| -| `clientId` _string_ | ClientID is the OIDC client ID | | Optional: \{\}
| -| `clientSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | ClientSecretRef is a reference to a Kubernetes Secret containing the client secret | | Optional: \{\}
| -| `caBundleRef` _[api.v1alpha1.CABundleSource](#apiv1alpha1cabundlesource)_ | CABundleRef references a ConfigMap containing the CA certificate bundle.
When specified, ToolHive auto-mounts the ConfigMap and auto-computes the CA bundle path. | | Optional: \{\}
| -| `jwksAuthTokenPath` _string_ | JWKSAuthTokenPath is the path to file containing bearer token for JWKS/OIDC requests
The file must be mounted into the pod (e.g., via Secret volume) | | Optional: \{\}
| -| `jwksAllowPrivateIP` _boolean_ | JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses.
Use with caution - only enable for trusted internal IDPs.
Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP
is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). | false | Optional: \{\}
| -| `protectedResourceAllowPrivateIP` _boolean_ | ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses.
Use with caution - only enable for trusted internal IDPs or testing.
Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP
is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). | false | Optional: \{\}
| -| `insecureAllowHTTP` _boolean_ | InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing
WARNING: This is insecure and should NEVER be used in production
Only enable for local development, testing, or trusted internal networks | false | Optional: \{\}
| -| `scopes` _string array_ | Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)
If empty, defaults to ["openid"] | | Optional: \{\}
| - - #### api.v1alpha1.InlineOIDCSharedConfig @@ -1359,28 +1312,6 @@ _Appears in:_ | `insecureAllowHTTP` _boolean_ | InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing.
WARNING: This is insecure and should NEVER be used in production. | false | Optional: \{\}
| -#### api.v1alpha1.KubernetesOIDCConfig - - - -KubernetesOIDCConfig configures OIDC for Kubernetes service account token validation - - - -_Appears in:_ -- [api.v1alpha1.OIDCConfigRef](#apiv1alpha1oidcconfigref) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `serviceAccount` _string_ | ServiceAccount is the name of the service account to validate tokens for
If empty, uses the pod's service account | | Optional: \{\}
| -| `namespace` _string_ | Namespace is the namespace of the service account
If empty, uses the MCPServer's namespace | | Optional: \{\}
| -| `audience` _string_ | Audience is the expected audience for the token | toolhive | Optional: \{\}
| -| `issuer` _string_ | Issuer is the OIDC issuer URL | https://kubernetes.default.svc | Optional: \{\}
| -| `jwksUrl` _string_ | JWKSURL is the URL to fetch the JWKS from
If empty, OIDC discovery will be used to automatically determine the JWKS URL | | Optional: \{\}
| -| `introspectionUrl` _string_ | IntrospectionURL is the URL for token introspection endpoint
If empty, OIDC discovery will be used to automatically determine the introspection URL | | Optional: \{\}
| -| `useClusterAuth` _boolean_ | UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token
When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification
and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication
Defaults to true if not specified | | Optional: \{\}
| - - #### api.v1alpha1.KubernetesServiceAccountOIDCConfig @@ -2474,8 +2405,6 @@ _Appears in:_ | `tokenResponseMapping` _[api.v1alpha1.TokenResponseMapping](#apiv1alpha1tokenresponsemapping)_ | TokenResponseMapping configures custom field extraction from non-standard token responses.
Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths
instead of returning them at the top level. When set, ToolHive performs the token
exchange HTTP call directly and extracts fields using the configured dot-notation paths.
If nil, standard OAuth 2.0 token response parsing is used. | | Optional: \{\}
| - - #### api.v1alpha1.OIDCUpstreamConfig @@ -2848,7 +2777,6 @@ _Appears in:_ - [api.v1alpha1.EmbeddingServerSpec](#apiv1alpha1embeddingserverspec) - [api.v1alpha1.HeaderFromSecret](#apiv1alpha1headerfromsecret) - [api.v1alpha1.HeaderInjectionConfig](#apiv1alpha1headerinjectionconfig) -- [api.v1alpha1.InlineOIDCConfig](#apiv1alpha1inlineoidcconfig) - [api.v1alpha1.InlineOIDCSharedConfig](#apiv1alpha1inlineoidcsharedconfig) - [api.v1alpha1.OAuth2UpstreamConfig](#apiv1alpha1oauth2upstreamconfig) - [api.v1alpha1.OIDCUpstreamConfig](#apiv1alpha1oidcupstreamconfig) diff --git a/docs/operator/virtualmcpserver-api.md b/docs/operator/virtualmcpserver-api.md index 56888d3fd3..8249045c4c 100644 --- a/docs/operator/virtualmcpserver-api.md +++ b/docs/operator/virtualmcpserver-api.md @@ -78,11 +78,10 @@ Configures authentication for clients connecting to the Virtual MCP server. Reus - `type` (string, required): Authentication type. Must be explicitly specified. - `anonymous`: No authentication required (use this when no auth is needed) - `oidc`: OIDC/OAuth2 authentication -- `oidcConfigRef` (MCPOIDCConfigReference, optional): Reference to a shared MCPOIDCConfig resource (preferred, required when type=oidc). Mutually exclusive with `oidcConfig`. +- `oidcConfigRef` (MCPOIDCConfigReference, optional): Reference to a shared MCPOIDCConfig resource (required when type=oidc). - `name` (string, required): Name of the MCPOIDCConfig resource (same namespace) - `audience` (string, required): Must be unique per server to prevent token replay - `scopes` ([]string, optional): Defaults to `["openid"]` -- `oidcConfig` (OIDCConfigRef, optional): Inline OIDC authentication configuration. **Deprecated**: use `oidcConfigRef` with an MCPOIDCConfig resource instead. Will be removed in v1beta1. Mutually exclusive with `oidcConfigRef` (CEL enforced). - `authzConfig` (AuthzConfigRef, optional): Authorization policy configuration **Important**: The `type` field must always be explicitly specified. When no authentication is required, use `type: anonymous`. @@ -115,27 +114,6 @@ spec: ); ``` -**Example (OIDC auth with inline config — deprecated)**: -```yaml -spec: - incomingAuth: - type: oidc - oidcConfig: - type: kubernetes - kubernetes: - audience: vmcp - authzConfig: - type: inline - inline: - policies: - - | - permit( - principal, - action == Action::"tools/call", - resource - ); -``` - ### `.spec.outgoingAuth` (optional) Configures authentication from Virtual MCP to backend MCPServers. From afce182b62e72fe1eb780c1f682ceb0cb23cf263 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:29:54 +0100 Subject: [PATCH 043/117] Add color annotations to agent definitions (#4853) adds some clours to agents Signed-off-by: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> --- .claude/agents/code-reviewer.md | 1 + .claude/agents/golang-code-writer.md | 1 + .claude/agents/kubernetes-expert.md | 1 + .claude/agents/toolhive-expert.md | 1 + 4 files changed, 4 insertions(+) diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md index 4452818f61..d912cea410 100644 --- a/.claude/agents/code-reviewer.md +++ b/.claude/agents/code-reviewer.md @@ -3,6 +3,7 @@ name: code-reviewer description: Reviews code for ToolHive best practices, security patterns, Go conventions, and architectural consistency tools: [Read, Glob, Grep] model: inherit +color: yellow --- # Code Reviewer Agent diff --git a/.claude/agents/golang-code-writer.md b/.claude/agents/golang-code-writer.md index a3cd94e49c..900914c136 100644 --- a/.claude/agents/golang-code-writer.md +++ b/.claude/agents/golang-code-writer.md @@ -4,6 +4,7 @@ description: Write, generate, or create new Go code — functions, structs, inte tools: [Read, Write, Edit, Glob, Grep, Bash] permissionMode: acceptEdits model: inherit +color: blue --- # Go Code Writer Agent diff --git a/.claude/agents/kubernetes-expert.md b/.claude/agents/kubernetes-expert.md index c6ee1c02e4..327ca3438c 100644 --- a/.claude/agents/kubernetes-expert.md +++ b/.claude/agents/kubernetes-expert.md @@ -3,6 +3,7 @@ name: kubernetes-expert description: Specialized in Kubernetes operator patterns, CRDs, controllers, and cloud-native architecture for ToolHive tools: [Read, Write, Edit, Glob, Grep, Bash, WebFetch] model: inherit +color: blue --- # Kubernetes Expert Agent diff --git a/.claude/agents/toolhive-expert.md b/.claude/agents/toolhive-expert.md index 934c350a65..625513aa46 100644 --- a/.claude/agents/toolhive-expert.md +++ b/.claude/agents/toolhive-expert.md @@ -2,6 +2,7 @@ name: toolhive-expert description: Codebase knowledge, navigation, and implementation guidance — use for understanding existing code and patterns tools: [Read, Glob, Grep, Bash] +color: green model: inherit --- From 9d5982de47d6fed7fbf2dc74727aab0fbb9a5381 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 15 Apr 2026 16:58:43 +0100 Subject: [PATCH 044/117] Add RoleClaimName to Cedar ConfigOptions (#4847) Add RoleClaimName field to ConfigOptions and wire it through to the Authorizer struct, complementing the existing GroupClaimName. This supports IdPs that separate group and role concepts (e.g. Entra ID "groups" vs "roles" claims). When empty (the default), no role extraction occurs -- backward compatible. The field is config plumbing only; actual extraction and deduplication with GroupClaimName is handled by #4768. E2E tested against real IDP tokens in a Kind cluster with the operator built from this branch: - Okta: Authorization server with a custom "groups" claim configured to emit group display names in access tokens. OIDC app uses authorization code flow with "mcpserver" as audience. RoleClaimName left empty -- confirms backward compat. JWT shape: { "groups": ["Everyone", "engineering"], "sub": "jakub@stacklok.com" } Cedar policy: permit(principal in THVGroup::"engineering", action, resource in MCP::"okta-group-test"); - Entra ID: App registration with two app roles ("mcp-admin", "developer") assigned to a test user. The access token carries roles but no "groups" claim (no group claim in token config). RoleClaimName set to "roles". JWT shape: { "roles": ["mcp-admin", "developer"], "sub": "cljQrWT58zYW..." } Cedar policies: permit(principal in THVGroup::"developer", action == Action::"list_tools", resource in MCP::"entra-role-test"); permit(principal in THVGroup::"mcp-admin", action == Action::"call_tool", resource in MCP::"entra-role-test") when { resource.name == "echo" }; Closes #4763 Co-authored-by: Claude Opus 4.6 --- pkg/authz/authorizers/cedar/core.go | 10 +++ pkg/authz/authorizers/cedar/core_test.go | 104 ++++++++++++++++++++--- 2 files changed, 102 insertions(+), 12 deletions(-) diff --git a/pkg/authz/authorizers/cedar/core.go b/pkg/authz/authorizers/cedar/core.go index bc1eb0a964..4c173085b4 100644 --- a/pkg/authz/authorizers/cedar/core.go +++ b/pkg/authz/authorizers/cedar/core.go @@ -164,6 +164,9 @@ type Authorizer struct { // groupClaimName is the JWT claim key that contains group membership. // When empty, the well-known defaults are checked ("groups", "roles", etc.). groupClaimName string + // roleClaimName is the JWT claim key that contains role membership. + // When empty, no role extraction is performed (backward compatible). + roleClaimName string // claimKeyLog rate-limits the diagnostic log of resolved JWT claim keys // so it emits at most once per 30 seconds instead of once per authorization check. claimKeyLog *syncutil.AtMost @@ -189,6 +192,12 @@ type ConfigOptions struct { // under a URI-style claim (e.g. "https://example.com/groups" in Auth0/Okta). // When empty, only the well-known claim names are checked. GroupClaimName string `json:"group_claim_name,omitempty" yaml:"group_claim_name,omitempty"` + + // RoleClaimName is the JWT claim key that contains role membership for the + // principal. When set, the claim is extracted separately from GroupClaimName + // and both are mapped to Cedar THVGroup entities. + // When empty, no role extraction is performed (backward compatible). + RoleClaimName string `json:"role_claim_name,omitempty" yaml:"role_claim_name,omitempty"` } // NewCedarAuthorizer creates a new Cedar authorizer. @@ -199,6 +208,7 @@ func NewCedarAuthorizer(options ConfigOptions) (authorizers.Authorizer, error) { entityFactory: NewEntityFactory(), primaryUpstreamProvider: options.PrimaryUpstreamProvider, groupClaimName: options.GroupClaimName, + roleClaimName: options.RoleClaimName, claimKeyLog: syncutil.NewAtMost(30 * time.Second), } diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index f3542408c0..92976cbff6 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -5,6 +5,7 @@ package cedar import ( "context" + "encoding/json" "testing" "github.com/golang-jwt/jwt/v5" @@ -32,11 +33,13 @@ func TestNewCedarAuthorizer(t *testing.T) { // Test cases testCases := []struct { - name string - policies []string - entitiesJSON string - expectError bool - errorType error + name string + policies []string + entitiesJSON string + roleClaimName string + expectError bool + errorType error + wantRoleClaimName string }{ { name: "Valid policy and empty entities", @@ -75,6 +78,22 @@ func TestNewCedarAuthorizer(t *testing.T) { entitiesJSON: `[{"uid": {"type": "User", "id": "alice"}, "attrs": {}, "parents": []}]`, expectError: false, }, + { + name: "Stores configured role claim", + policies: []string{`permit(principal, action, resource);`}, + entitiesJSON: `[]`, + roleClaimName: "roles", + expectError: false, + wantRoleClaimName: "roles", + }, + { + name: "Stores URI-style role claim", + policies: []string{`permit(principal, action, resource);`}, + entitiesJSON: `[]`, + roleClaimName: "https://example.com/roles", + expectError: false, + wantRoleClaimName: "https://example.com/roles", + }, } // Run test cases @@ -83,8 +102,9 @@ func TestNewCedarAuthorizer(t *testing.T) { t.Parallel() // Create a Cedar authorizer authorizer, err := NewCedarAuthorizer(ConfigOptions{ - Policies: tc.policies, - EntitiesJSON: tc.entitiesJSON, + Policies: tc.policies, + EntitiesJSON: tc.entitiesJSON, + RoleClaimName: tc.roleClaimName, }) // Check error expectations @@ -97,6 +117,10 @@ func TestNewCedarAuthorizer(t *testing.T) { } else { assert.NoError(t, err, "Unexpected error: %v", err) require.NotNil(t, authorizer, "Cedar authorizer is nil") + + cedarAuthz, ok := authorizer.(*Authorizer) + require.True(t, ok) + assert.Equal(t, tc.wantRoleClaimName, cedarAuthz.roleClaimName) } }) } @@ -1563,11 +1587,12 @@ func TestInjectUpstreamProvider(t *testing.T) { }, }, { - // GroupClaimName must survive the serialise→deserialise round-trip - // that InjectUpstreamProvider performs internally. A refactor that - // reconstructed ConfigOptions from scratch (populating only known - // fields) would silently drop GroupClaimName without this test. - name: "group_claim_name_preserved_after_inject", + // GroupClaimName and RoleClaimName must survive the + // serialise→deserialise round-trip that InjectUpstreamProvider + // performs internally. A refactor that reconstructed ConfigOptions + // from scratch (populating only known fields) would silently drop + // these claim name fields without this test. + name: "claim_names_preserved_after_inject", setup: func(t *testing.T) *authorizers.Config { t.Helper() cfg, err := authorizers.NewConfig(Config{ @@ -1577,6 +1602,7 @@ func TestInjectUpstreamProvider(t *testing.T) { Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: "[]", GroupClaimName: "https://example.com/groups", + RoleClaimName: "https://example.com/roles", }, }) require.NoError(t, err) @@ -1590,6 +1616,8 @@ func TestInjectUpstreamProvider(t *testing.T) { require.NoError(t, err) assert.Equal(t, "https://example.com/groups", extracted.Options.GroupClaimName, "GroupClaimName must be unchanged after InjectUpstreamProvider") + assert.Equal(t, "https://example.com/roles", extracted.Options.RoleClaimName, + "RoleClaimName must be unchanged after InjectUpstreamProvider") assert.Equal(t, "my-provider", extracted.Options.PrimaryUpstreamProvider, "PrimaryUpstreamProvider must be set by InjectUpstreamProvider") }, @@ -1780,3 +1808,55 @@ func TestExtractGroupsFromClaims(t *testing.T) { }) } } + +// TestConfigOptionsRoleClaimNameJSON verifies JSON marshal/unmarshal of the +// RoleClaimName field, including backward compatibility when the field is absent. +func TestConfigOptionsRoleClaimNameJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + jsonInput string + wantRole string + wantOmitOnMar bool // when true, marshal output must NOT contain "role_claim_name" + }{ + { + name: "present", + jsonInput: `{"policies":["permit(principal,action,resource);"],"role_claim_name":"roles"}`, + wantRole: "roles", + wantOmitOnMar: false, + }, + { + name: "absent_gives_empty_string", + jsonInput: `{"policies":["permit(principal,action,resource);"]}`, + wantRole: "", + wantOmitOnMar: true, + }, + { + name: "uri_style_claim", + jsonInput: `{"policies":["permit(principal,action,resource);"],"role_claim_name":"https://example.com/roles"}`, + wantRole: "https://example.com/roles", + wantOmitOnMar: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var opts ConfigOptions + err := json.Unmarshal([]byte(tt.jsonInput), &opts) + require.NoError(t, err) + assert.Equal(t, tt.wantRole, opts.RoleClaimName) + + marshalled, err := json.Marshal(opts) + require.NoError(t, err) + if tt.wantOmitOnMar { + assert.NotContains(t, string(marshalled), "role_claim_name", + "empty RoleClaimName must be omitted from JSON output") + } else { + assert.Contains(t, string(marshalled), "role_claim_name") + } + }) + } +} From bb213f1652f384efab93f2d4d543e3369705a1b6 Mon Sep 17 00:00:00 2001 From: Michelangelo Mori <328978+blkt@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:51:34 +0200 Subject: [PATCH 045/117] Return error from `UpdateConfig` callbacks (#4845) Change the `UpdateConfig` callback signature from `func(*Config)` to `func(*Config) error` so that future callers can propagate validation or transformation errors from inside the closure instead of relying on side-channel error handling. All existing closures return nil. `UpdateConfigAtPath` now short-circuits before writing to disk when the callback returns an error. --- cmd/thv/app/client.go | 10 +++--- cmd/thv/app/common.go | 3 +- cmd/thv/app/config.go | 3 +- cmd/thv/app/otel.go | 42 ++++++++++++++++--------- cmd/thv/app/run_flags_test.go | 2 +- pkg/api/v1/clients.go | 10 +++--- pkg/api/v1/registry_test.go | 2 +- pkg/api/v1/secrets.go | 3 +- pkg/client/config_test.go | 2 +- pkg/client/discovery_test.go | 2 +- pkg/config/buildauthfile.go | 9 ++++-- pkg/config/buildenv.go | 23 +++++++++----- pkg/config/cacert.go | 6 ++-- pkg/config/config.go | 11 ++++--- pkg/config/config_test.go | 15 ++++++--- pkg/config/interface.go | 8 ++--- pkg/config/interface_test.go | 9 ++++-- pkg/config/mocks/mock_provider.go | 2 +- pkg/config/registry.go | 12 ++++--- pkg/migration/middleware_telemetry.go | 3 +- pkg/migration/secret_scope.go | 3 +- pkg/migration/telemetry_config.go | 3 +- pkg/registry/auth/login.go | 12 ++++--- pkg/registry/auth/login_test.go | 16 +++++----- pkg/registry/auth/oauth_token_source.go | 3 +- pkg/registry/auth_manager.go | 3 +- pkg/registry/auth_manager_test.go | 4 +-- test/e2e/client_test.go | 3 +- 28 files changed, 141 insertions(+), 83 deletions(-) diff --git a/cmd/thv/app/client.go b/cmd/thv/app/client.go index b6b807e795..f1ce822fd3 100644 --- a/cmd/thv/app/client.go +++ b/cmd/thv/app/client.go @@ -292,15 +292,16 @@ func registerClientsGlobally( ) error { for _, clientToRegister := range clients { // Update the global config to register the client - err := config.UpdateConfig(func(c *config.Config) { + err := config.UpdateConfig(func(c *config.Config) error { for _, registeredClient := range c.Clients.RegisteredClients { if registeredClient == string(clientToRegister.Name) { slog.Debug(fmt.Sprintf("Client %s is already registered, skipping...", clientToRegister.Name)) - return + return nil } } c.Clients.RegisteredClients = append(c.Clients.RegisteredClients, string(clientToRegister.Name)) + return nil }) if err != nil { return fmt.Errorf("failed to update configuration for client %s: %w", clientToRegister.Name, err) @@ -411,15 +412,16 @@ func removeClientGlobally( } // Remove client from global registered clients list - err = config.UpdateConfig(func(c *config.Config) { + err = config.UpdateConfig(func(c *config.Config) error { for i, registeredClient := range c.Clients.RegisteredClients { if registeredClient == string(clientToRemove.Name) { // Remove client from slice c.Clients.RegisteredClients = append(c.Clients.RegisteredClients[:i], c.Clients.RegisteredClients[i+1:]...) slog.Debug(fmt.Sprintf("Successfully unregistered client: %s", clientToRemove.Name)) - return + return nil } } + return nil }) if err != nil { return fmt.Errorf("failed to update configuration for client %s: %w", clientToRemove.Name, err) diff --git a/cmd/thv/app/common.go b/cmd/thv/app/common.go index 83cf55ef01..53766aaf13 100644 --- a/cmd/thv/app/common.go +++ b/cmd/thv/app/common.go @@ -78,9 +78,10 @@ func SetSecretsProvider(ctx context.Context, provider secrets.ProviderType) erro } // Update the secrets provider type and mark setup as completed - err := config.UpdateConfig(func(c *config.Config) { + err := config.UpdateConfig(func(c *config.Config) error { c.Secrets.ProviderType = string(provider) c.Secrets.SetupCompleted = true + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) diff --git a/cmd/thv/app/config.go b/cmd/thv/app/config.go index 07ab52c320..517591ed9d 100644 --- a/cmd/thv/app/config.go +++ b/cmd/thv/app/config.go @@ -344,8 +344,9 @@ func usageMetricsCmdFunc(_ *cobra.Command, args []string) error { return fmt.Errorf("invalid argument: %s (expected 'enable' or 'disable')", action) } - err := config.UpdateConfig(func(c *config.Config) { + err := config.UpdateConfig(func(c *config.Config) error { c.DisableUsageMetrics = disable + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) diff --git a/cmd/thv/app/otel.go b/cmd/thv/app/otel.go index 30729f909c..6da7b6b497 100644 --- a/cmd/thv/app/otel.go +++ b/cmd/thv/app/otel.go @@ -235,8 +235,9 @@ func setOtelEndpointCmdFunc(_ *cobra.Command, args []string) error { } // Update the configuration - err := config.UpdateConfig(func(c *config.Config) { + err := config.UpdateConfig(func(c *config.Config) error { c.OTEL.Endpoint = endpoint + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -269,8 +270,9 @@ func unsetOtelEndpointCmdFunc(_ *cobra.Command, _ []string) error { } // Update the configuration - err := config.UpdateConfig(func(c *config.Config) { + err := config.UpdateConfig(func(c *config.Config) error { c.OTEL.Endpoint = "" + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -292,8 +294,9 @@ func setOtelSamplingRateCmdFunc(_ *cobra.Command, args []string) error { } // Update the configuration - err = config.UpdateConfig(func(c *config.Config) { + err = config.UpdateConfig(func(c *config.Config) error { c.OTEL.SamplingRate = rate + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -326,8 +329,9 @@ func unsetOtelSamplingRateCmdFunc(_ *cobra.Command, _ []string) error { } // Update the configuration - err := config.UpdateConfig(func(c *config.Config) { + err := config.UpdateConfig(func(c *config.Config) error { c.OTEL.SamplingRate = 0.0 + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -346,8 +350,9 @@ func setOtelEnvVarsCmdFunc(_ *cobra.Command, args []string) error { } // Update the configuration - err := config.UpdateConfig(func(c *config.Config) { + err := config.UpdateConfig(func(c *config.Config) error { c.OTEL.EnvVars = vars + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -380,8 +385,9 @@ func unsetOtelEnvVarsCmdFunc(_ *cobra.Command, _ []string) error { } // Update the configuration - err := config.UpdateConfig(func(c *config.Config) { + err := config.UpdateConfig(func(c *config.Config) error { c.OTEL.EnvVars = []string{} + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -398,8 +404,9 @@ func setOtelMetricsEnabledCmdFunc(_ *cobra.Command, args []string) error { } // Update the configuration - err = config.UpdateConfig(func(c *config.Config) { + err = config.UpdateConfig(func(c *config.Config) error { c.OTEL.MetricsEnabled = &enabled + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -428,8 +435,9 @@ func unsetOtelMetricsEnabledCmdFunc(_ *cobra.Command, _ []string) error { } // Update the configuration - err := config.UpdateConfig(func(c *config.Config) { + err := config.UpdateConfig(func(c *config.Config) error { c.OTEL.MetricsEnabled = nil + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -446,8 +454,9 @@ func setOtelTracingEnabledCmdFunc(_ *cobra.Command, args []string) error { } // Update the configuration - err = config.UpdateConfig(func(c *config.Config) { + err = config.UpdateConfig(func(c *config.Config) error { c.OTEL.TracingEnabled = &enabled + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -476,8 +485,9 @@ func unsetOtelTracingEnabledCmdFunc(_ *cobra.Command, _ []string) error { } // Update the configuration - err := config.UpdateConfig(func(c *config.Config) { + err := config.UpdateConfig(func(c *config.Config) error { c.OTEL.TracingEnabled = nil + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -494,8 +504,9 @@ func setOtelInsecureCmdFunc(_ *cobra.Command, args []string) error { } // Update the configuration - err = config.UpdateConfig(func(c *config.Config) { + err = config.UpdateConfig(func(c *config.Config) error { c.OTEL.Insecure = enabled + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -523,8 +534,9 @@ func unsetOtelInsecureCmdFunc(_ *cobra.Command, _ []string) error { } // Update the configuration - err := config.UpdateConfig(func(c *config.Config) { + err := config.UpdateConfig(func(c *config.Config) error { c.OTEL.Insecure = false + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -541,8 +553,9 @@ func setOtelEnablePrometheusMetricsPathCmdFunc(_ *cobra.Command, args []string) } // Update the configuration - err = config.UpdateConfig(func(c *config.Config) { + err = config.UpdateConfig(func(c *config.Config) error { c.OTEL.EnablePrometheusMetricsPath = enabled + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -570,8 +583,9 @@ func unsetOtelEnablePrometheusMetricsPathCmdFunc(_ *cobra.Command, _ []string) e } // Update the configuration - err := config.UpdateConfig(func(c *config.Config) { + err := config.UpdateConfig(func(c *config.Config) error { c.OTEL.EnablePrometheusMetricsPath = false + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) diff --git a/cmd/thv/app/run_flags_test.go b/cmd/thv/app/run_flags_test.go index b5fdc31ae7..d93153148d 100644 --- a/cmd/thv/app/run_flags_test.go +++ b/cmd/thv/app/run_flags_test.go @@ -43,7 +43,7 @@ func createTestConfigProvider(t *testing.T, cfg *config.Config) (config.Provider // Write the config file if one is provided if cfg != nil { - err = provider.UpdateConfig(func(c *config.Config) { *c = *cfg }) + err = provider.UpdateConfig(func(c *config.Config) error { *c = *cfg; return nil }) require.NoError(t, err) } diff --git a/pkg/api/v1/clients.go b/pkg/api/v1/clients.go index c6de6aeea7..8954e68f20 100644 --- a/pkg/api/v1/clients.go +++ b/pkg/api/v1/clients.go @@ -351,15 +351,16 @@ func (c *ClientRoutes) performClientRegistration(ctx context.Context, clients [] } else { // We should never reach this point once groups are enabled for _, clientToRegister := range clients { - err := config.UpdateConfig(func(c *config.Config) { + err := config.UpdateConfig(func(c *config.Config) error { for _, registeredClient := range c.Clients.RegisteredClients { if registeredClient == string(clientToRegister.Name) { slog.Debug("client already registered, skipping", "client", clientToRegister.Name) - return + return nil } } c.Clients.RegisteredClients = append(c.Clients.RegisteredClients, string(clientToRegister.Name)) + return nil }) if err != nil { return fmt.Errorf("failed to update configuration for client %s: %w", clientToRegister.Name, err) @@ -459,15 +460,16 @@ func (c *ClientRoutes) removeClientGlobally( // Remove clients from global registered clients list for _, clientToRemove := range clients { - err := config.UpdateConfig(func(c *config.Config) { + err := config.UpdateConfig(func(c *config.Config) error { for i, registeredClient := range c.Clients.RegisteredClients { if registeredClient == string(clientToRemove.Name) { // Remove client from slice c.Clients.RegisteredClients = append(c.Clients.RegisteredClients[:i], c.Clients.RegisteredClients[i+1:]...) slog.Debug("successfully unregistered client", "client", clientToRemove.Name) - return + return nil } } + return nil }) if err != nil { return fmt.Errorf("failed to update configuration for client %s: %w", clientToRemove.Name, err) diff --git a/pkg/api/v1/registry_test.go b/pkg/api/v1/registry_test.go index 3da903d7d2..10b5b7e326 100644 --- a/pkg/api/v1/registry_test.go +++ b/pkg/api/v1/registry_test.go @@ -41,7 +41,7 @@ func CreateTestConfigProvider(t *testing.T, cfg *config.Config) (config.Provider // Write the config file if one is provided if cfg != nil { - err = provider.UpdateConfig(func(c *config.Config) { *c = *cfg }) + err = provider.UpdateConfig(func(c *config.Config) error { *c = *cfg; return nil }) require.NoError(t, err) } diff --git a/pkg/api/v1/secrets.go b/pkg/api/v1/secrets.go index 6cb7c3d0c1..33771197fc 100644 --- a/pkg/api/v1/secrets.go +++ b/pkg/api/v1/secrets.go @@ -182,9 +182,10 @@ func (s *SecretsRoutes) setupSecretsProvider(w http.ResponseWriter, r *http.Requ } // Update the secrets provider type and mark setup as completed - err := s.configProvider.UpdateConfig(func(c *config.Config) { + err := s.configProvider.UpdateConfig(func(c *config.Config) error { c.Secrets.ProviderType = string(providerType) c.Secrets.SetupCompleted = true + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) diff --git a/pkg/client/config_test.go b/pkg/client/config_test.go index d401189cba..aa07e71475 100644 --- a/pkg/client/config_test.go +++ b/pkg/client/config_test.go @@ -249,7 +249,7 @@ func CreateTestConfigProvider(t *testing.T, cfg *config.Config) (config.Provider // Write the config file if one is provided if cfg != nil { - err = provider.UpdateConfig(func(c *config.Config) { *c = *cfg }) + err = provider.UpdateConfig(func(c *config.Config) error { *c = *cfg; return nil }) require.NoError(t, err) } diff --git a/pkg/client/discovery_test.go b/pkg/client/discovery_test.go index 6597575319..5bb5ed4b92 100644 --- a/pkg/client/discovery_test.go +++ b/pkg/client/discovery_test.go @@ -66,7 +66,7 @@ func createTestConfigProvider(t *testing.T, cfg *config.Config) (config.Provider // Write the config file if one is provided if cfg != nil { - err = provider.UpdateConfig(func(c *config.Config) { *c = *cfg }) + err = provider.UpdateConfig(func(c *config.Config) error { *c = *cfg; return nil }) require.NoError(t, err) } diff --git a/pkg/config/buildauthfile.go b/pkg/config/buildauthfile.go index 1b1c5f42b7..62d0a4f637 100644 --- a/pkg/config/buildauthfile.go +++ b/pkg/config/buildauthfile.go @@ -43,12 +43,13 @@ func markBuildAuthFileConfigured(p Provider, name string) error { return err } - return p.UpdateConfig(func(c *Config) { + return p.UpdateConfig(func(c *Config) error { if c.BuildAuthFiles == nil { c.BuildAuthFiles = make(map[string]string) } // Store only a marker - actual content is in secrets c.BuildAuthFiles[name] = "secret:" + BuildAuthFileSecretName(name) + return nil }) } @@ -81,10 +82,11 @@ func getConfiguredBuildAuthFiles(p Provider) []string { // Note: This only removes the config marker. The caller should also delete // the corresponding secret from the secrets provider. func unsetBuildAuthFile(p Provider, name string) error { - return p.UpdateConfig(func(c *Config) { + return p.UpdateConfig(func(c *Config) error { if c.BuildAuthFiles != nil { delete(c.BuildAuthFiles, name) } + return nil }) } @@ -92,7 +94,8 @@ func unsetBuildAuthFile(p Provider, name string) error { // Note: This only removes the config markers. The caller should also delete // the corresponding secrets from the secrets provider. func unsetAllBuildAuthFiles(p Provider) error { - return p.UpdateConfig(func(c *Config) { + return p.UpdateConfig(func(c *Config) error { c.BuildAuthFiles = nil + return nil }) } diff --git a/pkg/config/buildenv.go b/pkg/config/buildenv.go index 49b919d6fe..d71a3d8407 100644 --- a/pkg/config/buildenv.go +++ b/pkg/config/buildenv.go @@ -125,11 +125,12 @@ func setBuildEnv(p Provider, key, value string) error { return err } - return p.UpdateConfig(func(c *Config) { + return p.UpdateConfig(func(c *Config) error { if c.BuildEnv == nil { c.BuildEnv = make(map[string]string) } c.BuildEnv[key] = value + return nil }) } @@ -159,17 +160,19 @@ func getAllBuildEnv(p Provider) map[string]string { // unsetBuildEnv is a helper function that removes a specific build environment variable. func unsetBuildEnv(p Provider, key string) error { - return p.UpdateConfig(func(c *Config) { + return p.UpdateConfig(func(c *Config) error { if c.BuildEnv != nil { delete(c.BuildEnv, key) } + return nil }) } // unsetAllBuildEnv is a helper function that removes all build environment variables. func unsetAllBuildEnv(p Provider) error { - return p.UpdateConfig(func(c *Config) { + return p.UpdateConfig(func(c *Config) error { c.BuildEnv = nil + return nil }) } @@ -185,11 +188,12 @@ func setBuildEnvFromSecret(p Provider, key, secretName string) error { return err } - return p.UpdateConfig(func(c *Config) { + return p.UpdateConfig(func(c *Config) error { if c.BuildEnvFromSecrets == nil { c.BuildEnvFromSecrets = make(map[string]string) } c.BuildEnvFromSecrets[key] = secretName + return nil }) } @@ -218,10 +222,11 @@ func getAllBuildEnvFromSecrets(p Provider) map[string]string { // unsetBuildEnvFromSecret removes a secret reference. func unsetBuildEnvFromSecret(p Provider, key string) error { - return p.UpdateConfig(func(c *Config) { + return p.UpdateConfig(func(c *Config) error { if c.BuildEnvFromSecrets != nil { delete(c.BuildEnvFromSecrets, key) } + return nil }) } @@ -242,8 +247,9 @@ func setBuildEnvFromShell(p Provider, key string) error { return err } - return p.UpdateConfig(func(c *Config) { + return p.UpdateConfig(func(c *Config) error { c.BuildEnvFromShell = append(c.BuildEnvFromShell, key) + return nil }) } @@ -292,9 +298,9 @@ func getAllBuildEnvFromShell(p Provider) []string { // unsetBuildEnvFromShell removes a key from shell environment list. func unsetBuildEnvFromShell(p Provider, key string) error { - return p.UpdateConfig(func(c *Config) { + return p.UpdateConfig(func(c *Config) error { if c.BuildEnvFromShell == nil { - return + return nil } newList := make([]string, 0, len(c.BuildEnvFromShell)) for _, k := range c.BuildEnvFromShell { @@ -303,5 +309,6 @@ func unsetBuildEnvFromShell(p Provider, key string) error { } } c.BuildEnvFromShell = newList + return nil }) } diff --git a/pkg/config/cacert.go b/pkg/config/cacert.go index 332d365260..07fb6be312 100644 --- a/pkg/config/cacert.go +++ b/pkg/config/cacert.go @@ -37,8 +37,9 @@ func setCACert(provider Provider, certPath string) error { } // Update the configuration - err = provider.UpdateConfig(func(c *Config) { + err = provider.UpdateConfig(func(c *Config) error { c.CACertificatePath = cleanPath + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -86,8 +87,9 @@ func unsetCACert(provider Provider) error { } // Update the configuration - err := provider.UpdateConfig(func(c *Config) { + err := provider.UpdateConfig(func(c *Config) error { c.CACertificatePath = "" + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) diff --git a/pkg/config/config.go b/pkg/config/config.go index cbba6d1955..68ced5976f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -335,7 +335,7 @@ func (c *Config) saveToPath(configPath string) error { // UpdateConfig locks a separate lock file, reads from disk, applies the changes // from the anonymous function, writes to disk and unlocks the file. -func UpdateConfig(updateFn func(*Config)) error { +func UpdateConfig(updateFn func(*Config) error) error { provider := NewProvider() return provider.UpdateConfig(updateFn) } @@ -343,7 +343,7 @@ func UpdateConfig(updateFn func(*Config)) error { // UpdateConfigAtPath locks a separate lock file, reads from disk, applies the changes // from the anonymous function, writes to disk and unlocks the file. // If configPath is empty, it uses the default path. -func UpdateConfigAtPath(configPath string, updateFn func(*Config)) error { +func UpdateConfigAtPath(configPath string, updateFn func(*Config) error) error { if configPath == "" { var err error configPath, err = getConfigPath() @@ -375,7 +375,9 @@ func UpdateConfigAtPath(configPath string, updateFn func(*Config)) error { } // Apply changes to the config file. - updateFn(c) + if err := updateFn(c); err != nil { + return fmt.Errorf("update function failed: %w", err) + } // Write the updated config to disk. err = c.saveToPath(configPath) @@ -430,10 +432,11 @@ func setRuntimeConfig(provider Provider, transportType string, runtimeConfig *te } } - return provider.UpdateConfig(func(c *Config) { + return provider.UpdateConfig(func(c *Config) error { if c.RuntimeConfigs == nil { c.RuntimeConfigs = make(map[string]*templates.RuntimeConfig) } c.RuntimeConfigs[transportType] = runtimeConfig + return nil }) } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index ada88ef88f..c2094c60f4 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -160,8 +160,9 @@ func TestRegistryURLConfig(t *testing.T) { // Test setting a registry URL testURL := "https://example.com/registry.json" - err := UpdateConfigAtPath(configPath, func(c *Config) { + err := UpdateConfigAtPath(configPath, func(c *Config) error { c.RegistryUrl = testURL + return nil }) require.NoError(t, err) @@ -171,8 +172,9 @@ func TestRegistryURLConfig(t *testing.T) { assert.Equal(t, testURL, config.RegistryUrl) // Test unsetting the registry URL - err = UpdateConfigAtPath(configPath, func(c *Config) { + err = UpdateConfigAtPath(configPath, func(c *Config) error { c.RegistryUrl = "" + return nil }) require.NoError(t, err) @@ -195,8 +197,9 @@ func TestRegistryURLConfig(t *testing.T) { testURL := "https://custom-registry.example.com/registry.json" // Set the registry URL - err := UpdateConfigAtPath(configPath, func(c *Config) { + err := UpdateConfigAtPath(configPath, func(c *Config) error { c.RegistryUrl = testURL + return nil }) require.NoError(t, err) @@ -226,8 +229,9 @@ func TestRegistryURLConfig(t *testing.T) { }) // Test enabling - err := UpdateConfigAtPath(configPath, func(c *Config) { + err := UpdateConfigAtPath(configPath, func(c *Config) error { c.AllowPrivateRegistryIp = true + return nil }) require.NoError(t, err) @@ -237,8 +241,9 @@ func TestRegistryURLConfig(t *testing.T) { assert.Equal(t, true, config.AllowPrivateRegistryIp) // Test toggling setting to false - err = UpdateConfigAtPath(configPath, func(c *Config) { + err = UpdateConfigAtPath(configPath, func(c *Config) error { c.AllowPrivateRegistryIp = false + return nil }) require.NoError(t, err) diff --git a/pkg/config/interface.go b/pkg/config/interface.go index cfaa7019b2..5f44a4f9a5 100644 --- a/pkg/config/interface.go +++ b/pkg/config/interface.go @@ -13,7 +13,7 @@ import ( //go:generate mockgen -destination=mocks/mock_provider.go -package=mocks -source=interface.go Provider type Provider interface { GetConfig() *Config - UpdateConfig(updateFn func(*Config)) error + UpdateConfig(updateFn func(*Config) error) error LoadOrCreateConfig() (*Config, error) // Registry operations @@ -73,7 +73,7 @@ func (*DefaultProvider) GetConfig() *Config { } // UpdateConfig updates the config using the default path -func (*DefaultProvider) UpdateConfig(updateFn func(*Config)) error { +func (*DefaultProvider) UpdateConfig(updateFn func(*Config) error) error { return UpdateConfigAtPath("", updateFn) } @@ -244,7 +244,7 @@ func (p *PathProvider) GetConfig() *Config { } // UpdateConfig updates the config at the specific path -func (p *PathProvider) UpdateConfig(updateFn func(*Config)) error { +func (p *PathProvider) UpdateConfig(updateFn func(*Config) error) error { return UpdateConfigAtPath(p.configPath, updateFn) } @@ -409,7 +409,7 @@ func (*KubernetesProvider) GetConfig() *Config { } // UpdateConfig is a no-op for Kubernetes environments -func (*KubernetesProvider) UpdateConfig(_ func(*Config)) error { +func (*KubernetesProvider) UpdateConfig(_ func(*Config) error) error { return nil } diff --git a/pkg/config/interface_test.go b/pkg/config/interface_test.go index 2964f331c8..dfbd8868a8 100644 --- a/pkg/config/interface_test.go +++ b/pkg/config/interface_test.go @@ -79,8 +79,9 @@ func TestDefaultProvider(t *testing.T) { require.NoError(t, err) // Update config - err = pathProvider.UpdateConfig(func(c *Config) { + err = pathProvider.UpdateConfig(func(c *Config) error { c.RegistryUrl = "https://example.com" + return nil }) assert.NoError(t, err) @@ -165,8 +166,9 @@ func TestPathProvider(t *testing.T) { require.NoError(t, err) // Update config - err = provider.UpdateConfig(func(c *Config) { + err = provider.UpdateConfig(func(c *Config) error { c.RegistryUrl = "https://updated.com" + return nil }) assert.NoError(t, err) @@ -203,8 +205,9 @@ func TestKubernetesProvider(t *testing.T) { t.Run("UpdateConfig", func(t *testing.T) { t.Parallel() - err := provider.UpdateConfig(func(c *Config) { + err := provider.UpdateConfig(func(c *Config) error { c.RegistryUrl = "https://example.com" + return nil }) assert.NoError(t, err) // Should be no-op }) diff --git a/pkg/config/mocks/mock_provider.go b/pkg/config/mocks/mock_provider.go index 24433a750f..fb2cbf820c 100644 --- a/pkg/config/mocks/mock_provider.go +++ b/pkg/config/mocks/mock_provider.go @@ -471,7 +471,7 @@ func (mr *MockProviderMockRecorder) UnsetRegistry() *gomock.Call { } // UpdateConfig mocks base method. -func (m *MockProvider) UpdateConfig(updateFn func(*config.Config)) error { +func (m *MockProvider) UpdateConfig(updateFn func(*config.Config) error) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateConfig", updateFn) ret0, _ := ret[0].(error) diff --git a/pkg/config/registry.go b/pkg/config/registry.go index 6f9e76e89c..607793ab0d 100644 --- a/pkg/config/registry.go +++ b/pkg/config/registry.go @@ -274,11 +274,12 @@ func setRegistryURL(provider Provider, registryURL string, allowPrivateRegistryI } // Update the configuration - err = provider.UpdateConfig(func(c *Config) { + err = provider.UpdateConfig(func(c *Config) error { c.RegistryUrl = registryURL c.RegistryApiUrl = "" // Clear API URL when setting static URL c.LocalRegistryPath = "" // Clear local path when setting URL c.AllowPrivateRegistryIp = allowPrivateRegistryIp + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -320,10 +321,11 @@ func setRegistryFile(provider Provider, registryPath string) error { } // Update the configuration - err = provider.UpdateConfig(func(c *Config) { + err = provider.UpdateConfig(func(c *Config) error { c.LocalRegistryPath = absPath c.RegistryUrl = "" // Clear URL when setting local path c.RegistryApiUrl = "" // Clear API URL when setting local path + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -442,11 +444,12 @@ func setRegistryAPI(provider Provider, apiURL string, allowPrivateRegistryIp boo } // Update the configuration - err = provider.UpdateConfig(func(c *Config) { + err = provider.UpdateConfig(func(c *Config) error { c.RegistryApiUrl = apiURL c.RegistryUrl = "" // Clear static registry URL when setting API URL c.LocalRegistryPath = "" // Clear local path when setting API URL c.AllowPrivateRegistryIp = allowPrivateRegistryIp + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) @@ -457,11 +460,12 @@ func setRegistryAPI(provider Provider, apiURL string, allowPrivateRegistryIp boo // unsetRegistry resets registry configuration to defaults using the provided provider func unsetRegistry(provider Provider) error { - err := provider.UpdateConfig(func(c *Config) { + err := provider.UpdateConfig(func(c *Config) error { c.RegistryUrl = "" c.RegistryApiUrl = "" c.LocalRegistryPath = "" c.AllowPrivateRegistryIp = false + return nil }) if err != nil { return fmt.Errorf("failed to update configuration: %w", err) diff --git a/pkg/migration/middleware_telemetry.go b/pkg/migration/middleware_telemetry.go index 70afafc039..040301caed 100644 --- a/pkg/migration/middleware_telemetry.go +++ b/pkg/migration/middleware_telemetry.go @@ -32,8 +32,9 @@ func CheckAndPerformMiddlewareTelemetryMigration() { } // Mark migration as completed - if err := config.UpdateConfig(func(c *config.Config) { + if err := config.UpdateConfig(func(c *config.Config) error { c.MiddlewareTelemetryMigration = true + return nil }); err != nil { slog.Error("failed to update config after middleware telemetry migration", "error", err) } diff --git a/pkg/migration/secret_scope.go b/pkg/migration/secret_scope.go index 50739ff146..456ad3216b 100644 --- a/pkg/migration/secret_scope.go +++ b/pkg/migration/secret_scope.go @@ -58,8 +58,9 @@ func CheckAndPerformSecretScopeMigration() { } } - if err := config.UpdateConfig(func(c *config.Config) { + if err := config.UpdateConfig(func(c *config.Config) error { c.SecretScopeMigration = true + return nil }); err != nil { slog.Error("failed to update config after secret scope migration", "error", err) } diff --git a/pkg/migration/telemetry_config.go b/pkg/migration/telemetry_config.go index 9e8942bcce..bd6edd15e4 100644 --- a/pkg/migration/telemetry_config.go +++ b/pkg/migration/telemetry_config.go @@ -37,8 +37,9 @@ func CheckAndPerformTelemetryConfigMigration() { } // Mark migration as completed - if err := config.UpdateConfig(func(c *config.Config) { + if err := config.UpdateConfig(func(c *config.Config) error { c.TelemetryConfigMigration = true + return nil }); err != nil { slog.Error("failed to update config after telemetry config migration", "error", err) } diff --git a/pkg/registry/auth/login.go b/pkg/registry/auth/login.go index 09ff49c8c5..c842c6c0db 100644 --- a/pkg/registry/auth/login.go +++ b/pkg/registry/auth/login.go @@ -129,11 +129,12 @@ func Logout(ctx context.Context, configProvider config.Provider, secretsProvider slog.Debug("failed to clear registry cache", "error", err) } - return configProvider.UpdateConfig(func(c *config.Config) { + return configProvider.UpdateConfig(func(c *config.Config) error { if c.RegistryAuth.OAuth != nil { c.RegistryAuth.OAuth.CachedRefreshTokenRef = "" c.RegistryAuth.OAuth.CachedTokenExpiry = time.Time{} } + return nil }) } @@ -199,8 +200,9 @@ func ensureRegistryURL(configProvider config.Provider, opts LoginOptions) error // Always clear auth when a registry URL is explicitly provided, so that // tokens are never sent to the wrong server. - if err := configProvider.UpdateConfig(func(c *config.Config) { + if err := configProvider.UpdateConfig(func(c *config.Config) error { c.RegistryAuth = config.RegistryAuth{} + return nil }); err != nil { return fmt.Errorf("clearing stale auth config: %w", err) } @@ -262,7 +264,7 @@ func registryURLFromConfig(cfg *config.Config) string { // implementation used by both Login and AuthManager.SetOAuthAuth. func ConfigureOAuth( ctx context.Context, issuer, clientID, audience string, scopes []string, -) (func(*config.Config), error) { +) (func(*config.Config) error, error) { if err := validateIssuerURL(issuer); err != nil { return nil, err } @@ -281,7 +283,8 @@ func ConfigureOAuth( return DefaultOAuthScopes() }() - return func(c *config.Config) { + //nolint:unparam // error return is part of the UpdateConfig callback contract; this closure always succeeds + return func(c *config.Config) error { c.RegistryAuth = config.RegistryAuth{ Type: config.RegistryAuthTypeOAuth, OAuth: &config.RegistryOAuthConfig{ @@ -292,6 +295,7 @@ func ConfigureOAuth( CallbackPort: remote.DefaultCallbackPort, }, } + return nil }, nil } diff --git a/pkg/registry/auth/login_test.go b/pkg/registry/auth/login_test.go index 67830401b9..75ec1f2cc2 100644 --- a/pkg/registry/auth/login_test.go +++ b/pkg/registry/auth/login_test.go @@ -453,9 +453,9 @@ func TestEnsureOAuthConfig(t *testing.T) { cfg: &config.Config{}, useOIDC: true, setupMock: func(m *configmocks.MockProvider) { - m.EXPECT().UpdateConfig(gomock.Any()).DoAndReturn(func(fn func(*config.Config)) error { + m.EXPECT().UpdateConfig(gomock.Any()).DoAndReturn(func(fn func(*config.Config) error) error { c := &config.Config{} - fn(c) + require.NoError(t, fn(c)) // Verify the update function sets expected values. require.Equal(t, config.RegistryAuthTypeOAuth, c.RegistryAuth.Type) require.NotNil(t, c.RegistryAuth.OAuth) @@ -473,9 +473,9 @@ func TestEnsureOAuthConfig(t *testing.T) { useOIDC: true, overrideScopes: []string{"openid", "email"}, setupMock: func(m *configmocks.MockProvider) { - m.EXPECT().UpdateConfig(gomock.Any()).DoAndReturn(func(fn func(*config.Config)) error { + m.EXPECT().UpdateConfig(gomock.Any()).DoAndReturn(func(fn func(*config.Config) error) error { c := &config.Config{} - fn(c) + require.NoError(t, fn(c)) require.Equal(t, []string{"openid", "email"}, c.RegistryAuth.OAuth.Scopes) return nil }) @@ -488,9 +488,9 @@ func TestEnsureOAuthConfig(t *testing.T) { useOIDC: true, overrideAudience: "api://my-api", setupMock: func(m *configmocks.MockProvider) { - m.EXPECT().UpdateConfig(gomock.Any()).DoAndReturn(func(fn func(*config.Config)) error { + m.EXPECT().UpdateConfig(gomock.Any()).DoAndReturn(func(fn func(*config.Config) error) error { c := &config.Config{} - fn(c) + require.NoError(t, fn(c)) require.Equal(t, "api://my-api", c.RegistryAuth.OAuth.Audience) return nil }) @@ -653,8 +653,8 @@ func TestLogout_DeletesCachedToken(t *testing.T) { derivedKey := DeriveSecretKey(cfg.RegistryApiUrl, cfg.RegistryAuth.OAuth.Issuer) mockSecrets.EXPECT().DeleteSecret(gomock.Any(), derivedKey).Return(nil) - mockCfg.EXPECT().UpdateConfig(gomock.Any()).DoAndReturn(func(fn func(*config.Config)) error { - fn(cfg) + mockCfg.EXPECT().UpdateConfig(gomock.Any()).DoAndReturn(func(fn func(*config.Config) error) error { + require.NoError(t, fn(cfg)) require.Empty(t, cfg.RegistryAuth.OAuth.CachedRefreshTokenRef) require.True(t, cfg.RegistryAuth.OAuth.CachedTokenExpiry.IsZero()) return nil diff --git a/pkg/registry/auth/oauth_token_source.go b/pkg/registry/auth/oauth_token_source.go index a1301f3071..d27b3aa7b2 100644 --- a/pkg/registry/auth/oauth_token_source.go +++ b/pkg/registry/auth/oauth_token_source.go @@ -212,11 +212,12 @@ func (o *oauthTokenSource) createTokenPersister(refreshTokenKey string) remote.T // updateConfigTokenRef updates the config with the refresh token reference and expiry. func (*oauthTokenSource) updateConfigTokenRef(refreshTokenKey string, expiry time.Time) { - if err := config.UpdateConfig(func(cfg *config.Config) { + if err := config.UpdateConfig(func(cfg *config.Config) error { if cfg.RegistryAuth.OAuth != nil { cfg.RegistryAuth.OAuth.CachedRefreshTokenRef = refreshTokenKey cfg.RegistryAuth.OAuth.CachedTokenExpiry = expiry } + return nil }); err != nil { slog.Warn("Failed to update config with token reference", "error", err) } diff --git a/pkg/registry/auth_manager.go b/pkg/registry/auth_manager.go index d5ebb833a1..d5916c8c65 100644 --- a/pkg/registry/auth_manager.go +++ b/pkg/registry/auth_manager.go @@ -76,8 +76,9 @@ func (c *DefaultAuthManager) SetOAuthAuth(ctx context.Context, issuer, clientID, // UnsetAuth removes registry authentication configuration. func (c *DefaultAuthManager) UnsetAuth() error { - return c.provider.UpdateConfig(func(cfg *config.Config) { + return c.provider.UpdateConfig(func(cfg *config.Config) error { cfg.RegistryAuth = config.RegistryAuth{} + return nil }) } diff --git a/pkg/registry/auth_manager_test.go b/pkg/registry/auth_manager_test.go index 5d00daa388..4da14fa5dd 100644 --- a/pkg/registry/auth_manager_test.go +++ b/pkg/registry/auth_manager_test.go @@ -43,7 +43,7 @@ func TestDefaultAuthManager_UnsetAuth(t *testing.T) { // Capture the update function and verify it zeroes RegistryAuth. mockProvider.EXPECT(). UpdateConfig(gomock.Any()). - DoAndReturn(func(fn func(*config.Config)) error { + DoAndReturn(func(fn func(*config.Config) error) error { if tt.updateErr != nil { return tt.updateErr } @@ -56,7 +56,7 @@ func TestDefaultAuthManager_UnsetAuth(t *testing.T) { }, }, } - fn(cfg) + require.NoError(t, fn(cfg)) // After the update function runs, RegistryAuth must be zero. require.Equal(t, config.RegistryAuth{}, cfg.RegistryAuth) return nil diff --git a/test/e2e/client_test.go b/test/e2e/client_test.go index 537a12c94e..5942cfea24 100644 --- a/test/e2e/client_test.go +++ b/test/e2e/client_test.go @@ -85,8 +85,9 @@ var _ = Describe("Client Management", Label("core", "client", "e2e"), func() { BeforeEach(func() { // Pre-populate temporary config with multiple registered clients in non-alphabetical order testClients := []string{"vscode", "cursor", "roo-code", "cline", "claude-code"} - err := config.UpdateConfigAtPath(tempConfigPath, func(c *config.Config) { + err := config.UpdateConfigAtPath(tempConfigPath, func(c *config.Config) error { c.Clients.RegisteredClients = testClients + return nil }) Expect(err).ToNot(HaveOccurred()) }) From 2ce25094fbaac78d43d6bd720bfc61ce0c8e9b16 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:38:50 +0100 Subject: [PATCH 046/117] Split OTLP endpoint path to fix Langfuse/LangSmith integration (#4815) * Split OTLP endpoint path to fix Langfuse/LangSmith integration The OTLP HTTP SDK's WithEndpoint() only accepts host:port, but users need to include a path for platforms like Langfuse and LangSmith. The path component was being URL-encoded, breaking requests. Parse the endpoint into host:port and path, using WithURLPath() for the path. Fixes #4811 Co-Authored-By: Claude Opus 4.6 (1M context) * Address review feedback on OTLP endpoint path fix - Strip http:// and https:// prefixes defensively in splitEndpointPath so the CLI path (which skips NormalizeTelemetryConfig) works correctly - Extract "/v1/traces" and "/v1/metrics" as package-level constants - Add test cases: bare trailing slash, scheme-prefixed endpoints - Restore error-path coverage for metrics exporter (invalid CA cert) Co-Authored-By: Claude Opus 4.6 (1M context) * Document OTLP path suffix constants with spec reference Link to the OTLP/HTTP spec and explain why we need to append these suffixes manually when a custom base path is provided. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- pkg/telemetry/providers/otlp/endpoint.go | 37 ++++++++ pkg/telemetry/providers/otlp/endpoint_test.go | 92 +++++++++++++++++++ pkg/telemetry/providers/otlp/metrics.go | 7 +- pkg/telemetry/providers/otlp/metrics_test.go | 35 ++++--- pkg/telemetry/providers/otlp/tracing.go | 7 +- pkg/telemetry/providers/otlp/tracing_test.go | 10 ++ 6 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 pkg/telemetry/providers/otlp/endpoint.go create mode 100644 pkg/telemetry/providers/otlp/endpoint_test.go diff --git a/pkg/telemetry/providers/otlp/endpoint.go b/pkg/telemetry/providers/otlp/endpoint.go new file mode 100644 index 0000000000..3ceabd613e --- /dev/null +++ b/pkg/telemetry/providers/otlp/endpoint.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package otlp + +import "strings" + +// Default URL path suffixes for the OTLP/HTTP protocol, as defined in the +// OpenTelemetry specification: +// https://opentelemetry.io/docs/specs/otlp/#otlphttp-request +// +// The Go OTLP SDK normally appends these automatically. However, when the user +// provides a custom base path (e.g. "/api/public/otel" for Langfuse), we must +// call WithURLPath which replaces the entire path. In that case we concatenate +// the base path with the appropriate suffix ourselves (e.g. +// "/api/public/otel" + "/v1/traces"). +const ( + otlpTracesPath = "/v1/traces" + otlpMetricsPath = "/v1/metrics" +) + +// splitEndpointPath separates an OTLP endpoint string into its host:port and +// path components. If no path is present, basePath is empty. +// +// The function defensively strips http:// and https:// prefixes so it works +// correctly even when the scheme has not been removed upstream (e.g. the CLI +// path, which does not call NormalizeTelemetryConfig). +func splitEndpointPath(endpoint string) (hostPort, basePath string) { + endpoint = strings.TrimPrefix(endpoint, "https://") + endpoint = strings.TrimPrefix(endpoint, "http://") + + idx := strings.Index(endpoint, "/") + if idx < 0 { + return endpoint, "" + } + return endpoint[:idx], strings.TrimRight(endpoint[idx:], "/") +} diff --git a/pkg/telemetry/providers/otlp/endpoint_test.go b/pkg/telemetry/providers/otlp/endpoint_test.go new file mode 100644 index 0000000000..2f5a3f5e97 --- /dev/null +++ b/pkg/telemetry/providers/otlp/endpoint_test.go @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package otlp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitEndpointPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + endpoint string + wantHostPort string + wantBasePath string + }{ + { + name: "host and port only", + endpoint: "localhost:4318", + wantHostPort: "localhost:4318", + wantBasePath: "", + }, + { + name: "hostname without port", + endpoint: "otel-collector.local", + wantHostPort: "otel-collector.local", + wantBasePath: "", + }, + { + name: "Langfuse endpoint with path", + endpoint: "cloud.langfuse.com/api/public/otel", + wantHostPort: "cloud.langfuse.com", + wantBasePath: "/api/public/otel", + }, + { + name: "LangSmith endpoint with port and path", + endpoint: "smith.langchain.com:443/api/v1/otel", + wantHostPort: "smith.langchain.com:443", + wantBasePath: "/api/v1/otel", + }, + { + name: "trailing slash stripped", + endpoint: "cloud.langfuse.com/api/public/otel/", + wantHostPort: "cloud.langfuse.com", + wantBasePath: "/api/public/otel", + }, + { + name: "host:port with trailing slash only", + endpoint: "localhost:4318/", + wantHostPort: "localhost:4318", + wantBasePath: "", + }, + { + name: "https scheme stripped before splitting", + endpoint: "https://cloud.langfuse.com/api/public/otel", + wantHostPort: "cloud.langfuse.com", + wantBasePath: "/api/public/otel", + }, + { + name: "http scheme stripped before splitting", + endpoint: "http://localhost:4318", + wantHostPort: "localhost:4318", + wantBasePath: "", + }, + { + name: "https scheme with host only", + endpoint: "https://api.honeycomb.io", + wantHostPort: "api.honeycomb.io", + wantBasePath: "", + }, + { + name: "empty string", + endpoint: "", + wantHostPort: "", + wantBasePath: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + hostPort, basePath := splitEndpointPath(tt.endpoint) + assert.Equal(t, tt.wantHostPort, hostPort) + assert.Equal(t, tt.wantBasePath, basePath) + }) + } +} diff --git a/pkg/telemetry/providers/otlp/metrics.go b/pkg/telemetry/providers/otlp/metrics.go index 118caa4137..906d2a66da 100644 --- a/pkg/telemetry/providers/otlp/metrics.go +++ b/pkg/telemetry/providers/otlp/metrics.go @@ -27,8 +27,13 @@ func NewMetricReader(ctx context.Context, config Config) (sdkmetric.Reader, erro } func createMetricExporter(ctx context.Context, config Config) (sdkmetric.Exporter, error) { + host, basePath := splitEndpointPath(config.Endpoint) opts := []otlpmetrichttp.Option{ - otlpmetrichttp.WithEndpoint(config.Endpoint), + otlpmetrichttp.WithEndpoint(host), + } + + if basePath != "" { + opts = append(opts, otlpmetrichttp.WithURLPath(basePath+otlpMetricsPath)) } if len(config.Headers) > 0 { diff --git a/pkg/telemetry/providers/otlp/metrics_test.go b/pkg/telemetry/providers/otlp/metrics_test.go index 1e53485fe8..6d52f71e04 100644 --- a/pkg/telemetry/providers/otlp/metrics_test.go +++ b/pkg/telemetry/providers/otlp/metrics_test.go @@ -16,6 +16,7 @@ func TestCreateMetricExporter(t *testing.T) { tests := []struct { name string config Config + ctx func() context.Context wantErr bool errMsg string }{ @@ -26,6 +27,7 @@ func TestCreateMetricExporter(t *testing.T) { Headers: map[string]string{"x-api-key": "secret"}, Insecure: true, }, + ctx: func() context.Context { return context.Background() }, wantErr: false, }, { @@ -34,16 +36,29 @@ func TestCreateMetricExporter(t *testing.T) { Endpoint: "localhost:4318", Insecure: false, }, + ctx: func() context.Context { return context.Background() }, wantErr: false, }, { - name: "error creating metrics exporter due to malformed endpoint", + name: "endpoint with custom path", config: Config{ - Endpoint: "malformed//:4318", + Endpoint: "cloud.langfuse.com/api/public/otel", + Headers: map[string]string{"Authorization": "Basic abc123"}, Insecure: false, }, + ctx: func() context.Context { return context.Background() }, + wantErr: false, + }, + { + name: "error creating metrics exporter due to invalid CA cert", + config: Config{ + Endpoint: "localhost:4318", + Insecure: false, + CACertPath: "/nonexistent/ca.crt", + }, + ctx: func() context.Context { return context.Background() }, wantErr: true, - errMsg: "invalid URL escape", + errMsg: "failed to configure TLS for metric exporter", }, } @@ -51,7 +66,7 @@ func TestCreateMetricExporter(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := tt.ctx() exporter, err := createMetricExporter(ctx, tt.config) if tt.wantErr { @@ -109,17 +124,13 @@ func TestNewMetricReader(t *testing.T) { wantErr: false, }, { - name: "expect error creating metrics exporter due to malformed endpoint", + name: "endpoint with custom path", config: Config{ - Endpoint: "malformed//:4318", - Headers: map[string]string{ - "x-api-key": "secret", - "x-env": "production", - }, + Endpoint: "cloud.langfuse.com/api/public/otel", + Headers: map[string]string{"Authorization": "Basic abc123"}, Insecure: false, }, - wantErr: true, - errMsg: "invalid URL escape", + wantErr: false, }, } diff --git a/pkg/telemetry/providers/otlp/tracing.go b/pkg/telemetry/providers/otlp/tracing.go index 51086feed7..a76f518582 100644 --- a/pkg/telemetry/providers/otlp/tracing.go +++ b/pkg/telemetry/providers/otlp/tracing.go @@ -15,8 +15,13 @@ import ( ) func createTraceExporter(ctx context.Context, config Config) (sdktrace.SpanExporter, error) { + host, basePath := splitEndpointPath(config.Endpoint) opts := []otlptracehttp.Option{ - otlptracehttp.WithEndpoint(config.Endpoint), + otlptracehttp.WithEndpoint(host), + } + + if basePath != "" { + opts = append(opts, otlptracehttp.WithURLPath(basePath+otlpTracesPath)) } if len(config.Headers) > 0 { diff --git a/pkg/telemetry/providers/otlp/tracing_test.go b/pkg/telemetry/providers/otlp/tracing_test.go index dbf20036f2..87ee5b3aad 100644 --- a/pkg/telemetry/providers/otlp/tracing_test.go +++ b/pkg/telemetry/providers/otlp/tracing_test.go @@ -55,6 +55,16 @@ func TestCreateTraceExporter(t *testing.T) { ctx: func() context.Context { return context.Background() }, wantErr: false, }, + { + name: "endpoint with custom path", + config: Config{ + Endpoint: "cloud.langfuse.com/api/public/otel", + Headers: map[string]string{"Authorization": "Basic abc123"}, + Insecure: false, + }, + ctx: func() context.Context { return context.Background() }, + wantErr: false, + }, { name: "error creating sdk-span-exporter due to error (cancelled context)", config: Config{ From bfac2672612db4a3336cd7a7277c5a4c3cc0d18d Mon Sep 17 00:00:00 2001 From: Michelangelo Mori <328978+blkt@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:45:01 +0200 Subject: [PATCH 047/117] Simplify `UpdateConfig` error path and add test (#4858) Remove the "update function failed" error wrapping in `UpdateConfigAtPath`. A bare return avoids double-nesting once callers start returning real errors from their callbacks. Add `TestUpdateConfigAtPath_CallbackError` to exercise the error path: when the callback returns an error the config must not be written to disk. --- pkg/config/config.go | 2 +- pkg/config/config_test.go | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 68ced5976f..01222d8e77 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -376,7 +376,7 @@ func UpdateConfigAtPath(configPath string, updateFn func(*Config) error) error { // Apply changes to the config file. if err := updateFn(c); err != nil { - return fmt.Errorf("update function failed: %w", err) + return err } // Write the updated config to disk. diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index c2094c60f4..56f076772e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -4,6 +4,7 @@ package config import ( + "errors" "os" "path/filepath" "testing" @@ -260,6 +261,27 @@ func TestRegistryURLConfig(t *testing.T) { }) } +func TestUpdateConfigAtPath_CallbackError(t *testing.T) { + t.Parallel() + + _, configPath := SetupTestConfig(t, &Config{ + RegistryUrl: "https://original.example.com", + }) + + cbErr := errors.New("validation failed") + err := UpdateConfigAtPath(configPath, func(c *Config) error { + c.RegistryUrl = "https://should-not-persist.example.com" + return cbErr + }) + require.ErrorIs(t, err, cbErr) + + // The config on disk must be unchanged. + config, err := LoadOrCreateConfigWithPath(configPath) + require.NoError(t, err) + assert.Equal(t, "https://original.example.com", config.RegistryUrl, + "config should not be written to disk when the callback returns an error") +} + func TestSecrets_GetProviderType_EnvironmentVariable(t *testing.T) { t.Parallel() From 300d65989380b4f2c7f07f58ef7fac2210e18046 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:18:34 +0100 Subject: [PATCH 048/117] Add resourceUrl field to MCPOIDCConfigReference (#4855) * Add resourceUrl field to MCPOIDCConfigReference When a VirtualMCPServer is exposed via Ingress, the protected resource metadata (RFC 9728) must advertise the public URL, not the internal Kubernetes service URL. Without this, oidcConfigRef is unusable for any externally-exposed server because MCP clients receive an unreachable internal address. Add an optional resourceUrl field to MCPOIDCConfigReference that overrides the derived service URL. When empty, the existing fallback to the internal service URL is preserved. Closes #4851 Co-Authored-By: Claude Opus 4.6 (1M context) * Add integration test for oidcConfigRef resourceUrl override Verify end-to-end that when a VirtualMCPServer sets resourceUrl on its oidcConfigRef, the generated ConfigMap contains the explicit public URL instead of the internal Kubernetes service URL. Co-Authored-By: Claude Opus 4.6 (1M context) * Strengthen OIDC integration tests to assert all resolved fields The existing ConfigMap assertion only checked Issuer and Audience. Expand it to also verify ClientID, Scopes, and the derived Resource URL, ensuring full field propagation from MCPOIDCConfig + MCPOIDCConfigReference through to the generated runconfig. The resourceUrl override test now also asserts all fields, not just Resource. Co-Authored-By: Claude Opus 4.6 (1M context) * Consolidate OIDC integration tests into single context Fold the resourceUrl override test into the existing VirtualMCPServer context instead of duplicating the full setup. The first context now sets ResourceURL on the ref and asserts all resolved fields including the explicit resource URL. Co-Authored-By: Claude Opus 4.6 (1M context) * Update cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go * Address review feedback: fix comment, add fallback test, update arch docs Fix contradictory comment in integration test that described fallback behavior while the fixture actually tests the override path (F1). Add explicit empty-string ResourceURL test case to verify the fallback to createServiceURL is exercised even when the field is present but empty (F3). Update operator architecture doc to list resourceUrl alongside audience and scopes as a per-server override on oidcConfigRef (F4). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../api/v1alpha1/mcpoidcconfig_types.go | 7 ++ cmd/thv-operator/pkg/oidc/resolver.go | 5 +- .../pkg/oidc/resolver_configref_test.go | 68 +++++++++++++++++++ ...onfig_virtualmcpserver_integration_test.go | 19 ++++-- ...oolhive.stacklok.dev_mcpremoteproxies.yaml | 7 ++ .../toolhive.stacklok.dev_mcpservers.yaml | 7 ++ ...olhive.stacklok.dev_virtualmcpservers.yaml | 7 ++ ...oolhive.stacklok.dev_mcpremoteproxies.yaml | 7 ++ .../toolhive.stacklok.dev_mcpservers.yaml | 7 ++ ...olhive.stacklok.dev_virtualmcpservers.yaml | 7 ++ docs/arch/09-operator-architecture.md | 4 +- docs/operator/crd-api.md | 1 + 12 files changed, 140 insertions(+), 6 deletions(-) diff --git a/cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go b/cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go index 71e7d71fa2..52545ff0ac 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go @@ -241,6 +241,13 @@ type MCPOIDCConfigReference struct { // +listType=atomic // +optional Scopes []string `json:"scopes,omitempty"` + + // ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728). + // When the server is exposed via Ingress or gateway, set this to the external + // URL that MCP clients connect to. If not specified, defaults to the internal + // Kubernetes service URL. + // +optional + ResourceURL string `json:"resourceUrl,omitempty"` } // Validate performs validation on the MCPOIDCConfig spec. diff --git a/cmd/thv-operator/pkg/oidc/resolver.go b/cmd/thv-operator/pkg/oidc/resolver.go index 48a441aaa0..8cb045ff9e 100644 --- a/cmd/thv-operator/pkg/oidc/resolver.go +++ b/cmd/thv-operator/pkg/oidc/resolver.go @@ -82,7 +82,10 @@ func (r *resolver) ResolveFromConfigRef( return nil, nil } - resourceURL := createServiceURL(serverName, namespace, proxyPort) + resourceURL := ref.ResourceURL + if resourceURL == "" { + resourceURL = createServiceURL(serverName, namespace, proxyPort) + } switch oidcCfg.Spec.Type { case mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount: diff --git a/cmd/thv-operator/pkg/oidc/resolver_configref_test.go b/cmd/thv-operator/pkg/oidc/resolver_configref_test.go index 0f0c6199d9..8e4121b481 100644 --- a/cmd/thv-operator/pkg/oidc/resolver_configref_test.go +++ b/cmd/thv-operator/pkg/oidc/resolver_configref_test.go @@ -70,6 +70,29 @@ func TestResolveFromConfigRef_KubernetesServiceAccountType(t *testing.T) { JWKSAllowPrivateIP: true, }, }, + { + name: "empty resourceUrl falls back to derived service URL", + ref: &mcpv1alpha1.MCPOIDCConfigReference{ + Name: "k", Audience: "my-aud", Scopes: []string{"openid"}, + ResourceURL: "", + }, + oidcCfg: &mcpv1alpha1.MCPOIDCConfig{ + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount, + KubernetesServiceAccount: &mcpv1alpha1.KubernetesServiceAccountOIDCConfig{ + Issuer: "https://kubernetes.default.svc", + }, + }, + }, + expected: &OIDCConfig{ + Issuer: "https://kubernetes.default.svc", Audience: "my-aud", + Scopes: []string{"openid"}, + ResourceURL: "http://srv.default.svc.cluster.local:8080", + ThvCABundlePath: defaultK8sCABundlePath, + JWKSAuthTokenPath: defaultK8sTokenPath, + JWKSAllowPrivateIP: true, + }, + }, { name: "nil KSA config falls back to all defaults", ref: &mcpv1alpha1.MCPOIDCConfigReference{ @@ -89,6 +112,29 @@ func TestResolveFromConfigRef_KubernetesServiceAccountType(t *testing.T) { JWKSAllowPrivateIP: true, }, }, + { + name: "explicit resourceUrl overrides derived service URL", + ref: &mcpv1alpha1.MCPOIDCConfigReference{ + Name: "k", Audience: "my-aud", Scopes: []string{"openid"}, + ResourceURL: "https://mcp-gateway.example.com/mcp", + }, + oidcCfg: &mcpv1alpha1.MCPOIDCConfig{ + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount, + KubernetesServiceAccount: &mcpv1alpha1.KubernetesServiceAccountOIDCConfig{ + Issuer: "https://kubernetes.default.svc", + }, + }, + }, + expected: &OIDCConfig{ + Issuer: "https://kubernetes.default.svc", Audience: "my-aud", + Scopes: []string{"openid"}, + ResourceURL: "https://mcp-gateway.example.com/mcp", + ThvCABundlePath: defaultK8sCABundlePath, + JWKSAuthTokenPath: defaultK8sTokenPath, + JWKSAllowPrivateIP: true, + }, + }, { name: "UseClusterAuth false omits CA and token paths", ref: &mcpv1alpha1.MCPOIDCConfigReference{ @@ -179,6 +225,28 @@ func TestResolveFromConfigRef_InlineType(t *testing.T) { JWKSAllowPrivateIP: false, }, }, + { + name: "explicit resourceUrl overrides derived service URL for inline config", + ref: &mcpv1alpha1.MCPOIDCConfigReference{ + Name: "i", Audience: "inline-aud", Scopes: []string{"openid"}, + ResourceURL: "https://mcp.corp.internal/tools", + }, + oidcCfg: &mcpv1alpha1.MCPOIDCConfig{ + Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Issuer: "https://accounts.google.com", + ClientID: "gid", + }, + }, + }, + expected: &OIDCConfig{ + Issuer: "https://accounts.google.com", Audience: "inline-aud", + ClientID: "gid", + ResourceURL: "https://mcp.corp.internal/tools", + Scopes: []string{"openid"}, + }, + }, { name: "nil inline config returns nil", ref: &mcpv1alpha1.MCPOIDCConfigReference{ diff --git a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go index 8037603da1..cda92baa98 100644 --- a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go @@ -107,9 +107,10 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ Type: "oidc", OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ - Name: configName, - Audience: "test-vmcp-audience", - Scopes: []string{"openid"}, + Name: configName, + Audience: "test-vmcp-audience", + Scopes: []string{"openid"}, + ResourceURL: "https://mcp-gateway.example.com/mcp", }, }, }, @@ -157,7 +158,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration }, timeout, interval).Should(BeTrue()) }) - It("should produce a ConfigMap with OIDC config from the MCPOIDCConfig", func() { + It("should produce a ConfigMap with all OIDC fields from the MCPOIDCConfig and ref", func() { configMapName := vmcpName + "-vmcp-config" configMap := &corev1.ConfigMap{} Eventually(func() error { @@ -173,8 +174,18 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration Expect(config.IncomingAuth).NotTo(BeNil()) Expect(config.IncomingAuth.OIDC).NotTo(BeNil(), "OIDC config from MCPOIDCConfig should be present in ConfigMap") + + // Shared config fields from MCPOIDCConfig Expect(config.IncomingAuth.OIDC.Issuer).To(Equal("https://accounts.google.com")) + Expect(config.IncomingAuth.OIDC.ClientID).To(Equal("test-client")) + + // Per-server fields from MCPOIDCConfigReference Expect(config.IncomingAuth.OIDC.Audience).To(Equal("test-vmcp-audience")) + Expect(config.IncomingAuth.OIDC.Scopes).To(Equal([]string{"openid"})) + + // Resource URL: explicit resourceUrl on the ref overrides the internal service URL + Expect(config.IncomingAuth.OIDC.Resource).To(Equal("https://mcp-gateway.example.com/mcp"), + "resource should be the explicit resourceUrl, not the internal service URL") }) It("should track VirtualMCPServer in MCPOIDCConfig ReferencingWorkloads", func() { diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml index 81e9b3283f..7313692b1e 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -246,6 +246,13 @@ spec: description: Name is the name of the MCPOIDCConfig resource minLength: 1 type: string + resourceUrl: + description: |- + ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728). + When the server is exposed via Ingress or gateway, set this to the external + URL that MCP clients connect to. If not specified, defaults to the internal + Kubernetes service URL. + type: string scopes: description: |- Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728). diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml index 83b8809531..3c23914b94 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml @@ -241,6 +241,13 @@ spec: description: Name is the name of the MCPOIDCConfig resource minLength: 1 type: string + resourceUrl: + description: |- + ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728). + When the server is exposed via Ingress or gateway, set this to the external + URL that MCP clients connect to. If not specified, defaults to the internal + Kubernetes service URL. + type: string scopes: description: |- Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728). diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index ec8c65f4f8..6addc27430 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -1878,6 +1878,13 @@ spec: description: Name is the name of the MCPOIDCConfig resource minLength: 1 type: string + resourceUrl: + description: |- + ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728). + When the server is exposed via Ingress or gateway, set this to the external + URL that MCP clients connect to. If not specified, defaults to the internal + Kubernetes service URL. + type: string scopes: description: |- Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728). diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml index c58e1ba0e5..32391e4c45 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -249,6 +249,13 @@ spec: description: Name is the name of the MCPOIDCConfig resource minLength: 1 type: string + resourceUrl: + description: |- + ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728). + When the server is exposed via Ingress or gateway, set this to the external + URL that MCP clients connect to. If not specified, defaults to the internal + Kubernetes service URL. + type: string scopes: description: |- Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728). diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml index cc1eced891..c5be16186a 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml @@ -244,6 +244,13 @@ spec: description: Name is the name of the MCPOIDCConfig resource minLength: 1 type: string + resourceUrl: + description: |- + ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728). + When the server is exposed via Ingress or gateway, set this to the external + URL that MCP clients connect to. If not specified, defaults to the internal + Kubernetes service URL. + type: string scopes: description: |- Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728). diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 2045597aae..f54942a1fc 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -1881,6 +1881,13 @@ spec: description: Name is the name of the MCPOIDCConfig resource minLength: 1 type: string + resourceUrl: + description: |- + ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728). + When the server is exposed via Ingress or gateway, set this to the external + URL that MCP clients connect to. If not specified, defaults to the internal + Kubernetes service URL. + type: string scopes: description: |- Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728). diff --git a/docs/arch/09-operator-architecture.md b/docs/arch/09-operator-architecture.md index c55f2aa06a..105567c9b4 100644 --- a/docs/arch/09-operator-architecture.md +++ b/docs/arch/09-operator-architecture.md @@ -219,6 +219,7 @@ MCPOIDCConfig eliminates OIDC configuration duplication — define an identity p **Per-server overrides** live in the workload's `oidcConfigRef` field (not the shared spec): - `audience` (required) — Must be unique per server to prevent token replay - `scopes` (optional) — Defaults to `["openid"]` +- `resourceUrl` (optional) — Public URL for OAuth protected resource metadata (RFC 9728); defaults to internal service URL **Status fields** include a `Ready` condition, `configHash` for change detection, and `referencingWorkloads` tracking which resources reference this config. Deletion is blocked while references exist (finalizer pattern). @@ -255,7 +256,7 @@ Defines a proxy for remote MCP servers with authentication, authorization, audit **Key fields:** - `remoteUrl` - URL of the remote MCP server to proxy -- `oidcConfigRef` - Reference to shared MCPOIDCConfig (with per-server `audience` and `scopes`) +- `oidcConfigRef` - Reference to shared MCPOIDCConfig (with per-server `audience`, `scopes`, and `resourceUrl`) - `externalAuthConfigRef` - Token exchange for remote service authentication - `authzConfig` - Authorization policies - `toolConfigRef` - Tool filtering and renaming @@ -629,6 +630,7 @@ spec: name: corporate-idp audience: my-server # per-server, prevents token replay scopes: ["openid"] # optional, defaults to ["openid"] + resourceUrl: https://mcp.example.com # optional, defaults to internal service URL ``` **MCPTelemetryConfig reference:** diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 66a03fd325..b5d39e5f71 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -1607,6 +1607,7 @@ _Appears in:_ | `name` _string_ | Name is the name of the MCPOIDCConfig resource | | MinLength: 1
Required: \{\}
| | `audience` _string_ | Audience is the expected audience for token validation.
This MUST be unique per server to prevent token replay attacks. | | MinLength: 1
Required: \{\}
| | `scopes` _string array_ | Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728).
If empty, defaults to ["openid"]. | | Optional: \{\}
| +| `resourceUrl` _string_ | ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728).
When the server is exposed via Ingress or gateway, set this to the external
URL that MCP clients connect to. If not specified, defaults to the internal
Kubernetes service URL. | | Optional: \{\}
| #### api.v1alpha1.MCPOIDCConfigSourceType From a954dc44e1f77e35210fc9db62a2fbd020fb58c1 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:18:40 +0100 Subject: [PATCH 049/117] Add release-notes skill for generating release notes (#4852) Add release-notes skill for generating publication-ready release notes Adds a structured skill that analyzes every PR between two tags, dispatches domain-expert agents to assess breaking changes, and composes release notes following a consistent template. Co-authored-by: Claude Opus 4.6 (1M context) --- .claude/skills/release-notes/SKILL.md | 256 +++++++++++++++++++++++ .claude/skills/release-notes/TEMPLATE.md | 113 ++++++++++ 2 files changed, 369 insertions(+) create mode 100644 .claude/skills/release-notes/SKILL.md create mode 100644 .claude/skills/release-notes/TEMPLATE.md diff --git a/.claude/skills/release-notes/SKILL.md b/.claude/skills/release-notes/SKILL.md new file mode 100644 index 0000000000..3c2e09e3f6 --- /dev/null +++ b/.claude/skills/release-notes/SKILL.md @@ -0,0 +1,256 @@ +--- +name: release-notes +description: Generates polished GitHub release notes for a ToolHive release by analyzing every merged PR, cross-referencing linked issues, dispatching expert agents to assess breaking changes, and producing a formatted release body. Use when the user provides a GitHub release URL, tag name, or says "release notes". +--- + +# Release Notes Generator + +Produces publication-ready GitHub release notes by deeply analyzing every PR +merged between two version tags. + +## Arguments + +``` +/release-notes https://github.com/stacklok/toolhive/releases/tag/v0.18.0 +/release-notes v0.18.0 +``` + +**Input**: `$ARGUMENTS` — a GitHub release URL or a tag name. + +--- + +## Phase 1: Gather Raw Data + +### Step 1: Resolve the release and prior tag + +```bash +# If given a URL, extract the tag from the path +# Then find the immediately preceding release tag +gh release view --json tagName,name,body,publishedAt +git tag --sort=-v:refname | grep -A1 "^$" | tail -1 +``` + +Store: +- `CURRENT_TAG` (e.g., `v0.18.0`) +- `PREVIOUS_TAG` (e.g., `v0.17.0`) +- `PUBLISHED_AT` date + +### Step 2: Get the auto-generated changelog + +Fetch the existing release body. GitHub's auto-generated "What's Changed" block +(PR title by @author with links) will be preserved verbatim as the commit log +at the bottom of the final output. Save it as `AUTO_CHANGELOG`. + +### Step 3: List all PRs between tags + +```bash +gh api repos/stacklok/toolhive/compare/{PREVIOUS_TAG}...{CURRENT_TAG} \ + --jq '.commits[] | "\(.sha[0:8]) \(.commit.message | split("\n")[0])"' +``` + +Extract every PR number from commit messages (look for `(#NNNN)` suffixes). +Exclude the release PR itself (e.g., "Release vX.Y.Z"). + +### Step 3b: Separate dependency PRs + +Filter out PRs authored by `renovate[bot]`, `dependabot[bot]`, or with labels +containing `dependencies`. These go directly into the **Dependencies** section — +they do not need expert review or further classification. Record them separately. + +### Step 4: Fetch PR details + +For each PR, fetch: +- Title, labels, body +- Whether the "Breaking change" checkbox is checked in the body +- Linked issues (look for `Closes #N`, `Fixes #N`, `Part of #N`, `Resolves #N`) +- Migration guide content (if present in the PR body) + +```bash +gh pr view --json title,labels,body +``` + +### Step 5: Fetch linked issue details + +For each unique linked issue number, fetch title and labels: + +```bash +gh issue view --json title,labels +``` + +### Step 6: Identify new contributors + +Check the auto-generated changelog for the "New Contributors" section. Extract +author handles. + +--- + +## Phase 2: Classify Changes + +### Step 1: Initial triage + +Dependency PRs (from Step 3b) are already separated — skip them here. + +Categorize each remaining PR into one of the categories below. Check the +signals **in this priority order** — earlier signals are more reliable: + +1. **Linked issue labels** — if the linked issue has a `breaking-change` label, + classify as Breaking regardless of whether the PR checkbox is checked. +2. **PR body content** — look for explicit "breaking" mentions, removal of + fields/APIs, or JSON tag renames. Note: a migration guide alone does NOT + mean breaking — deprecations often include migration guides too. The key + question is whether the old behavior/field/API **still works**. If yes, + it's a deprecation. If no, it's breaking. +3. **PR labels** — `breaking`, `enhancement`, `bug`, etc. +4. **Breaking change checkbox** — least reliable; often unchecked even on + genuinely breaking PRs. + +| Category | Criteria | +|----------|----------| +| **Breaking** | Old behavior/field/API **no longer works** — linked issue labeled `breaking-change`, OR "Breaking change" checkbox checked, OR PR labels contain `breaking`, OR PR removes fields/endpoints/flags without backwards compatibility | +| **Deprecation** | PR introduces new deprecation warnings or marks fields as deprecated | +| **New Feature** | Labels contain `enhancement`/`feature`, OR PR adds new user-facing capability | +| **Bug Fix** | Labels contain `bug`, OR PR title/body indicates a fix | +| **Misc** | Everything else — refactors, test improvements, CI, docs, internal cleanup | + +**Overlap rule:** If a PR belongs to multiple categories (e.g., both a new +feature AND a breaking change), always classify it in the **most urgent** +category. The priority order is: Breaking > Deprecation > Bug Fix > New Feature > Misc. +The PR can still be mentioned in a secondary section (e.g., a breaking API +change can also appear under New Features for its positive user impact), but its +primary home is always the most urgent category. + +### Step 2: Identify ambiguous PRs + +Any PR that touches CRD types, API surfaces, wire formats, authentication flows, +or MCP protocol behavior but is NOT already classified as breaking needs expert +review. Flag these for Phase 3. + +Heuristics for flagging: +- Modifies files in `cmd/thv-operator/api/` or CRD manifests +- Changes JSON/YAML struct tags (especially renames — these cause silent etcd + data loss on existing resources) +- Removes CRD fields, API fields, CLI flags, or enum values +- Alters authentication, token handling, or middleware wiring +- Changes MCP message formats or transport behavior +- Renames or removes public Go types/methods consumed by external packages +- Changes default values, config semantics, or HTTP status codes + +For flagged PRs, always fetch the diff summary so agents have concrete data: + +```bash +gh pr diff --stat +``` + +--- + +## Phase 3: Expert Breaking-Change Assessment + +### Step 1: Map PRs to expert agents + +For flagged PRs and confirmed breaking PRs, dispatch the appropriate expert +agent to assess impact and write migration guidance. + +| Change Area | Agent | What to ask | +|-------------|-------|-------------| +| CRD types, operator, Helm | `kubernetes-expert` | Is this a breaking CRD change? What manifests break? What's the migration path? | +| MCP transport, protocol messages | `mcp-protocol-expert` | Does this break MCP clients or change wire behavior? | +| Auth flows, OIDC, tokens, Cedar | `oauth-expert` | Does this break existing auth configurations? | +| API endpoints, CLI commands | `toolhive-expert` | Does this break CLI users or API consumers? | +| Observability, metrics, tracing | `site-reliability-engineer` | Does this change metric names, trace attributes, or dashboard contracts? | + +### Step 2: Launch agents in parallel + +For each flagged PR, include in the agent prompt: +- The PR title, number, and full body +- The linked issue title and body (if any) +- The diff summary (`gh pr diff --stat`) +- The question: "Is this a breaking change? If yes, who is affected and what is + the migration path? If no, explain why it's safe." + +**When a PR has no labels, no checkbox, no migration guide, and no issue +references** — the agent MUST read the actual code changes to make a +determination. Tell the agent to examine the PR diff and the affected source +files directly rather than relying on metadata. This is the fallback for +under-documented PRs. + +**Launch all agents in a single message** so they run in parallel. + +### Step 3: Collect verdicts + +Each agent returns one of: +- **Breaking** — with affected audience, impact description, and migration steps +- **Deprecation** — with timeline and recommended replacement +- **Not breaking** — with rationale for why it's safe + +Update the classification from Phase 2 with agent verdicts. If an agent +overrides the initial classification (e.g., flags something as breaking that +wasn't initially caught), trust the domain expert. + +--- + +## Phase 4: Compose Release Notes + +Read the template at [TEMPLATE.md](TEMPLATE.md) and use it to assemble the +final release body. **Omit any section that has zero entries** — do not include +empty headers. + +--- + +## Phase 5: Present and Publish + +### Step 1: Present the draft + +Show the complete release notes to the user. Highlight: +- How many breaking changes were found (and which agents confirmed them) +- Any PRs where the breaking-change assessment was uncertain +- Any PRs with no linked issues (less context available) + +### Step 2: Wait for approval + +Ask: + +> "Ready to publish these release notes? +> 1. **Publish** — update the GitHub release with these notes +> 2. **Revise** — tell me what to change +> 3. **Export** — save to a file instead of publishing" + +### Step 3: Save to file + +Always write the final release notes to `release-notes-.md` in the repo +root (e.g., `release-notes-v0.19.0.md`). This gives the user a reviewable +artifact before anything is published. + +### Step 4: Publish (if approved) + +If the user chose "Publish", push the notes to the GitHub release: + +```bash +gh release edit --notes-file release-notes-.md +``` + +--- + +## Important Notes + +- **Read every PR body** — do not skip PRs or rely only on titles. The breaking + change checkbox, migration guides, and linked issues are in the body. +- **Cross-reference issues** — issue labels and descriptions often contain + context that the PR body lacks (e.g., an issue labeled `breaking` when the PR + isn't). +- **Trust expert agents** for domain-specific breaking-change assessments. If + the kubernetes-expert says a CRD change is breaking, it is breaking. +- **When in doubt, flag it** — it's better to ask the user about a potentially + breaking change than to miss it. Present the evidence and let them decide. +- **Preserve the auto-generated changelog verbatim** — do not reformat, reorder, + or edit the GitHub "What's Changed" block. It's the raw record. +- **Omit empty sections** — if there are no breaking changes, no deprecations, + or no new contributors, leave those sections out entirely. Do not include + headers with no content beneath them. + +## Usage Examples + +``` +/release-notes https://github.com/stacklok/toolhive/releases/tag/v0.18.0 +/release-notes v0.18.0 +/release-notes v0.15.0 +``` diff --git a/.claude/skills/release-notes/TEMPLATE.md b/.claude/skills/release-notes/TEMPLATE.md new file mode 100644 index 0000000000..6d7c3ba1d8 --- /dev/null +++ b/.claude/skills/release-notes/TEMPLATE.md @@ -0,0 +1,113 @@ +# Release Notes Template + +Use this template to produce the final release notes body. Omit any section +that has zero entries — do not include empty headers. + +Replace placeholders (`<...>`) with actual content. Emoji shortcodes are written +literally here for clarity — render them as actual emoji in the final output. + +--- + +```markdown +# 🚀 **Toolhive vX.Y.Z is live!** + + + +## ⚠️ Breaking Changes + + +- **** — <one-liner: what breaks and what to do> ([migration guide](#migration-guide-anchor)) + +<for each breaking change, a collapsible migration guide:> + +<details> +<summary><strong>Migration guide: <title></strong></summary> + +<description of who is affected> + +### Before + +```yaml +<old manifest or config> +``` + +### After + +```yaml +<new manifest or config> +``` + +### Migration steps + +1. <step> +2. <step> +3. <step> + +*PR: [#NNN](https://github.com/stacklok/toolhive/pull/NNN) — Closes [#NNN](https://github.com/stacklok/toolhive/issues/NNN)* + +</details> + + +## 🔄 Deprecations + +<for each NEW deprecation in this release — do not carry forward old ones:> +- **`field.or.feature`** deprecated in favour of `replacement` — will be removed in <version> ([#NNN](https://github.com/stacklok/toolhive/pull/NNN)) + + +## 🆕 New Features + +- <one-sentence user impact> ([#NNN](https://github.com/stacklok/toolhive/pull/NNN)) + +## 🐛 Bug Fixes + +- <one-sentence description> ([#NNN](https://github.com/stacklok/toolhive/pull/NNN)) + +## 🧹 Misc + +- <one-sentence description> ([#NNN](https://github.com/stacklok/toolhive/pull/NNN)) + +## 📦 Dependencies + +<table of dependency updates from renovate/dependabot PRs:> + +| Module | Version | +|--------|---------| +| `module/name` | vX.Y.Z | + + +👋 Welcome to our newest contributors: **@handle** 🎉 + +<details> +<summary><strong>Full commit log</strong></summary> + +<paste the GitHub auto-generated "What's Changed" block here verbatim, +including PR titles, @author links, and the "New Contributors" sub-section +if present> + +</details> + +🔗 Full changelog: https://github.com/stacklok/toolhive/compare/vPREVIOUS...vCURRENT +``` + +--- + +## Section rules + +| Section | When to include | Content guidance | +|---------|----------------|------------------| +| Breaking Changes | At least one breaking change confirmed by expert agent or PR checkbox | One-liner at top + collapsible migration guide with before/after examples | +| Deprecations | At least one NEW deprecation introduced in this release | One-liner with replacement, removal version, and PR link | +| New Features | At least one user-facing feature added | One sentence, lead with user impact, PR link at end | +| Bug Fixes | At least one bug fixed | One sentence, PR link at end | +| Misc | Any internal changes (refactors, tests, CI, naming) | One sentence, PR link at end | +| Dependencies | Any renovate/dependabot PRs | Table of module name + version | +| New Contributors | GitHub auto-generated section lists new contributors | Celebrate them by handle | +| Full Commit Log | Always | Verbatim GitHub auto-generated "What's Changed" block inside `<details>` | + +## Writing guidelines + +- **One sentence per bullet** — lead with user impact, not implementation detail. +- **Breaking change one-liners** must say what breaks and what the user must do. +- **Migration guides** always include before/after YAML or code, plus numbered steps. +- **Do not reformat the auto-generated commit log** — paste it exactly as GitHub produces it. +- **Link PRs** as `[#NNN](url)` — not bare numbers. From e92dba46b5f808fe4270932d4225ef292ecda22f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:02:52 -0400 Subject: [PATCH 050/117] Update module github.com/stacklok/toolhive-catalog to v0.20260415.0 (#4839) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: reyortiz3 <reynier@stacklok.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index ec4c677f99..9d9b53c3fc 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( github.com/google/uuid v1.6.0 github.com/lestrrat-go/httprc/v3 v3.0.5 github.com/lestrrat-go/jwx/v3 v3.0.13 - github.com/mark3labs/mcp-go v0.47.1 + github.com/mark3labs/mcp-go v0.48.0 github.com/moby/moby/client v0.4.0 github.com/modelcontextprotocol/registry v1.5.0 github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 @@ -45,8 +45,8 @@ require ( github.com/redis/go-redis/v9 v9.18.0 github.com/shirou/gopsutil/v4 v4.26.2 github.com/spf13/viper v1.21.0 - github.com/stacklok/toolhive-catalog v0.20260413.0 - github.com/stacklok/toolhive-core v0.0.15 + github.com/stacklok/toolhive-catalog v0.20260415.0 + github.com/stacklok/toolhive-core v0.0.16 github.com/stretchr/testify v1.11.1 github.com/swaggo/swag/v2 v2.0.0-rc5 github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd diff --git a/go.sum b/go.sum index 70f8cf0b79..2e7128bac8 100644 --- a/go.sum +++ b/go.sum @@ -558,8 +558,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2 github.com/luna-duclos/instrumentedsql v1.1.3/go.mod h1:9J1njvFds+zN7y85EDhN9XNQLANWwZt2ULeIC8yMNYs= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mark3labs/mcp-go v0.47.1 h1:A9sJJ20mscl/ssLYHjodfaoBmq6uuhMG7pAPNYaQymQ= -github.com/mark3labs/mcp-go v0.47.1/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag= +github.com/mark3labs/mcp-go v0.48.0 h1:o+MXuGW/HCeR2ny5LcAcZQn2bo6I2xaZMEHnpRG+dtw= +github.com/mark3labs/mcp-go v0.48.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= @@ -796,10 +796,10 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stacklok/toolhive-catalog v0.20260413.0 h1:kXpbPJCAqVC2uh9aYRaol3Inhl5DZCV4vx/KqkrVbGg= -github.com/stacklok/toolhive-catalog v0.20260413.0/go.mod h1:aQ9lN8BPmA+m5G+Hf6bDv7Ty0RopWy9C6hhcSbzA8iI= -github.com/stacklok/toolhive-core v0.0.15 h1:uhkB4knchTZc6zOiVUHfJe7R/qxrshvhww6HVeFVfIg= -github.com/stacklok/toolhive-core v0.0.15/go.mod h1:6KW90AxiKnB2bu70L4qa3sKjHFaUi2lgl8bmPj1Q16g= +github.com/stacklok/toolhive-catalog v0.20260415.0 h1:jms1mO9yfCUq/kf2sUCPSLYBSZgoaBV/usdguQopULs= +github.com/stacklok/toolhive-catalog v0.20260415.0/go.mod h1:mAEG6GBxBDN7Ygfpla6C6aigsV3RT658Ur3NfNK9kbg= +github.com/stacklok/toolhive-core v0.0.16 h1:Td/o/zrO3Pbr6tkx4r0k8Bqn9DySlwgah2vknE8tj5A= +github.com/stacklok/toolhive-core v0.0.16/go.mod h1:iHp39rCHZxXzU5FmXKK9aoH48NE+K0ob+MtdTS9CvAk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= From 12f25e5b7c6c60372c5b3093ae957e3726fe0a2a Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota <yolanda@stacklok.com> Date: Thu, 16 Apr 2026 09:18:39 +0200 Subject: [PATCH 051/117] Expand singleflight boundary in ValidatingCache.Get to cover full hit+check and miss+load path (#4798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the singleflight.Do boundary to wrap the entire Get operation rather than only the miss+load path. This coalesces concurrent liveness checks for the same key into a single storage.Load round-trip, reducing storage pressure under concurrent access. With only one goroutine running per key at a time: - ContainsOrAdd is replaced with plain Add — the concurrent-writer race between multiple Get goroutines no longer exists - The ContainsOrAdd + Get race-handling block is removed entirely - The onEvict field on the struct is removed (it was only needed to manually call the callback in the discarded-load path) As a side effect of the new design, an expired cache hit now evicts the entry and falls through to load within the same singleflight operation, returning the fresh value instead of (zero, false). Closes #4729 Co-authored-by: taskbot <taskbot@users.noreply.github.com> --- pkg/cache/validating_cache.go | 129 ++++++++++------- pkg/cache/validating_cache_test.go | 220 +++++++++++++++++++---------- 2 files changed, 224 insertions(+), 125 deletions(-) diff --git a/pkg/cache/validating_cache.go b/pkg/cache/validating_cache.go index b34dfee8b8..c3f6342f79 100644 --- a/pkg/cache/validating_cache.go +++ b/pkg/cache/validating_cache.go @@ -8,6 +8,8 @@ package cache import ( "errors" "fmt" + "reflect" + "sync" lru "github.com/hashicorp/golang-lru/v2" "golang.org/x/sync/singleflight" @@ -18,25 +20,26 @@ import ( var ErrExpired = errors.New("cache entry expired") // ValidatingCache is a node-local write-through cache backed by a -// capacity-bounded LRU map, with singleflight-deduplicated restore on cache -// miss and lazy liveness validation on cache hit. +// capacity-bounded LRU map, with singleflight-deduplicated Get operations and +// lazy liveness validation on cache hit. // // Type parameter K is the key type (must be comparable). // Type parameter V is the cached value type. // -// The no-resurrection invariant (preventing a concurrent restore from -// overwriting a deletion) is enforced via ContainsOrAdd: if a concurrent -// writer stored a value between load() returning and the cache being updated, -// the prior writer's value wins and the just-loaded value is discarded via -// onEvict. +// The entire Get operation — cache hit validation and miss load — runs under a +// singleflight group so at most one operation executes concurrently per key. +// Concurrent callers for the same key share the result, coalescing both +// liveness checks and storage round-trips into a single operation per key. type ValidatingCache[K comparable, V any] struct { lruCache *lru.Cache[K, V] flight singleflight.Group load func(key K) (V, error) check func(key K, val V) error - // onEvict is kept here so we can call it when discarding a concurrently - // loaded value that lost the race to a prior writer. - onEvict func(key K, val V) + onEvict func(K, V) + // mu serializes Set against the conditional eviction in getHit. + // check() runs outside the lock to avoid holding it during I/O; the lock + // is only held for the short Peek+Remove sequence. + mu sync.Mutex } // New creates a ValidatingCache with the given capacity and callbacks. @@ -88,8 +91,16 @@ func New[K comparable, V any]( func (c *ValidatingCache[K, V]) getHit(key K, val V) (V, bool) { if err := c.check(key, val); err != nil { if errors.Is(err, ErrExpired) { - // Remove fires the eviction callback automatically. - c.lruCache.Remove(key) + // check() ran outside the lock to avoid holding it during I/O. + // Re-verify under the lock that the entry hasn't been replaced by a + // concurrent Set before removing it; otherwise we would evict a + // freshly-written value that the caller intended to keep. + c.mu.Lock() + if current, ok := c.lruCache.Peek(key); ok && sameEntry(current, val) { + // Remove fires the eviction callback automatically. + c.lruCache.Remove(key) + } + c.mu.Unlock() var zero V return zero, false } @@ -97,54 +108,57 @@ func (c *ValidatingCache[K, V]) getHit(key K, val V) (V, bool) { return val, true } -// Get returns the value for key, loading it on a cache miss. On a cache hit -// the entry's liveness is validated via the check function provided to New: -// ErrExpired evicts the entry and returns (zero, false); transient errors -// return the cached value unchanged. On a cache miss, load is called under a -// singleflight group so at most one restore runs concurrently per key. +// Get returns the value for key, loading it on a cache miss. The entire +// operation — cache hit validation and miss load — runs under a singleflight +// group so at most one operation executes concurrently per key. Concurrent +// callers for the same key share the result. +// +// On a cache hit the entry's liveness is validated via the check function +// provided to New: ErrExpired evicts the entry and falls through to load; +// transient errors return the cached value unchanged. On a cache miss, load +// is called to restore the value. +// +// The returned bool is false whenever the value is unavailable — either +// because load returned an error or because the key does not exist in the +// backing store. Callers cannot distinguish these two cases. func (c *ValidatingCache[K, V]) Get(key K) (V, bool) { - if val, ok := c.lruCache.Get(key); ok { - return c.getHit(key, val) - } - - // Cache miss: use singleflight to prevent concurrent restores for the same key. type result struct{ v V } + // fmt.Sprint(key) is the singleflight key. For string keys this is + // exact. For other types, distinct values with identical string + // representations would be incorrectly coalesced — avoid non-string K + // types unless their fmt.Sprint output is guaranteed unique. raw, err, _ := c.flight.Do(fmt.Sprint(key), func() (any, error) { - // Re-check the cache: a concurrent singleflight group may have stored - // the value between our miss check above and acquiring this group. - if existing, ok := c.lruCache.Get(key); ok { - return result{v: existing}, nil + // Cache hit path: validate liveness. + if val, ok := c.lruCache.Get(key); ok { + v, alive := c.getHit(key, val) + if alive { + return result{v: v}, nil + } + // Entry expired and evicted; fall through to load. } + // Cache miss (or expired): load the value and store it. v, loadErr := c.load(key) if loadErr != nil { return nil, loadErr } - // Guard against a concurrent Set or Remove that occurred while load() was - // running. ContainsOrAdd stores only if absent; if another writer got - // in first, their value wins and we discard ours via onEvict. - ok, _ := c.lruCache.ContainsOrAdd(key, v) - if ok { - // Another writer stored a value first; discard our loaded value and - // return the winner's. ContainsOrAdd and Get are separate lock - // acquisitions, so the winner may itself have been evicted by LRU - // pressure between the two calls — fall back to our freshly loaded - // value in that case rather than returning a zero value. - winner, found := c.lruCache.Get(key) - // Discard our loaded value in favour of the winner (or clean up if - // the winner was itself evicted between ContainsOrAdd and Get). - if c.onEvict != nil { - c.onEvict(key, v) + // Guard against a concurrent Set that occurred while load() was running. + // ContainsOrAdd stores only if absent; if a concurrent Set got in first, + // their value wins and we return it instead. + if alreadySet, _ := c.lruCache.ContainsOrAdd(key, v); alreadySet { + if winner, ok := c.lruCache.Get(key); ok { + // Winner confirmed: v is definitively discarded — release its resources. + if c.onEvict != nil { + c.onEvict(key, v) + } + return result{v: winner}, nil } - if !found { - // Winner was evicted before we could retrieve it; signal a cache - // miss so the caller retries rather than receiving a stale value. - return nil, nil - } - return result{v: winner}, nil + // The concurrent winner was itself evicted by LRU pressure between + // ContainsOrAdd and Get. Fall back to storing v — do NOT call onEvict + // since v has not been released and is still valid. + c.lruCache.Add(key, v) } - return result{v: v}, nil }) if err != nil { @@ -159,6 +173,8 @@ func (c *ValidatingCache[K, V]) Get(key K) (V, bool) { // cache is at capacity, the least-recently-used entry is evicted first and // onEvict is called for it. func (c *ValidatingCache[K, V]) Set(key K, value V) { + c.mu.Lock() + defer c.mu.Unlock() c.lruCache.Add(key, value) } @@ -166,3 +182,20 @@ func (c *ValidatingCache[K, V]) Set(key K, value V) { func (c *ValidatingCache[K, V]) Len() int { return c.lruCache.Len() } + +// sameEntry reports whether a and b are the same cache entry. +// For pointer types it compares addresses (identity), so a concurrent Set that +// stores a distinct new value is never mistaken for the stale entry. For +// non-pointer types it falls back to reflect.DeepEqual, which is safe for all +// comparable and non-comparable types. +func sameEntry[V any](a, b V) bool { + ra := reflect.ValueOf(any(a)) + if ra.IsValid() { + switch ra.Kind() { //nolint:exhaustive + case reflect.Ptr, reflect.UnsafePointer: + rb := reflect.ValueOf(any(b)) + return rb.IsValid() && ra.Pointer() == rb.Pointer() + } + } + return reflect.DeepEqual(a, b) +} diff --git a/pkg/cache/validating_cache_test.go b/pkg/cache/validating_cache_test.go index 209d8769d3..0d78536e35 100644 --- a/pkg/cache/validating_cache_test.go +++ b/pkg/cache/validating_cache_test.go @@ -9,6 +9,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -44,6 +45,20 @@ func TestValidatingCache_New_PanicsOnNegativeCapacity(t *testing.T) { }) } +func TestValidatingCache_New_PanicsOnNilLoad(t *testing.T) { + t.Parallel() + assert.Panics(t, func() { + New[string, string](1, nil, alwaysAliveCheck, nil) + }) +} + +func TestValidatingCache_New_PanicsOnNilCheck(t *testing.T) { + t.Parallel() + assert.Panics(t, func() { + New(1, func(_ string) (string, error) { return "", nil }, nil, nil) + }) +} + // --------------------------------------------------------------------------- // Cache miss / restore // --------------------------------------------------------------------------- @@ -80,8 +95,8 @@ func TestValidatingCache_CacheMiss_StoresResult(t *testing.T) { nil, ) - c.Get("k") //nolint:errcheck - c.Get("k") //nolint:errcheck + c.Get("k") + c.Get("k") assert.Equal(t, 1, calls, "load should be called only once after caching") } @@ -112,7 +127,7 @@ func TestValidatingCache_CacheHit_AliveCheck_ReturnsCached(t *testing.T) { alwaysAliveCheck, nil, ) - c.Get("k") //nolint:errcheck // prime the cache + c.Get("k") // prime the cache // Second Get should return cached value without calling load again. v, ok := c.Get("k") @@ -133,11 +148,13 @@ func TestValidatingCache_CacheHit_Expired_EvictsAndCallsOnEvict(t *testing.T) { evictedVal = val }, ) - c.Get("k") //nolint:errcheck // prime the cache + c.Get("k") // prime the cache + // With singleflight wrapping the full Get, an expired hit evicts the entry + // and falls through to load within the same operation, returning the fresh value. v, ok := c.Get("k") - assert.False(t, ok) - assert.Empty(t, v) + require.True(t, ok) + assert.Equal(t, "v", v) assert.Equal(t, "k", evictedKey) assert.Equal(t, "v", evictedVal) } @@ -161,11 +178,11 @@ func TestValidatingCache_CacheHit_Expired_EntryRemovedFromCache(t *testing.T) { nil, ) - c.Get("k") //nolint:errcheck // prime the cache; check returns alive + c.Get("k") // prime the cache; check returns alive expired = true - c.Get("k") //nolint:errcheck // check returns ErrExpired → evict + c.Get("k") // check returns ErrExpired → evict expired = false - c.Get("k") //nolint:errcheck // cache miss again → load called + c.Get("k") // cache miss again → load called assert.Equal(t, 2, calls, "load should be called twice: initial + after eviction") } @@ -178,7 +195,7 @@ func TestValidatingCache_CacheHit_TransientCheckError_ReturnsCached(t *testing.T func(_ string, _ string) error { return errors.New("transient storage error") }, nil, ) - c.Get("k") //nolint:errcheck // prime the cache + c.Get("k") // prime the cache v, ok := c.Get("k") require.True(t, ok) @@ -213,7 +230,7 @@ func TestValidatingCache_Set_UpdatesExisting(t *testing.T) { alwaysAliveCheck, nil, ) - c.Get("k") //nolint:errcheck // prime with "loaded" + c.Get("k") // prime with "loaded" c.Set("k", "updated") v, ok := c.Get("k") @@ -242,9 +259,9 @@ func TestValidatingCache_LRU_EvictsLeastRecentlyUsed(t *testing.T) { }, ) - c.Get("a") //nolint:errcheck // a=MRU - c.Get("b") //nolint:errcheck // b=MRU, a=LRU - c.Get("c") //nolint:errcheck // c=MRU, b, a=LRU → evicts a + c.Get("a") // a=MRU + c.Get("b") // b=MRU, a=LRU + c.Get("c") // c=MRU, b, a=LRU → evicts a mu.Lock() defer mu.Unlock() @@ -273,10 +290,10 @@ func TestValidatingCache_LRU_GetRefreshesMRUPosition(t *testing.T) { }, ) - c.Get("a") //nolint:errcheck // a loaded (MRU) - c.Get("b") //nolint:errcheck // b loaded (MRU), a=LRU - c.Get("a") //nolint:errcheck // a accessed → a becomes MRU, b=LRU - c.Get("c") //nolint:errcheck // c loaded → evicts b (LRU), not a + c.Get("a") // a loaded (MRU) + c.Get("b") // b loaded (MRU), a=LRU + c.Get("a") // a accessed → a becomes MRU, b=LRU + c.Get("c") // c loaded → evicts b (LRU), not a mu.Lock() defer mu.Unlock() @@ -302,10 +319,10 @@ func TestValidatingCache_LRU_SetRefreshesMRUPosition(t *testing.T) { }, ) - c.Get("a") //nolint:errcheck // a=MRU - c.Get("b") //nolint:errcheck // b=MRU, a=LRU + c.Get("a") // a=MRU + c.Get("b") // b=MRU, a=LRU c.Set("a", "x") // Set refreshes a to MRU; b becomes LRU - c.Get("c") //nolint:errcheck // c loaded → evicts b + c.Get("c") // c loaded → evicts b mu.Lock() defer mu.Unlock() @@ -328,9 +345,9 @@ func TestValidatingCache_LRU_CapacityOne(t *testing.T) { }, ) - c.Get("a") //nolint:errcheck - c.Get("b") //nolint:errcheck // evicts a - c.Get("c") //nolint:errcheck // evicts b + c.Get("a") + c.Get("b") // evicts a + c.Get("c") // evicts b mu.Lock() defer mu.Unlock() @@ -350,7 +367,7 @@ func TestValidatingCache_LRU_LargeCapacityNoEviction(t *testing.T) { ) for i := range n { - c.Get(fmt.Sprintf("k%d", i)) //nolint:errcheck + c.Get(fmt.Sprintf("k%d", i)) } assert.Equal(t, n, c.Len(), "no entries should be evicted when under capacity") } @@ -365,9 +382,9 @@ func TestValidatingCache_LRU_Len(t *testing.T) { ) assert.Equal(t, 0, c.Len()) - c.Get("a") //nolint:errcheck + c.Get("a") assert.Equal(t, 1, c.Len()) - c.Get("b") //nolint:errcheck + c.Get("b") assert.Equal(t, 2, c.Len()) } @@ -375,19 +392,24 @@ func TestValidatingCache_LRU_Len(t *testing.T) { // Re-check inside singleflight (TOCTOU prevention) // --------------------------------------------------------------------------- -func TestValidatingCache_Singleflight_ReCheckReturnsPreStoredValue(t *testing.T) { +// TestValidatingCache_Singleflight_SetBeforeLoadReturns verifies that when +// Set is called for a key before the in-flight load completes, the Set value +// wins: ContainsOrAdd does not overwrite the writer's value, and the caller +// receives the Set value. +func TestValidatingCache_Singleflight_SetBeforeLoadReturns(t *testing.T) { t.Parallel() var loadCount atomic.Int32 - // The load function is gated: it waits until we signal that an external - // Set has been applied, mimicking a value written by another goroutine - // between the miss check and the singleflight group. - storeApplied := make(chan struct{}) + // loadReached is closed once load has definitely started, so the test can + // inject a concurrent Set before load returns. + loadReached := make(chan struct{}) + allowReturn := make(chan struct{}) c := newStringCache( func(_ string) (string, error) { - <-storeApplied // wait until external Set is applied + close(loadReached) // signal: load is now in-flight + <-allowReturn // block until test injects the concurrent Set loadCount.Add(1) return "from-load", nil }, @@ -400,70 +422,114 @@ func TestValidatingCache_Singleflight_ReCheckReturnsPreStoredValue(t *testing.T) result string ok bool ) - wg.Go(func() { + wg.Add(1) + go func() { + defer wg.Done() result, ok = c.Get("k") - }) + }() - // Set the value externally to simulate a concurrent writer, then release - // the load function. The re-check at the top of the singleflight function - // fires first and finds "external-value", so load is never called. + // Wait until load is definitely executing, then write via Set so that + // ContainsOrAdd inside the miss path finds the key already present. + <-loadReached c.Set("k", "external-value") - close(storeApplied) + close(allowReturn) // let load return "from-load" wg.Wait() require.True(t, ok) - assert.Equal(t, "external-value", result) - assert.Equal(t, int32(0), loadCount.Load(), "re-check should short-circuit before load is called") + assert.Equal(t, "external-value", result, "Set value should win over concurrent load") + assert.Equal(t, int32(1), loadCount.Load(), "load is called but its value is discarded") } -// TestValidatingCache_Singleflight_EvictsLoserWhenLoadRacesWriter covers the -// path where load() runs to completion but loses the ContainsOrAdd race to a -// concurrent Set. The loaded-but-discarded value must be passed to onEvict so -// any resources it holds (e.g. connections) can be cleaned up. -func TestValidatingCache_Singleflight_EvictsLoserWhenLoadRacesWriter(t *testing.T) { +// TestValidatingCache_Singleflight_DeduplicatesConcurrentLivenessChecks verifies +// that concurrent Gets on an expired entry coalesce into a single load call. +// +// Design: load blocks until all goroutines have signalled they are about to +// call Get. Because expired.Store(false) runs inside the singleflight callback +// (before it returns), goroutines that arrive late — after load() has already +// returned — find either: +// +// (a) the singleflight still in progress (they join it and share the result), or +// (b) a live entry in the cache (expired=false, check passes, no load needed). +// +// Either way loadCount == 1 is an invariant enforced by the implementation, not +// by timing luck. +func TestValidatingCache_Singleflight_DeduplicatesConcurrentLivenessChecks(t *testing.T) { t.Parallel() - // loadReached is closed when load() is about to return, giving us a hook to - // race a Set before ContainsOrAdd is called. - loadReached := make(chan struct{}) - // allowReturn lets the test control exactly when load() returns. - allowReturn := make(chan struct{}) + const goroutines = 10 + var ( + loadCount atomic.Int32 + allStarted sync.WaitGroup + wg sync.WaitGroup + results = make([]string, goroutines) + oks = make([]bool, goroutines) + ) + + var expired atomic.Bool - var evictedKey, evictedVal string c := newStringCache( func(_ string) (string, error) { - close(loadReached) // signal: load has run - <-allowReturn // wait until test injects the concurrent Set - return "from-load", nil + // Wait until every goroutine has signalled it is about to call Get. + // allStarted.Done() is called before Get(), so this unblocks once + // the goroutine scheduler has scheduled all callers — not necessarily + // once they've all entered flight.Do. That is fine: goroutines + // arriving after load() returns find a live entry (expired is cleared + // below) and return early via the cache-hit path. loadCount = 1 + // either way. + allStarted.Wait() + loadCount.Add(1) + expired.Store(false) // refresh: late arrivals see a live entry + return "reloaded", nil }, - alwaysAliveCheck, - func(key, val string) { - evictedKey = key - evictedVal = val + func(_ string, _ string) error { + if expired.Load() { + return ErrExpired + } + return nil }, + nil, ) - var wg sync.WaitGroup - var gotVal string - var gotOk bool - wg.Go(func() { - gotVal, gotOk = c.Get("k") - }) + // Prime the cache with a live entry. allStarted has count 0 here, so + // Wait() inside load returns immediately — no deadlock. + _, ok := c.Get("k") + require.True(t, ok) + assert.Equal(t, int32(1), loadCount.Load()) - // Wait until load() is running, then inject a concurrent Set so that - // ContainsOrAdd finds the key already present and discards the loaded value. - <-loadReached - c.Set("k", "from-set") - close(allowReturn) // let load() return "from-load" - wg.Wait() + // Reset state: add the goroutine count first, then mark expired so load + // will block waiting for goroutines to pile up. + loadCount.Store(0) + allStarted.Add(goroutines) + expired.Store(true) - // The concurrent Set wins: caller receives the Set value. - require.True(t, gotOk) - assert.Equal(t, "from-set", gotVal, "concurrent Set value should win") + for i := range goroutines { + wg.Add(1) + go func(i int) { + defer wg.Done() + allStarted.Done() // signal: about to call Get + results[i], oks[i] = c.Get("k") + }(i) + } + + // Use the test deadline as a safeguard so a future refactor that breaks + // the allStarted synchronisation causes a fast failure rather than a hang. + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + deadline, ok := t.Deadline() + if !ok { + deadline = time.Now().Add(10 * time.Second) + } + select { + case <-done: + case <-time.After(time.Until(deadline)): + t.Fatal("timed out waiting for goroutines — possible deadlock in load synchronisation") + } - // The loaded-but-discarded value must be passed to onEvict. - assert.Equal(t, "k", evictedKey, "onEvict must be called for the discarded loaded value") - assert.Equal(t, "from-load", evictedVal, "onEvict must receive the discarded loaded value") + assert.Equal(t, int32(1), loadCount.Load(), "concurrent expired-entry Gets should coalesce to a single load") + for i := range goroutines { + assert.True(t, oks[i], "all goroutines should get ok=true") + assert.Equal(t, "reloaded", results[i]) + } } // --------------------------------------------------------------------------- From e54e6aee433ca0e59bf4cbc87eb050c49703ccb4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 05:58:48 -0400 Subject: [PATCH 052/117] Update module github.com/stacklok/toolhive-catalog to v0.20260416.0 (#4876) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 12 ++++++------ go.sum | 60 +++++++++++++++++++++++++++++----------------------------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/go.mod b/go.mod index 9d9b53c3fc..7e7330ee30 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/containerd/errdefs v1.0.0 - github.com/coreos/go-oidc/v3 v3.17.0 + github.com/coreos/go-oidc/v3 v3.18.0 github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.6.0 github.com/evanphx/json-patch/v5 v5.9.11 @@ -32,7 +32,7 @@ require ( github.com/lestrrat-go/jwx/v3 v3.0.13 github.com/mark3labs/mcp-go v0.48.0 github.com/moby/moby/client v0.4.0 - github.com/modelcontextprotocol/registry v1.5.0 + github.com/modelcontextprotocol/registry v1.6.0 github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 github.com/olekukonko/tablewriter v1.1.4 github.com/onsi/ginkgo/v2 v2.28.1 @@ -45,7 +45,7 @@ require ( github.com/redis/go-redis/v9 v9.18.0 github.com/shirou/gopsutil/v4 v4.26.2 github.com/spf13/viper v1.21.0 - github.com/stacklok/toolhive-catalog v0.20260415.0 + github.com/stacklok/toolhive-catalog v0.20260416.0 github.com/stacklok/toolhive-core v0.0.16 github.com/stretchr/testify v1.11.1 github.com/swaggo/swag/v2 v2.0.0-rc5 @@ -56,7 +56,7 @@ require ( github.com/zalando/go-keyring v0.2.8 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 - go.opentelemetry.io/otel/exporters/prometheus v0.63.0 + go.opentelemetry.io/otel/exporters/prometheus v0.65.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 go.uber.org/mock v0.6.0 @@ -232,7 +232,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect @@ -285,7 +285,7 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.ngrok.com/muxado/v2 v2.0.1 // indirect golang.org/x/exp/event v0.0.0-20260312153236-7ab1446f8b90 // indirect diff --git a/go.sum b/go.sum index 2e7128bac8..d12d7207a5 100644 --- a/go.sum +++ b/go.sum @@ -2,18 +2,18 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= -cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= -cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= -cloud.google.com/go/kms v1.26.0 h1:cK9mN2cf+9V63D3H1f6koxTatWy39aTI/hCjz1I+adU= -cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58= -cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= -cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= +cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY= +cloud.google.com/go/kms v1.29.0 h1:bAW1C5FQf+6GhPkywQzPlsULALCG7c16qpXLFGV9ivY= +cloud.google.com/go/kms v1.29.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= +cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= @@ -143,8 +143,8 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= -github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= -github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= @@ -389,10 +389,10 @@ github.com/google/trillian v1.7.2 h1:EPBxc4YWY4Ak8tcuhyFleY+zYlbCDCa4Sn24e1Ka8Js github.com/google/trillian v1.7.2/go.mod h1:mfQJW4qRH6/ilABtPYNBerVJAJ/upxHLX81zxNQw05s= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= -github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= -github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= -github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= +github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= @@ -474,8 +474,8 @@ github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9 github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw= -github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= -github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= @@ -617,8 +617,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/modelcontextprotocol/registry v1.5.0 h1:V2SnpNc9Yq5F8udOHJdxCZB+Uv+TRMWTzpyEgzJjnxY= -github.com/modelcontextprotocol/registry v1.5.0/go.mod h1:0zsxw7/nbvDaur8FDius7NQMg6C8vRIz5S+atl04pK0= +github.com/modelcontextprotocol/registry v1.6.0 h1:KBUecboLdaf7T564TMY77PwjE0qPXk7peIWtk25oXzo= +github.com/modelcontextprotocol/registry v1.6.0/go.mod h1:NMhz9I+4Kd8g5GUDaNdXaVgxB6BP/DHaLGwSDP5WaQw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -708,8 +708,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -796,8 +796,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stacklok/toolhive-catalog v0.20260415.0 h1:jms1mO9yfCUq/kf2sUCPSLYBSZgoaBV/usdguQopULs= -github.com/stacklok/toolhive-catalog v0.20260415.0/go.mod h1:mAEG6GBxBDN7Ygfpla6C6aigsV3RT658Ur3NfNK9kbg= +github.com/stacklok/toolhive-catalog v0.20260416.0 h1:e2b8/90d3/VoA7JVKIk6eceEsbaBtY6uET9WB/u32vs= +github.com/stacklok/toolhive-catalog v0.20260416.0/go.mod h1:KveauhiKSukvUr2SYxDnlSeW3JmfdbXefuZxpiOwQ6A= github.com/stacklok/toolhive-core v0.0.16 h1:Td/o/zrO3Pbr6tkx4r0k8Bqn9DySlwgah2vknE8tj5A= github.com/stacklok/toolhive-core v0.0.16/go.mod h1:iHp39rCHZxXzU5FmXKK9aoH48NE+K0ob+MtdTS9CvAk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -922,8 +922,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bT go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= -go.opentelemetry.io/otel/exporters/prometheus v0.63.0 h1:OLo1FNb0pBZykLqbKRZolKtGZd0Waqlr240YdMEnhhg= -go.opentelemetry.io/otel/exporters/prometheus v0.63.0/go.mod h1:8yeQAdhrK5xsWuFehO13Dk/Xb9FuhZoVpJfpoNCfJnw= +go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= +go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= go.opentelemetry.io/otel/exporters/zipkin v1.21.0 h1:D+Gv6lSfrFBWmQYyxKjDd0Zuld9SRXpIrEsKZvE4DO4= go.opentelemetry.io/otel/exporters/zipkin v1.21.0/go.mod h1:83oMKR6DzmHisFOW3I+yIMGZUTjxiWaiBI8M8+TU5zE= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= @@ -961,8 +961,8 @@ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.ngrok.com/muxado/v2 v2.0.1 h1:jM9i6Pom6GGmnPrHKNR6OJRrUoHFkSZlJ3/S0zqdVpY= @@ -1116,10 +1116,10 @@ gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU= -google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= +google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= From bcc9588c5bfae35bb863e290540395226a9e25ab Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota <yolanda@stacklok.com> Date: Thu, 16 Apr 2026 13:04:28 +0200 Subject: [PATCH 053/117] Persist restored session metadata to Redis in loadSession (#4842) After a successful RestoreSession call, write the restored session's metadata back to Redis so per-backend session IDs stay current. Backends that ignore Mcp-Session-Id hints (e.g. SSE transports) assign a fresh ID on every restore; without this write the stale IDs persisted in Redis indefinitely. With Redis kept consistent, revert checkSession's comparison from the narrowed MetadataKeyBackendIDs-only workaround (introduced in #4724) back to maps.Equal, restoring full cross-pod consistency detection for any metadata drift. Closes #4836 Co-authored-by: taskbot <taskbot@users.noreply.github.com> --- .../server/sessionmanager/session_manager.go | 57 ++++- .../sessionmanager/session_manager_test.go | 201 ++++++++++++++++++ 2 files changed, 249 insertions(+), 9 deletions(-) diff --git a/pkg/vmcp/server/sessionmanager/session_manager.go b/pkg/vmcp/server/sessionmanager/session_manager.go index cad8365567..0b01e9b945 100644 --- a/pkg/vmcp/server/sessionmanager/session_manager.go +++ b/pkg/vmcp/server/sessionmanager/session_manager.go @@ -182,11 +182,18 @@ const createSessionStorageTimeout = 5 * time.Second // timeout bounds how long a slow or unreachable Redis can stall a request goroutine. const validateTimeout = 3 * time.Second -// restoreStorageTimeout bounds the storage.Load call in the GetMultiSession -// cache-miss restore path. The operation is a single Redis GETEX, so 3 s is -// generous. +// restoreStorageTimeout bounds storage.Load calls (GETEX) in the +// GetMultiSession restore path (loadSession) and in the checkSession liveness +// check. Both are single-key Redis reads; 3 s is generous. const restoreStorageTimeout = 3 * time.Second +// restoreMetadataWriteTimeout bounds the storage.Update call that persists +// the restored session's metadata back to Redis after a successful +// RestoreSession. Single-key Redis SET XX operation; 5 s is consistent with +// other write timeouts (createSessionStorageTimeout, terminateTimeout, +// decorateTimeout, notifyBackendExpiredTimeout). +const restoreMetadataWriteTimeout = 5 * time.Second + // restoreSessionTimeout bounds factory.RestoreSession in the GetMultiSession // cache-miss path. RestoreSession opens HTTP connections to each backend, so // we allow more time than a simple storage read. Aligned with discoveryTimeout @@ -658,12 +665,13 @@ func (sm *Manager) checkSession(sessionID string, sess vmcpsession.MultiSession) // // We intentionally compare only MetadataKeyBackendIDs rather than the full // metadata map. Per-backend session IDs (MetadataKeyBackendSessionPrefix+*) - // are the session IDs negotiated by the restoring pod's backend connections. - // RestoreSession sends the stored IDs as Mcp-Session-Id hints, so a backend - // that honors session resumption will return the same ID — but not all backends - // do (e.g. SSE transports have no session ID at all). Comparing the full map - // would evict on every cross-pod cache hit whenever any backend assigns a - // fresh ID, preventing tools from ever being served. + // are the session IDs negotiated by each pod's independent RestoreSession call. + // Backends that do not honor Mcp-Session-Id hints (e.g. SSE transports, some + // StreamableHTTP backends) assign a fresh ID on every restore, so different pods + // legitimately hold different per-backend IDs for the same session. Comparing + // the full map would cause each pod's loadSession write-back to invalidate all + // other pods' cached sessions, creating an infinite eviction storm that prevents + // tools from ever being served in multi-pod deployments. sessBackendIDs := sess.GetMetadata()[vmcpsession.MetadataKeyBackendIDs] if sessBackendIDs != metadata[vmcpsession.MetadataKeyBackendIDs] { return cache.ErrExpired @@ -715,6 +723,37 @@ func (sm *Manager) loadSession(sessionID string) (vmcpsession.MultiSession, erro return nil, restoreErr } + // Persist the restored session's metadata back to Redis so that + // per-backend session IDs are kept current. Backends that do not honor + // Mcp-Session-Id hints (e.g. SSE transports) assign a fresh ID on every + // restore; without this write the stale IDs would persist in Redis + // indefinitely. + // + // We use Update (SET XX) rather than Upsert so we never resurrect a key + // that was concurrently deleted (Terminate / TTL expiry). A (false, nil) + // result means the key is already gone — treat it as not found so the + // cache never serves a session that no longer exists in storage. + updateCtx, updateCancel := context.WithTimeout(context.Background(), restoreMetadataWriteTimeout) + defer updateCancel() + updated, updateErr := sm.storage.Update(updateCtx, sessionID, restored.GetMetadata()) + if updateErr != nil { + slog.Warn("Manager.loadSession: failed to persist restored session metadata", + "session_id", sessionID, "error", updateErr) + // Non-fatal: the session is still usable on this pod. checkSession + // will detect metadata drift on the next liveness check and evict, + // triggering a fresh restore that will retry the write. + } else if !updated { + // Session was concurrently deleted (Terminate / TTL expiry) between + // RestoreSession and this write — do not cache the restored session. + slog.Debug("Manager.loadSession: session already gone before metadata could be persisted; treating as not found", + "session_id", sessionID) + if closeErr := restored.Close(); closeErr != nil { + slog.Warn("Manager.loadSession: failed to close restored session after concurrent deletion", + "session_id", sessionID, "error", closeErr) + } + return nil, transportsession.ErrSessionNotFound + } + slog.Debug("Manager.loadSession: restored session from storage", "session_id", sessionID) return restored, nil } diff --git a/pkg/vmcp/server/sessionmanager/session_manager_test.go b/pkg/vmcp/server/sessionmanager/session_manager_test.go index a7bc3cbc80..bffce554f9 100644 --- a/pkg/vmcp/server/sessionmanager/session_manager_test.go +++ b/pkg/vmcp/server/sessionmanager/session_manager_test.go @@ -132,6 +132,39 @@ func (s *configurableFailDataStorage) Delete(ctx context.Context, id string) err return s.DataStorage.Delete(ctx, id) } +// deleteBeforeUpdateStorage wraps a real DataStorage and deletes the key +// from the underlying store on the first Update call, simulating a concurrent +// Terminate / TTL expiry that races with loadSession's metadata write-back. +// The Update then returns (false, nil) because the key no longer exists. +type deleteBeforeUpdateStorage struct { + transportsession.DataStorage + deleted bool +} + +func (s *deleteBeforeUpdateStorage) Update(ctx context.Context, id string, metadata map[string]string) (bool, error) { + if !s.deleted { + s.deleted = true + _ = s.Delete(ctx, id) + } + return s.DataStorage.Update(ctx, id, metadata) +} + +// errorOnUpdateStorage wraps a real DataStorage and returns an error on the +// first Update call, simulating a transient Redis write failure during +// loadSession's metadata write-back. +type errorOnUpdateStorage struct { + transportsession.DataStorage + errored bool +} + +func (s *errorOnUpdateStorage) Update(_ context.Context, _ string, _ map[string]string) (bool, error) { + if !s.errored { + s.errored = true + return false, errors.New("injected Update failure") + } + return true, nil +} + // fakeBackendRegistry is a simple BackendRegistry for tests. type fakeBackendRegistry struct { backends []vmcp.Backend @@ -933,6 +966,140 @@ func TestSessionManager_GetMultiSession(t *testing.T) { require.NotNil(t, multiSess) assert.Equal(t, sessionID, multiSess.ID()) }) + + t.Run("restore path: restored metadata is persisted back to storage", func(t *testing.T) { + t.Parallel() + + // Simulate a backend that doesn't honor Mcp-Session-Id hints (e.g. SSE + // transport): RestoreSession assigns a fresh per-backend session ID. + // loadSession must write the restored session's metadata back to Redis so + // that stale per-backend session IDs do not persist indefinitely in storage. + ctrl := gomock.NewController(t) + factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) + factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) + + sessionID := "restore-metadata-persist-session" + + // The restored session returns fresh per-backend session metadata. + freshMeta := map[string]string{ + sessiontypes.MetadataKeyTokenHash: "", + vmcpsession.MetadataKeyBackendIDs: "backend-a", + vmcpsession.MetadataKeyBackendSessionPrefix + "backend-a": "fresh-session-id", + } + restored := sessionmocks.NewMockMultiSession(ctrl) + restored.EXPECT().ID().Return(sessionID).AnyTimes() + restored.EXPECT().GetMetadata().Return(freshMeta).AnyTimes() + + factory.EXPECT(). + RestoreSession(gomock.Any(), sessionID, gomock.Any(), gomock.Any()). + Return(restored, nil).Times(1) + + sm, storage := newTestSessionManager(t, factory, newFakeRegistry()) + + // Seed storage with stale per-backend session ID. + staleMeta := map[string]string{ + sessiontypes.MetadataKeyTokenHash: "", + vmcpsession.MetadataKeyBackendIDs: "backend-a", + vmcpsession.MetadataKeyBackendSessionPrefix + "backend-a": "stale-session-id", + } + _, err := sm.storage.Create(context.Background(), sessionID, staleMeta) + require.NoError(t, err) + + multiSess, ok := sm.GetMultiSession(sessionID) + require.True(t, ok, "session must be restored") + require.NotNil(t, multiSess) + + // Verify storage now contains the fresh metadata written by loadSession. + storedMeta, loadErr := storage.Load(context.Background(), sessionID) + require.NoError(t, loadErr) + assert.Equal(t, freshMeta, storedMeta, + "loadSession must persist restored session metadata back to storage") + }) + + t.Run("restore path: concurrent delete between RestoreSession and Update returns ErrSessionNotFound", func(t *testing.T) { + t.Parallel() + + // Simulate a Terminate / TTL expiry that races with loadSession's + // metadata write-back: deleteBeforeUpdateStorage deletes the key just + // before the first Update, so Update returns (false, nil). + // loadSession must treat this as ErrSessionNotFound and NOT cache the + // restored session. + ctrl := gomock.NewController(t) + factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) + factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) + + sessionID := "restore-concurrent-delete-session" + restored := sessionmocks.NewMockMultiSession(ctrl) + restored.EXPECT().ID().Return(sessionID).AnyTimes() + restored.EXPECT().GetMetadata().Return(map[string]string{ + sessiontypes.MetadataKeyTokenHash: "", + }).AnyTimes() + + factory.EXPECT(). + RestoreSession(gomock.Any(), sessionID, gomock.Any(), gomock.Any()). + Return(restored, nil).Times(1) + // loadSession calls Close on the restored session when a concurrent + // delete is detected (Update returns false, nil). + restored.EXPECT().Close().Return(nil).Times(1) + + // Build Manager with the wrapping storage. + innerStorage := newTestSessionDataStorage(t) + racyStorage := &deleteBeforeUpdateStorage{DataStorage: innerStorage} + sm, cleanup, err := New(racyStorage, &FactoryConfig{Base: factory, CacheCapacity: 1000}, newFakeRegistry()) + require.NoError(t, err) + t.Cleanup(func() { _ = cleanup(context.Background()) }) + + // Seed the inner storage with a valid session record. + _, err = innerStorage.Create(context.Background(), sessionID, map[string]string{ + sessiontypes.MetadataKeyTokenHash: "", + }) + require.NoError(t, err) + + // GetMultiSession triggers loadSession; the racing delete causes + // Update to return (false, nil) → ErrSessionNotFound → (nil, false). + multiSess, ok := sm.GetMultiSession(sessionID) + assert.False(t, ok, "session deleted before metadata write-back must not be cached") + assert.Nil(t, multiSess) + }) + + t.Run("restore path: transient Update error is non-fatal, session is still returned", func(t *testing.T) { + t.Parallel() + + // A transient Redis write failure during loadSession's metadata write-back + // must not prevent the restored session from being cached and served. + // The session is still usable on this pod; checkSession will detect any + // metadata drift on the next liveness check and evict if necessary. + ctrl := gomock.NewController(t) + factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) + factory.EXPECT().MakeSessionWithID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) + + sessionID := "restore-update-error-session" + restored := newMockSession(t, ctrl, sessionID, nil) + + factory.EXPECT(). + RestoreSession(gomock.Any(), sessionID, gomock.Any(), gomock.Any()). + Return(restored, nil).Times(1) + + innerStorage := newTestSessionDataStorage(t) + faultyStorage := &errorOnUpdateStorage{DataStorage: innerStorage} + sm, cleanup, err := New(faultyStorage, &FactoryConfig{Base: factory, CacheCapacity: 1000}, newFakeRegistry()) + require.NoError(t, err) + t.Cleanup(func() { _ = cleanup(context.Background()) }) + + _, err = innerStorage.Create(context.Background(), sessionID, map[string]string{ + sessiontypes.MetadataKeyTokenHash: "", + }) + require.NoError(t, err) + + // Write failure must be non-fatal: session is still returned and cached. + multiSess, ok := sm.GetMultiSession(sessionID) + assert.True(t, ok, "transient Update error must not prevent session from being served") + assert.NotNil(t, multiSess) + assert.Equal(t, sessionID, multiSess.ID()) + }) } // --------------------------------------------------------------------------- @@ -2087,6 +2254,40 @@ func TestSessionManager_CheckSession(t *testing.T) { err = sm.checkSession(sessionID, cached) assert.NoError(t, err, "matching empty metadata must not cause eviction") }) + + t.Run("differing per-backend session IDs do not evict", func(t *testing.T) { + t.Parallel() + // In multi-pod deployments, each pod's RestoreSession independently + // negotiates its own per-backend session IDs with backends that do not + // honor Mcp-Session-Id hints (e.g. SSE transports). Each pod then + // writes its own IDs back to Redis via loadSession. checkSession must + // NOT evict when only per-backend session IDs differ — only when the + // backend ID list (MetadataKeyBackendIDs) changes. Evicting on per- + // backend ID drift would cause each pod's write-back to invalidate all + // other pods' sessions, creating an infinite eviction storm. + sm, storage := newTestSessionManager(t, makeFactory(t), newFakeRegistry()) + sessionID := "multi-pod-per-backend-ids" + + // Storage holds IDs written by another pod's RestoreSession. + _, err := storage.Create(context.Background(), sessionID, map[string]string{ + vmcpsession.MetadataKeyBackendIDs: "backend-a", + vmcpsession.MetadataKeyBackendSessionPrefix + "backend-a": "pod-a-session-id", + }) + require.NoError(t, err) + + // This pod cached different per-backend IDs from its own RestoreSession. + ctrl := gomock.NewController(t) + cached := sessionmocks.NewMockMultiSession(ctrl) + cached.EXPECT().GetMetadata().Return(map[string]string{ + vmcpsession.MetadataKeyBackendIDs: "backend-a", + vmcpsession.MetadataKeyBackendSessionPrefix + "backend-a": "pod-b-session-id", + }).AnyTimes() + sm.sessions.Set(sessionID, cached) + + err = sm.checkSession(sessionID, cached) + assert.NoError(t, err, + "differing per-backend session IDs must not evict to avoid cross-pod eviction storms") + }) } // --------------------------------------------------------------------------- From d5addcd7d330e94286da31617ad561bd9931dd97 Mon Sep 17 00:00:00 2001 From: reyortiz3 <reynier@stacklok.com> Date: Thu, 16 Apr 2026 07:42:16 -0400 Subject: [PATCH 054/117] Enforce go.mod patch version check in Taskfile and CI (#4864) * Add go.mod patch version convention to go-style rules Codify the convention that the go directive in go.mod files must not include a patch version (e.g. go 1.23, not go 1.23.4) to prevent unnecessary churn when toolchains update. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Move go.mod convention to dedicated go-modules rule file Split the go directive version rule out of go-style.md into its own .claude/rules/go-modules.md so it only triggers on go.mod files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Enforce go.mod patch version check in Taskfile and CI Add a check to lint task and lint.yml CI workflow that fails if any go.mod file specifies a patch version in the go directive. Remove the now-redundant Claude rule file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Extract go.mod version check into a reusable script Move the inline check from Taskfile and CI into scripts/check-go-mod-version.sh so the logic lives in one place. Remove the Claude rule since the check is now deterministically enforced by tooling. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Simplify go.mod version check to a one-liner in lint CI Drop the dedicated script and Taskfile task in favour of a single grep in lint.yml. There is only one go.mod in the repo so the recursive search and extra indirection were unnecessary. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix go.mod version check to correctly reject patch versions grep -qvE inverts the match and exits 0 as soon as any non-matching line is found, making the check a no-op. Use ! grep -qE instead so the step fails only when the patch-version pattern is actually present. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --- .github/workflows/lint.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 05165e34ab..1188e900b3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,6 +36,9 @@ jobs: ${{ runner.os }}-go-build-lint- ${{ runner.os }}-go-build- + - name: Check go.mod version format + run: "! grep -qE '^go [0-9]+\\.[0-9]+\\.[0-9]+' go.mod || { echo 'ERROR: go.mod must pin Go to minor version (e.g. go 1.26), not patch (e.g. go 1.26.1)'; exit 1; }" + - name: Run golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: From 96c4d49b181e71e9d18e424f9e804700833e5a3b Mon Sep 17 00:00:00 2001 From: Radoslav Dimitrov <radoslav@stacklok.com> Date: Thu, 16 Apr 2026 08:02:03 -0400 Subject: [PATCH 055/117] Drop registry-based group support (#4873) * Drop registry-based group support Remove the ability to deploy server groups from registry data (thv group run). Registry-based groups were a ToolHive-specific concept embedded in registry JSON files that is no longer supported. Runtime groups (thv group create/list/rm) and the groups API continue to work unchanged. The MCP optimizer's use of runtime groups is not affected. Changes: - groupRunCmdFunc now returns a clear error with migration guidance - handleGroupLookup in retriever returns "no longer supported" error - Delete ~400 lines of dead group deployment/validation code Ref: #4199 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Regenerate CLI docs for group command changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address review findings - Rewrite TestResolveMCPServer_WithGroup to expect "no longer supported" error instead of old group lookup behavior - Remove stale TODO referencing registry group lookups in workload_service.go - Change group run command from "deprecated" to "removed" - Regenerate CLI docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address review feedback - Inline group error at the call site and delete handleGroupLookup stub entirely, removing unreachable dead code after the call - Use Cobra's built-in Deprecated field to auto-hide the command from --help and print a standard deprecation warning - Regenerate CLI docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- cmd/thv/app/group.go | 406 ++----------------------- docs/cli/thv_group.md | 1 - docs/cli/thv_group_run.md | 42 --- pkg/api/v1/workload_service.go | 2 +- pkg/runner/retriever/retriever.go | 78 +---- pkg/runner/retriever/retriever_test.go | 114 +------ 6 files changed, 39 insertions(+), 604 deletions(-) delete mode 100644 docs/cli/thv_group_run.md diff --git a/cmd/thv/app/group.go b/cmd/thv/app/group.go index 3cbc386ade..51390776b9 100644 --- a/cmd/thv/app/group.go +++ b/cmd/thv/app/group.go @@ -14,16 +14,11 @@ import ( "github.com/spf13/cobra" - types "github.com/stacklok/toolhive-core/registry/types" groupval "github.com/stacklok/toolhive-core/validation/group" "github.com/stacklok/toolhive/pkg/client" - "github.com/stacklok/toolhive/pkg/container" "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/core" "github.com/stacklok/toolhive/pkg/groups" - "github.com/stacklok/toolhive/pkg/registry" - "github.com/stacklok/toolhive/pkg/runner/retriever" - "github.com/stacklok/toolhive/pkg/transport" "github.com/stacklok/toolhive/pkg/workloads" ) @@ -64,13 +59,13 @@ var groupRmCmd = &cobra.Command{ } var groupRunCmd = &cobra.Command{ - Use: "run [group-name]", - Short: "Deploy all MCP servers from a registry group", - Long: `Deploy all MCP servers defined in a registry group. - This creates a new runtime group and starts all MCP servers within it.`, - Args: cobra.ExactArgs(1), - PreRunE: validateGroupArg(), - RunE: groupRunCmdFunc, + Use: "run [group-name]", + Short: "Deploy all MCP servers from a registry group", + Deprecated: "registry-based groups are no longer supported; use 'thv group create' and 'thv run --group' instead", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("registry-based groups are no longer supported; use 'thv group create' and 'thv run --group <name>' instead") + }, } func validateGroupArg() func(cmd *cobra.Command, args []string) error { @@ -85,11 +80,17 @@ func validateGroupArg() func(cmd *cobra.Command, args []string) error { } } -var ( - withWorkloadsFlag bool - groupSecrets []string - groupEnvVars []string -) +var withWorkloadsFlag bool + +func init() { + groupCmd.AddCommand(groupCreateCmd) + groupCmd.AddCommand(groupListCmd) + groupCmd.AddCommand(groupRmCmd) + groupCmd.AddCommand(groupRunCmd) + + groupRmCmd.Flags().BoolVar(&withWorkloadsFlag, "with-workloads", false, + "Delete all workloads in the group along with the group (default false)") +} func groupCreateCmdFunc(cmd *cobra.Command, args []string) error { groupName := args[0] @@ -336,374 +337,3 @@ func updateClientConfigurations(ctx context.Context, groupWorkloads []core.Workl return nil } - -func groupRunCmdFunc(cmd *cobra.Command, args []string) error { - groupName := args[0] - ctx := cmd.Context() - - // Get registry provider - registryProvider, err := registry.GetDefaultProvider() - if err != nil { - return fmt.Errorf("failed to get registry provider: %w", err) - } - - // Get registry data - reg, err := registryProvider.GetRegistry() - if err != nil { - return fmt.Errorf("failed to get registry: %w", err) - } - - // Find the group in the registry - registryGroup, found := reg.GetGroupByName(groupName) - if !found { - return fmt.Errorf("group '%s' not found in registry", groupName) - } - - // Validate all preconditions before making any changes - if err := validateGroupRunPreconditions(ctx, groupName, registryGroup, groupSecrets, groupEnvVars); err != nil { - return err - } - - // Create managers - groupManager, err := groups.NewManager() - if err != nil { - return fmt.Errorf("failed to create group manager: %w", err) - } - - // Create the runtime group - if err := groupManager.Create(ctx, groupName); err != nil { - return fmt.Errorf("failed to create group '%s': %w", groupName, err) - } - - // Deploy servers - continue on failure but warn user - var failedServers []string - - // Deploy container servers - for serverName, serverMetadata := range registryGroup.Servers { - fmt.Printf("Deploying server '%s' from image '%s'\n", serverName, serverMetadata.Image) - - if err := deployServer(ctx, serverName, serverMetadata, groupName, groupSecrets, groupEnvVars, cmd); err != nil { - fmt.Printf("Warning: failed to deploy server '%s': %v\n", serverName, err) - failedServers = append(failedServers, serverName) - continue - } - } - - // Deploy remote servers - for serverName, serverMetadata := range registryGroup.RemoteServers { - fmt.Printf("Deploying remote server '%s' at URL '%s'\n", serverName, serverMetadata.URL) - - if err := deployRemoteServer(ctx, serverName, groupName, groupSecrets, groupEnvVars, cmd); err != nil { - fmt.Printf("Warning: failed to deploy remote server '%s': %v\n", serverName, err) - failedServers = append(failedServers, serverName) - continue - } - } - - // Report deployment failures - if len(failedServers) > 0 { - fmt.Printf("Warning: %d servers failed to deploy: %v\n", len(failedServers), failedServers) - } - - return nil -} - -// validateGroupRunPreconditions validates all conditions before making any changes -func validateGroupRunPreconditions(ctx context.Context, groupName string, - registryGroup *types.Group, secrets []string, envVars []string) error { - if err := validateRuntimeGroupDoesNotExist(ctx, groupName); err != nil { - return err - } - if err := validateServersDoNotExist(ctx, registryGroup); err != nil { - return err - } - if err := validateSecretsFormat(secrets, registryGroup); err != nil { - return err - } - return validateEnvVarsFormat(envVars, registryGroup) -} - -// validateRuntimeGroupDoesNotExist checks that the runtime group doesn't already exist -func validateRuntimeGroupDoesNotExist(ctx context.Context, groupName string) error { - groupManager, err := groups.NewManager() - if err != nil { - return fmt.Errorf("failed to create group manager: %w", err) - } - - exists, err := groupManager.Exists(ctx, groupName) - if err != nil { - return fmt.Errorf("failed to check if group exists: %w", err) - } - if exists { - return fmt.Errorf( - "runtime group '%s' already exists. "+ - "Hint: use 'thv group rm %s' to remove it first, or choose a different group name", - groupName, groupName) - } - return nil -} - -// validateServersDoNotExist checks that no servers in the group already exist as workloads -func validateServersDoNotExist(ctx context.Context, registryGroup *types.Group) error { - rt, err := container.NewFactory().Create(ctx) - if err != nil { - return fmt.Errorf("failed to create container runtime: %w", err) - } - workloadManager, err := workloads.NewManagerFromRuntime(rt) - if err != nil { - return fmt.Errorf("failed to create workload manager: %w", err) - } - - // Check container servers - for serverName := range registryGroup.Servers { - exists, err := workloadManager.DoesWorkloadExist(ctx, serverName) - if err != nil { - return fmt.Errorf("failed to check if server '%s' exists: %w", serverName, err) - } - if exists { - return fmt.Errorf( - "MCP server '%s' already exists. "+ - "Hint: use 'thv rm %s' to remove it first, or rename the existing server", - serverName, serverName) - } - } - - // Check remote servers - for serverName := range registryGroup.RemoteServers { - exists, err := workloadManager.DoesWorkloadExist(ctx, serverName) - if err != nil { - return fmt.Errorf("failed to check if server '%s' exists: %w", serverName, err) - } - if exists { - return fmt.Errorf( - "MCP server '%s' already exists. "+ - "Hint: use 'thv rm %s' to remove it first, or rename the existing server", - serverName, serverName) - } - } - - return nil -} - -// validateSecretsFormat validates all secrets have correct format and target existing servers -func validateSecretsFormat(secrets []string, registryGroup *types.Group) error { - for _, secret := range secrets { - if err := validateSecretFormat(secret, registryGroup); err != nil { - return err - } - } - return nil -} - -// validateEnvVarsFormat validates all environment variables have correct format and target existing servers -func validateEnvVarsFormat(envVars []string, registryGroup *types.Group) error { - for _, envVar := range envVars { - if err := validateEnvVarFormat(envVar, registryGroup); err != nil { - return err - } - } - return nil -} - -// deployServer deploys a single server using the same path as the normal run command -func deployServer(ctx context.Context, serverName string, - serverMetadata *types.ImageMetadata, groupName string, secrets []string, envVars []string, cmd *cobra.Command) error { - - // Filter secrets and env vars for this specific server - serverSecrets := filterSecretsForServer(secrets, serverName) - serverEnvVars := filterEnvVarsForServer(envVars, serverName) - - // Create RunFlags similar to normal run command - runFlags := &RunFlags{ - Name: serverName, - Group: groupName, - Transport: "", // Let normal defaulting logic handle it (matches cobra default) - Host: transport.LocalhostIPv4, - ProxyPort: 0, - TargetPort: serverMetadata.TargetPort, - Foreground: false, - VerifyImage: retriever.VerifyImageWarn, // Matches cobra default - Env: serverEnvVars, - Volumes: []string{}, - Secrets: serverSecrets, - } - - // Use the shared runSingleServer function - return runSingleServer(ctx, runFlags, serverName, []string{}, false, cmd, groupName) -} - -// deployRemoteServer deploys a single remote server using the same path as the normal run command -func deployRemoteServer(ctx context.Context, serverName string, - groupName string, secrets []string, envVars []string, cmd *cobra.Command) error { - - // Filter secrets and env vars for this specific server - serverSecrets := filterSecretsForServer(secrets, serverName) - serverEnvVars := filterEnvVarsForServer(envVars, serverName) - - // Create RunFlags for remote server - runFlags := &RunFlags{ - Name: serverName, - Group: groupName, - Transport: "", // Let normal defaulting logic handle it (matches cobra default) - Host: transport.LocalhostIPv4, - ProxyPort: 0, - TargetPort: 0, - Foreground: false, - VerifyImage: retriever.VerifyImageWarn, // Matches cobra default - Env: serverEnvVars, - Volumes: []string{}, - Secrets: serverSecrets, - // Don't pre-set RemoteURL - let ResolveMCPServer discover it through group lookup - } - - // Use the shared runSingleServer function, passing the serverName - return runSingleServer(ctx, runFlags, serverName, []string{}, false, cmd, groupName) -} - -// validateSecretFormat validates secret format and checks if target server exists in the group -func validateSecretFormat(secret string, registryGroup *types.Group) error { - // Expected format: NAME,target=SERVER_NAME.TARGET - parts := strings.Split(secret, ",target=") - if len(parts) != 2 { - return fmt.Errorf("invalid secret format '%s': expected 'NAME,target=SERVER_NAME.TARGET'", secret) - } - - secretName := parts[0] - if secretName == "" { - return fmt.Errorf("secret name cannot be empty in '%s'", secret) - } - - targetPart := parts[1] - if targetPart == "" { - return fmt.Errorf("target cannot be empty in secret '%s'", secret) - } - - // Extract server name from target (SERVER_NAME.TARGET) - targetParts := strings.Split(targetPart, ".") - if len(targetParts) < 2 { - return fmt.Errorf("invalid target format in secret '%s': expected 'SERVER_NAME.TARGET'", secret) - } - - serverName := targetParts[0] - if serverName == "" { - return fmt.Errorf("server name cannot be empty in secret target '%s'", secret) - } - - // Check if server exists in the registry group (check both container and remote servers) - _, existsInServers := registryGroup.Servers[serverName] - _, existsInRemoteServers := registryGroup.RemoteServers[serverName] - if !existsInServers && !existsInRemoteServers { - return fmt.Errorf("secret cannot be set because the MCP server named '%s' does not exist in group", serverName) - } - - return nil -} - -// validateEnvVarFormat validates environment variable format and checks if target server exists in the group -func validateEnvVarFormat(envVar string, registryGroup *types.Group) error { - // Expected format: SERVER_NAME.KEY=VALUE - parts := strings.Split(envVar, "=") - if len(parts) < 2 { - return fmt.Errorf("invalid env var format '%s': expected 'SERVER_NAME.KEY=VALUE'", envVar) - } - - keyPart := parts[0] - keyParts := strings.Split(keyPart, ".") - if len(keyParts) < 2 { - return fmt.Errorf("invalid env var format '%s': expected 'SERVER_NAME.KEY=VALUE'", envVar) - } - - serverName := keyParts[0] - keyName := strings.Join(keyParts[1:], ".") - - if serverName == "" { - return fmt.Errorf("server name cannot be empty in env var '%s'", envVar) - } - - if keyName == "" { - return fmt.Errorf("key name cannot be empty in env var '%s'", envVar) - } - - // Check if server exists in the registry group (check both container and remote servers) - _, existsInServers := registryGroup.Servers[serverName] - _, existsInRemoteServers := registryGroup.RemoteServers[serverName] - if !existsInServers && !existsInRemoteServers { - return fmt.Errorf("env var cannot be set because the MCP server named '%s' does not exist in group", serverName) - } - - return nil -} - -// filterSecretsForServer filters secrets that are targeted for a specific server -func filterSecretsForServer(secrets []string, serverName string) []string { - var serverSecrets []string - for _, secret := range secrets { - // Expected format: NAME,target=SERVER_NAME.TARGET - parts := strings.Split(secret, ",target=") - if len(parts) != 2 { - continue // Skip invalid formats (should have been caught in validation) - } - - targetPart := parts[1] - targetParts := strings.Split(targetPart, ".") - if len(targetParts) < 2 { - continue // Skip invalid formats (should have been caught in validation) - } - - targetServerName := targetParts[0] - if targetServerName == serverName { - // Convert from group format to normal run format - // From: GITHUB_TOKEN,target=github.GITHUB_PERSONAL_ACCESS_TOKEN - // To: GITHUB_TOKEN,target=GITHUB_PERSONAL_ACCESS_TOKEN - secretName := parts[0] - targetEnvVar := strings.Join(targetParts[1:], ".") // Join in case env var has dots - normalRunFormat := fmt.Sprintf("%s,target=%s", secretName, targetEnvVar) - serverSecrets = append(serverSecrets, normalRunFormat) - } - } - return serverSecrets -} - -// filterEnvVarsForServer filters environment variables that are targeted for a specific server -func filterEnvVarsForServer(envVars []string, serverName string) []string { - var serverEnvVars []string - for _, envVar := range envVars { - // Expected format: SERVER_NAME.KEY=VALUE - parts := strings.Split(envVar, "=") - if len(parts) < 2 { - continue // Skip invalid formats (should have been caught in validation) - } - - keyPart := parts[0] - keyParts := strings.Split(keyPart, ".") - if len(keyParts) < 2 { - continue // Skip invalid formats (should have been caught in validation) - } - - targetServerName := keyParts[0] - if targetServerName == serverName { - // Convert to standard env var format by removing server prefix - keyName := strings.Join(keyParts[1:], ".") - value := strings.Join(parts[1:], "=") - serverEnvVars = append(serverEnvVars, fmt.Sprintf("%s=%s", keyName, value)) - } - } - return serverEnvVars -} - -func init() { - groupCmd.AddCommand(groupCreateCmd) - groupCmd.AddCommand(groupListCmd) - groupCmd.AddCommand(groupRmCmd) - groupCmd.AddCommand(groupRunCmd) - - // Add --with-workloads flag to group rm command - groupRmCmd.Flags().BoolVar(&withWorkloadsFlag, "with-workloads", false, - "Delete all workloads in the group along with the group (default false)") - - // Add flags to group run command - groupRunCmd.Flags().StringArrayVar(&groupSecrets, "secret", []string{}, - "Secrets to be fetched from the secrets manager and set as environment variables (format: NAME,target=SERVER_NAME.TARGET)") - groupRunCmd.Flags().StringArrayVar(&groupEnvVars, "env", []string{}, - "Environment variables to pass to an MCP server in the group (format: SERVER_NAME.KEY=VALUE)") -} diff --git a/docs/cli/thv_group.md b/docs/cli/thv_group.md index 5c8f84cc2e..d0ec552cde 100644 --- a/docs/cli/thv_group.md +++ b/docs/cli/thv_group.md @@ -35,5 +35,4 @@ The group command provides subcommands to manage logical groupings of MCP server * [thv group create](thv_group_create.md) - Create a new group of MCP servers * [thv group list](thv_group_list.md) - List all groups * [thv group rm](thv_group_rm.md) - Remove a group and remove workloads from it -* [thv group run](thv_group_run.md) - Deploy all MCP servers from a registry group diff --git a/docs/cli/thv_group_run.md b/docs/cli/thv_group_run.md deleted file mode 100644 index 3d3c81e88b..0000000000 --- a/docs/cli/thv_group_run.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: thv group run -hide_title: true -description: Reference for ToolHive CLI command `thv group run` -last_update: - author: autogenerated -slug: thv_group_run -mdx: - format: md ---- - -## thv group run - -Deploy all MCP servers from a registry group - -### Synopsis - -Deploy all MCP servers defined in a registry group. - This creates a new runtime group and starts all MCP servers within it. - -``` -thv group run [group-name] [flags] -``` - -### Options - -``` - --env stringArray Environment variables to pass to an MCP server in the group (format: SERVER_NAME.KEY=VALUE) - -h, --help help for run - --secret stringArray Secrets to be fetched from the secrets manager and set as environment variables (format: NAME,target=SERVER_NAME.TARGET) -``` - -### Options inherited from parent commands - -``` - --debug Enable debug mode -``` - -### SEE ALSO - -* [thv group](thv_group.md) - Manage logical groupings of MCP servers - diff --git a/pkg/api/v1/workload_service.go b/pkg/api/v1/workload_service.go index 142ecdd33a..70c7143b9a 100644 --- a/pkg/api/v1/workload_service.go +++ b/pkg/api/v1/workload_service.go @@ -214,7 +214,7 @@ func (s *WorkloadService) BuildFullRunConfig( req.Image, "", // We do not let the user specify a CA cert path here. retriever.VerifyImageWarn, - "", // TODO Add support for registry groups lookups for API + "", // Registry-based group lookups are not supported retrievalRuntimeConfig, ) if err != nil { diff --git a/pkg/runner/retriever/retriever.go b/pkg/runner/retriever/retriever.go index acfd7ae788..d73437b8c4 100644 --- a/pkg/runner/retriever/retriever.go +++ b/pkg/runner/retriever/retriever.go @@ -94,19 +94,15 @@ func ResolveMCPServer( slog.Debug("No protocol scheme detected, attempting to retrieve image or registry server", "server_or_image", serverOrImage) - // If group name is provided, look up server in the group first + // Registry-based group lookups are no longer supported. if groupName != "" { - var err error - var server types.ServerMetadata - imageToUse, imageMetadata, server, err = handleGroupLookup(ctx, serverOrImage, groupName) - if err != nil { - return "", nil, err - } - // Handle remote servers early return - if server != nil && server.IsRemote() { - return serverOrImage, server, nil - } - } else { + return "", nil, fmt.Errorf( + "registry-based group %q is no longer supported; use 'thv group' commands to manage workload groups", + groupName, + ) + } + + { var err error var server types.ServerMetadata imageToUse, imageMetadata, server, err = handleRegistryLookup(ctx, serverOrImage) @@ -218,64 +214,6 @@ func handleProtocolScheme( return generatedImage, nil } -// handleGroupLookup handles the group lookup case -func handleGroupLookup( - _ context.Context, - serverOrImage string, - groupName string, -) (string, *types.ImageMetadata, types.ServerMetadata, error) { - var imageMetadata *types.ImageMetadata - var imageToUse string - - provider, err := registry.GetDefaultProvider() - if err != nil { - return "", nil, nil, fmt.Errorf("failed to get registry provider: %w", err) - } - - reg, err := provider.GetRegistry() - if err != nil { - return "", nil, nil, fmt.Errorf("failed to get registry: %w", err) - } - - group, exists := reg.GetGroupByName(groupName) - if !exists { - return "", nil, nil, fmt.Errorf("group '%s' not found in registry", groupName) - } - - // First check if the server exists and whether it's remote - var server types.ServerMetadata - var serverFound bool - if containerServer, exists := group.Servers[serverOrImage]; exists { - server = containerServer - serverFound = true - } else if remoteServer, exists := group.RemoteServers[serverOrImage]; exists { - server = remoteServer - serverFound = true - } - - if serverFound { - // Server found, check if it's remote - if server.IsRemote() { - return serverOrImage, nil, server, nil - } - // It's a container server, get the ImageMetadata - if imgMetadata, ok := server.(*types.ImageMetadata); ok { - imageMetadata = imgMetadata - slog.Debug("Found imageMetadata in group", "server", serverOrImage, "metadata", imageMetadata) - imageToUse = imageMetadata.Image - } else { - // This shouldn't happen since we just found it, but handle it anyway - slog.Debug("ImageMetadata not found in group: could not cast", "server", serverOrImage) - imageToUse = serverOrImage - } - } else { - // Server not found in group - fail explicitly - return "", nil, nil, fmt.Errorf("server '%s' not found in group '%s'", serverOrImage, groupName) - } - - return imageToUse, imageMetadata, nil, nil -} - // handleRegistryLookup handles the standard registry lookup case func handleRegistryLookup( _ context.Context, diff --git a/pkg/runner/retriever/retriever_test.go b/pkg/runner/retriever/retriever_test.go index 6229fcc81f..92d8eb64f9 100644 --- a/pkg/runner/retriever/retriever_test.go +++ b/pkg/runner/retriever/retriever_test.go @@ -12,114 +12,24 @@ import ( "github.com/stretchr/testify/require" regtypes "github.com/stacklok/toolhive-core/registry/types" - "github.com/stacklok/toolhive/pkg/registry" "github.com/stacklok/toolhive/pkg/runner" ) func TestResolveMCPServer_WithGroup(t *testing.T) { t.Parallel() - ctx := context.Background() - - // Test group functionality by using actual registry provider - provider, err := registry.GetDefaultProvider() - require.NoError(t, err) - - reg, err := provider.GetRegistry() - require.NoError(t, err) - - // Find a group that exists in the registry - var testGroupName string - var group *regtypes.Group - for _, g := range reg.Groups { - if g != nil && g.Name != "" { - testGroupName = g.Name - group = g - break - } - } - - if testGroupName == "" || group == nil { - t.Skip("No groups found in registry, skipping group tests") - } - - // Find a server in the group to test with - var testServerName string - if len(group.Servers) > 0 { - for serverName := range group.Servers { - testServerName = serverName - break - } - } else if len(group.RemoteServers) > 0 { - for serverName := range group.RemoteServers { - testServerName = serverName - break - } - } - - if testServerName == "" { - t.Skip("No servers found in test group, skipping") - } - - tests := []struct { - name string - serverName string - groupName string - expectError bool - errorContains string - }{ - { - name: "valid server in group", - serverName: testServerName, - groupName: testGroupName, - expectError: false, - }, - { - name: "non-existent server in group", - serverName: "non-existent-server", - groupName: testGroupName, - expectError: true, - errorContains: "not found in group", - }, - { - name: "non-existent group", - serverName: testServerName, - groupName: "non-existent-group", - expectError: true, - errorContains: "not found in registry", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - imageURL, serverMetadata, err := ResolveMCPServer( - ctx, - tt.serverName, - "", - VerifyImageDisabled, - tt.groupName, - nil, - ) - - if tt.expectError { - assert.Error(t, err) - if tt.errorContains != "" { - assert.Contains(t, err.Error(), tt.errorContains) - } - assert.Empty(t, imageURL) - assert.Nil(t, serverMetadata) - } else { - assert.NoError(t, err) - assert.NotEmpty(t, imageURL) - assert.NotNil(t, serverMetadata) - - // Verify server metadata name is set - assert.Equal(t, tt.serverName, serverMetadata.GetName()) - } - }) - } + // Registry-based group lookups are no longer supported. + // Any non-empty groupName should return an error. + _, _, err := ResolveMCPServer( + context.Background(), + "any-server", + "", + VerifyImageDisabled, + "some-group", + nil, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "no longer supported") } func TestResolveMCPServer_WithoutGroup(t *testing.T) { From d9447746b7a914551a43cdcb1b4f4d8f60003454 Mon Sep 17 00:00:00 2001 From: Eleftheria Stein-Kousathana <eleftheria@stacklok.com> Date: Thu, 16 Apr 2026 14:24:46 +0200 Subject: [PATCH 056/117] Use scoped secrets provider for registry auth (#4893) After secret scope migration runs, registry tokens are moved from bare keys (REGISTRY_OAUTH_*) to the scoped namespace (__thv_registry_*). The factory and API login/logout handlers were still using the bare provider, so token lookups failed after migration and returned 401. Switch factory.go and the API registry auth handler to use CreateProvider with WithScope(ScopeRegistry). ScopedProvider.GetSecret falls back to bare keys during the migration window, and all new reads/writes go through the scoped namespace consistently. Also fix EncryptedManager.DeleteSecret to return ErrSecretNotFound (matching GetSecret) so callers relying on IsNotFoundError handle delete-not-found correctly. Fix #4892 --- pkg/api/v1/registry.go | 2 +- pkg/registry/factory.go | 2 +- pkg/secrets/encrypted.go | 2 +- pkg/secrets/encrypted_test.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/api/v1/registry.go b/pkg/api/v1/registry.go index 72b74959c6..3727ef3f8f 100644 --- a/pkg/api/v1/registry.go +++ b/pkg/api/v1/registry.go @@ -97,7 +97,7 @@ func newSecretsProvider(configProvider config.Provider) (secrets.Provider, error if err != nil { return nil, fmt.Errorf("getting secrets provider type: %w", err) } - return secrets.CreateSecretProvider(providerType) + return secrets.CreateProvider(providerType, secrets.WithScope(secrets.ScopeRegistry)) } // registryAuthLogin handles POST /registry/auth/login. diff --git a/pkg/registry/factory.go b/pkg/registry/factory.go index 1ab51ebdd3..4b96172ff3 100644 --- a/pkg/registry/factory.go +++ b/pkg/registry/factory.go @@ -143,7 +143,7 @@ func resolveTokenSource(cfg *config.Config, interactive bool) auth.TokenSource { slog.Debug("Secrets provider not available for registry auth token persistence", "error", err) } else { - secretsProvider, err = secrets.CreateSecretProvider(providerType) + secretsProvider, err = secrets.CreateProvider(providerType, secrets.WithScope(secrets.ScopeRegistry)) if err != nil { slog.Warn("Failed to create secrets provider for registry auth, tokens will not be persisted", "error", err) diff --git a/pkg/secrets/encrypted.go b/pkg/secrets/encrypted.go index 71de682e37..d9da583989 100644 --- a/pkg/secrets/encrypted.go +++ b/pkg/secrets/encrypted.go @@ -82,7 +82,7 @@ func (e *EncryptedManager) DeleteSecret(_ context.Context, name string) error { return err } if _, ok := secrets[name]; !ok { - return fmt.Errorf("cannot delete non-existent secret: %s", name) + return fmt.Errorf("%w: %s", ErrSecretNotFound, name) } delete(secrets, name) return e.writeFileSecrets(secrets) diff --git a/pkg/secrets/encrypted_test.go b/pkg/secrets/encrypted_test.go index 39ae7234b9..86a3455edc 100644 --- a/pkg/secrets/encrypted_test.go +++ b/pkg/secrets/encrypted_test.go @@ -117,7 +117,7 @@ func TestEncryptedManager_DeleteSecret(t *testing.T) { // Test deleting a non-existent secret err := manager.DeleteSecret(ctx, "non-existent") assert.Error(t, err, "Deleting a non-existent secret should return an error") - assert.Contains(t, err.Error(), "cannot delete non-existent", "Error message should indicate the secret does not exist") + assert.ErrorIs(t, err, ErrSecretNotFound, "Error should be ErrSecretNotFound for a non-existent secret") // Test deleting a secret with an empty name err = manager.DeleteSecret(ctx, "") From c2a8154c656d74e62010d9e0d39fd9f3374f2bab Mon Sep 17 00:00:00 2001 From: Radoslav Dimitrov <radoslav@stacklok.com> Date: Thu, 16 Apr 2026 08:31:51 -0400 Subject: [PATCH 057/117] Add v0.1 server browse endpoints (#4871) * Add v0.1 server endpoints and combine registry router Add upstream-compatible server browse endpoints: - GET /registry/{name}/v0.1/servers (paginated, searchable) - GET /registry/{name}/v0.1/servers/{serverName}/versions/latest Servers are converted from ToolHive types to ServerJSON using toolhive-core converters. Same pattern as the skills endpoint. Combine skills and servers into a single RegistryV01Router with shared helpers (provider accessor, pagination, JSON errors). Refactor skills handlers to use shared helpers. Ref: #4199 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Regenerate OpenAPI spec for v0.1 server endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix pagination overflow: parse limit before computing page cap The page overflow guard was computed against the default limit (50) before the actual limit from the query string was parsed. This meant (page-1)*limit could overflow when both a large page and custom limit were specified. Fix by parsing both page and limit first, then computing the cap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address review feedback - Add ErrServerNotFound sentinel and use errors.Is() instead of fragile strings.Contains(err.Error(), "not found") pattern - Wrap server-not-found errors in BaseProvider and APIRegistryProvider with the sentinel via fmt.Errorf("%w: ...", ErrServerNotFound, ...) - Add @Failure 400 Swagger annotation for invalid server name encoding - Regenerate OpenAPI spec Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- docs/server/docs.go | 584 +++++++++++++++++++++++- docs/server/swagger.json | 584 +++++++++++++++++++++++- docs/server/swagger.yaml | 418 ++++++++++++++++- pkg/api/server.go | 2 +- pkg/api/v1/registry_v01.go | 131 ++++++ pkg/api/v1/registry_v01_servers.go | 195 ++++++++ pkg/api/v1/registry_v01_servers_test.go | 155 +++++++ pkg/api/v1/registry_v01_skills.go | 113 +---- pkg/api/v1/registry_v01_skills_test.go | 36 +- pkg/registry/errors.go | 8 +- pkg/registry/provider_api.go | 2 +- pkg/registry/provider_base.go | 2 +- 12 files changed, 2049 insertions(+), 181 deletions(-) create mode 100644 pkg/api/v1/registry_v01.go create mode 100644 pkg/api/v1/registry_v01_servers.go create mode 100644 pkg/api/v1/registry_v01_servers_test.go diff --git a/docs/server/docs.go b/docs/server/docs.go index 5ea52bbaec..46cf506bc7 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1833,6 +1833,303 @@ const docTemplate = `{ }, "type": "object" }, + "model.Argument": { + "properties": { + "choices": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "$ref": "#/components/schemas/model.Format" + }, + "isRepeated": { + "type": "boolean" + }, + "isRequired": { + "type": "boolean" + }, + "isSecret": { + "type": "boolean" + }, + "name": { + "example": "--port", + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/model.ArgumentType" + }, + "value": { + "type": "string" + }, + "valueHint": { + "example": "file_path", + "type": "string" + }, + "variables": { + "additionalProperties": { + "$ref": "#/components/schemas/model.Input" + }, + "type": "object" + } + }, + "type": "object" + }, + "model.ArgumentType": { + "enum": [ + "positional", + "named" + ], + "example": "positional", + "type": "string", + "x-enum-varnames": [ + "ArgumentTypePositional", + "ArgumentTypeNamed" + ] + }, + "model.Format": { + "enum": [ + "string", + "number", + "boolean", + "filepath" + ], + "type": "string", + "x-enum-varnames": [ + "FormatString", + "FormatNumber", + "FormatBoolean", + "FormatFilePath" + ] + }, + "model.Icon": { + "properties": { + "mimeType": { + "example": "image/png", + "type": "string" + }, + "sizes": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "src": { + "example": "https://example.com/icon.png", + "format": "uri", + "maxLength": 255, + "type": "string" + }, + "theme": { + "type": "string" + } + }, + "type": "object" + }, + "model.Input": { + "properties": { + "choices": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "$ref": "#/components/schemas/model.Format" + }, + "isRequired": { + "type": "boolean" + }, + "isSecret": { + "type": "boolean" + }, + "placeholder": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "type": "object" + }, + "model.KeyValueInput": { + "properties": { + "choices": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "$ref": "#/components/schemas/model.Format" + }, + "isRequired": { + "type": "boolean" + }, + "isSecret": { + "type": "boolean" + }, + "name": { + "example": "SOME_VARIABLE", + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "value": { + "type": "string" + }, + "variables": { + "additionalProperties": { + "$ref": "#/components/schemas/model.Input" + }, + "type": "object" + } + }, + "type": "object" + }, + "model.Package": { + "properties": { + "environmentVariables": { + "description": "EnvironmentVariables are set when running the package", + "items": { + "$ref": "#/components/schemas/model.KeyValueInput" + }, + "type": "array", + "uniqueItems": false + }, + "fileSha256": { + "description": "FileSHA256 is the SHA-256 hash for integrity verification (required for mcpb, optional for others)", + "example": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "identifier": { + "description": "Identifier is the package identifier:\n - For NPM/PyPI/NuGet: package name or ID\n - For OCI: full image reference (e.g., \"ghcr.io/owner/repo:v1.0.0\")\n - For MCPB: direct download URL", + "example": "@modelcontextprotocol/server-brave-search", + "minLength": 1, + "type": "string" + }, + "packageArguments": { + "description": "PackageArguments are passed to the package's binary", + "items": { + "$ref": "#/components/schemas/model.Argument" + }, + "type": "array", + "uniqueItems": false + }, + "registryBaseUrl": { + "description": "RegistryBaseURL is the base URL of the package registry (used by npm, pypi, nuget; not used by oci, mcpb)", + "example": "https://registry.npmjs.org", + "format": "uri", + "type": "string" + }, + "registryType": { + "description": "RegistryType indicates how to download packages (e.g., \"npm\", \"pypi\", \"oci\", \"nuget\", \"mcpb\")", + "example": "npm", + "minLength": 1, + "type": "string" + }, + "runtimeArguments": { + "description": "RuntimeArguments are passed to the package's runtime command (e.g., docker, npx)", + "items": { + "$ref": "#/components/schemas/model.Argument" + }, + "type": "array", + "uniqueItems": false + }, + "runtimeHint": { + "description": "RunTimeHint suggests the appropriate runtime for the package", + "example": "npx", + "type": "string" + }, + "transport": { + "$ref": "#/components/schemas/model.Transport" + }, + "version": { + "description": "Version is the package version (required for npm, pypi, nuget; optional for mcpb; not used by oci where version is in the identifier)", + "example": "1.0.2", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "model.Repository": { + "properties": { + "id": { + "example": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9", + "type": "string" + }, + "source": { + "example": "github", + "type": "string" + }, + "subfolder": { + "example": "src/everything", + "type": "string" + }, + "url": { + "example": "https://github.com/modelcontextprotocol/servers", + "format": "uri", + "type": "string" + } + }, + "type": "object" + }, + "model.Transport": { + "description": "Transport is required and specifies the transport protocol configuration", + "properties": { + "headers": { + "items": { + "$ref": "#/components/schemas/model.KeyValueInput" + }, + "type": "array", + "uniqueItems": false + }, + "type": { + "example": "stdio", + "type": "string" + }, + "url": { + "example": "https://api.example.com/mcp", + "type": "string" + }, + "variables": { + "additionalProperties": { + "$ref": "#/components/schemas/model.Input" + }, + "type": "object" + } + }, + "type": "object" + }, "permissions.InboundNetworkPermissions": { "description": "Inbound defines inbound network permissions", "properties": { @@ -2520,6 +2817,24 @@ const docTemplate = `{ }, "type": "object" }, + "pkg_api_v1.paginationV01Metadata": { + "description": "Metadata contains pagination information", + "properties": { + "limit": { + "description": "Limit is the maximum number of items per page", + "type": "integer" + }, + "page": { + "description": "Page is the current page number (1-based)", + "type": "integer" + }, + "total": { + "description": "Total is the total number of items matching the query", + "type": "integer" + } + }, + "type": "object" + }, "pkg_api_v1.providerCapabilitiesResponse": { "description": "Capabilities of the secrets provider", "properties": { @@ -2697,6 +3012,23 @@ const docTemplate = `{ }, "type": "object" }, + "pkg_api_v1.serversV01Response": { + "description": "Paginated list of servers from the registry", + "properties": { + "metadata": { + "$ref": "#/components/schemas/pkg_api_v1.paginationV01Metadata" + }, + "servers": { + "description": "Servers is the list of servers on the current page", + "items": { + "$ref": "#/components/schemas/v0.ServerJSON" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "pkg_api_v1.setupSecretsRequest": { "description": "Request to setup a secrets provider", "properties": { @@ -2739,29 +3071,11 @@ const docTemplate = `{ }, "type": "object" }, - "pkg_api_v1.skillsV01Metadata": { - "description": "Metadata contains pagination information", - "properties": { - "limit": { - "description": "Limit is the maximum number of skills per page", - "type": "integer" - }, - "page": { - "description": "Page is the current page number (1-based)", - "type": "integer" - }, - "total": { - "description": "Total is the total number of skills matching the query", - "type": "integer" - } - }, - "type": "object" - }, "pkg_api_v1.skillsV01Response": { "description": "Paginated list of skills from the registry", "properties": { "metadata": { - "$ref": "#/components/schemas/pkg_api_v1.skillsV01Metadata" + "$ref": "#/components/schemas/pkg_api_v1.paginationV01Metadata" }, "skills": { "description": "Skills is the list of skills on the current page", @@ -3527,6 +3841,81 @@ const docTemplate = `{ }, "type": "object" }, + "v0.ServerJSON": { + "properties": { + "$schema": { + "example": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "format": "uri", + "minLength": 1, + "type": "string" + }, + "_meta": { + "$ref": "#/components/schemas/v0.ServerMeta" + }, + "description": { + "example": "MCP server providing weather data and forecasts via OpenWeatherMap API", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "icons": { + "items": { + "$ref": "#/components/schemas/model.Icon" + }, + "type": "array", + "uniqueItems": false + }, + "name": { + "example": "io.github.user/weather", + "maxLength": 200, + "minLength": 3, + "pattern": "^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$", + "type": "string" + }, + "packages": { + "items": { + "$ref": "#/components/schemas/model.Package" + }, + "type": "array", + "uniqueItems": false + }, + "remotes": { + "items": { + "$ref": "#/components/schemas/model.Transport" + }, + "type": "array", + "uniqueItems": false + }, + "repository": { + "$ref": "#/components/schemas/model.Repository" + }, + "title": { + "example": "Weather API", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "version": { + "example": "1.0.2", + "type": "string" + }, + "websiteUrl": { + "example": "https://modelcontextprotocol.io/examples", + "format": "uri", + "type": "string" + } + }, + "type": "object" + }, + "v0.ServerMeta": { + "properties": { + "io.modelcontextprotocol.registry/publisher-provided": { + "additionalProperties": {}, + "type": "object" + } + }, + "type": "object" + }, "v1.Duration": { "description": "RefillPeriod is the duration to fully refill the bucket from zero to maxTokens.\nThe effective refill rate is maxTokens / refillPeriod tokens per second.\nFormat: Go duration string (e.g., \"1m0s\", \"30s\", \"1h0m0s\").\n+kubebuilder:validation:Required", "type": "object" @@ -6276,6 +6665,163 @@ const docTemplate = `{ ] } }, + "/registry/{registryName}/v0.1/servers": { + "get": { + "description": "Get a paginated list of servers from the registry. Supports optional full-text search and pagination.", + "parameters": [ + { + "description": "Registry name (currently ignored, uses the default provider)", + "in": "path", + "name": "registryName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Search filter — matches against server name and description", + "in": "query", + "name": "q", + "schema": { + "type": "string" + } + }, + { + "description": "Page number, 1-based (default: 1)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "Items per page, max 200 (default: 50)", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.serversV01Response" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Registry authentication required or upstream registry unavailable" + } + }, + "summary": "List available registry servers", + "tags": [ + "registry-servers" + ] + } + }, + "/registry/{registryName}/v0.1/servers/{serverName}/versions/latest": { + "get": { + "description": "Retrieve a single server by name. Names use reverse-DNS format; URL-encode slashes.", + "parameters": [ + { + "description": "Registry name (currently ignored, uses the default provider)", + "in": "path", + "name": "registryName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Server name (URL-encoded reverse-DNS format)", + "in": "path", + "name": "serverName", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/v0.ServerJSON" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Invalid server name encoding" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Server not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Registry authentication required or upstream registry unavailable" + } + }, + "summary": "Get a registry server", + "tags": [ + "registry-servers" + ] + } + }, "/registry/{registryName}/v0.1/x/dev.toolhive/skills": { "get": { "description": "Get a paginated list of skills from the registry. Supports optional full-text search and pagination.", diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 8bb3ca0cbb..11fdd2c973 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1826,6 +1826,303 @@ }, "type": "object" }, + "model.Argument": { + "properties": { + "choices": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "$ref": "#/components/schemas/model.Format" + }, + "isRepeated": { + "type": "boolean" + }, + "isRequired": { + "type": "boolean" + }, + "isSecret": { + "type": "boolean" + }, + "name": { + "example": "--port", + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/model.ArgumentType" + }, + "value": { + "type": "string" + }, + "valueHint": { + "example": "file_path", + "type": "string" + }, + "variables": { + "additionalProperties": { + "$ref": "#/components/schemas/model.Input" + }, + "type": "object" + } + }, + "type": "object" + }, + "model.ArgumentType": { + "enum": [ + "positional", + "named" + ], + "example": "positional", + "type": "string", + "x-enum-varnames": [ + "ArgumentTypePositional", + "ArgumentTypeNamed" + ] + }, + "model.Format": { + "enum": [ + "string", + "number", + "boolean", + "filepath" + ], + "type": "string", + "x-enum-varnames": [ + "FormatString", + "FormatNumber", + "FormatBoolean", + "FormatFilePath" + ] + }, + "model.Icon": { + "properties": { + "mimeType": { + "example": "image/png", + "type": "string" + }, + "sizes": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "src": { + "example": "https://example.com/icon.png", + "format": "uri", + "maxLength": 255, + "type": "string" + }, + "theme": { + "type": "string" + } + }, + "type": "object" + }, + "model.Input": { + "properties": { + "choices": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "$ref": "#/components/schemas/model.Format" + }, + "isRequired": { + "type": "boolean" + }, + "isSecret": { + "type": "boolean" + }, + "placeholder": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "type": "object" + }, + "model.KeyValueInput": { + "properties": { + "choices": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "$ref": "#/components/schemas/model.Format" + }, + "isRequired": { + "type": "boolean" + }, + "isSecret": { + "type": "boolean" + }, + "name": { + "example": "SOME_VARIABLE", + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "value": { + "type": "string" + }, + "variables": { + "additionalProperties": { + "$ref": "#/components/schemas/model.Input" + }, + "type": "object" + } + }, + "type": "object" + }, + "model.Package": { + "properties": { + "environmentVariables": { + "description": "EnvironmentVariables are set when running the package", + "items": { + "$ref": "#/components/schemas/model.KeyValueInput" + }, + "type": "array", + "uniqueItems": false + }, + "fileSha256": { + "description": "FileSHA256 is the SHA-256 hash for integrity verification (required for mcpb, optional for others)", + "example": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "identifier": { + "description": "Identifier is the package identifier:\n - For NPM/PyPI/NuGet: package name or ID\n - For OCI: full image reference (e.g., \"ghcr.io/owner/repo:v1.0.0\")\n - For MCPB: direct download URL", + "example": "@modelcontextprotocol/server-brave-search", + "minLength": 1, + "type": "string" + }, + "packageArguments": { + "description": "PackageArguments are passed to the package's binary", + "items": { + "$ref": "#/components/schemas/model.Argument" + }, + "type": "array", + "uniqueItems": false + }, + "registryBaseUrl": { + "description": "RegistryBaseURL is the base URL of the package registry (used by npm, pypi, nuget; not used by oci, mcpb)", + "example": "https://registry.npmjs.org", + "format": "uri", + "type": "string" + }, + "registryType": { + "description": "RegistryType indicates how to download packages (e.g., \"npm\", \"pypi\", \"oci\", \"nuget\", \"mcpb\")", + "example": "npm", + "minLength": 1, + "type": "string" + }, + "runtimeArguments": { + "description": "RuntimeArguments are passed to the package's runtime command (e.g., docker, npx)", + "items": { + "$ref": "#/components/schemas/model.Argument" + }, + "type": "array", + "uniqueItems": false + }, + "runtimeHint": { + "description": "RunTimeHint suggests the appropriate runtime for the package", + "example": "npx", + "type": "string" + }, + "transport": { + "$ref": "#/components/schemas/model.Transport" + }, + "version": { + "description": "Version is the package version (required for npm, pypi, nuget; optional for mcpb; not used by oci where version is in the identifier)", + "example": "1.0.2", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "model.Repository": { + "properties": { + "id": { + "example": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9", + "type": "string" + }, + "source": { + "example": "github", + "type": "string" + }, + "subfolder": { + "example": "src/everything", + "type": "string" + }, + "url": { + "example": "https://github.com/modelcontextprotocol/servers", + "format": "uri", + "type": "string" + } + }, + "type": "object" + }, + "model.Transport": { + "description": "Transport is required and specifies the transport protocol configuration", + "properties": { + "headers": { + "items": { + "$ref": "#/components/schemas/model.KeyValueInput" + }, + "type": "array", + "uniqueItems": false + }, + "type": { + "example": "stdio", + "type": "string" + }, + "url": { + "example": "https://api.example.com/mcp", + "type": "string" + }, + "variables": { + "additionalProperties": { + "$ref": "#/components/schemas/model.Input" + }, + "type": "object" + } + }, + "type": "object" + }, "permissions.InboundNetworkPermissions": { "description": "Inbound defines inbound network permissions", "properties": { @@ -2513,6 +2810,24 @@ }, "type": "object" }, + "pkg_api_v1.paginationV01Metadata": { + "description": "Metadata contains pagination information", + "properties": { + "limit": { + "description": "Limit is the maximum number of items per page", + "type": "integer" + }, + "page": { + "description": "Page is the current page number (1-based)", + "type": "integer" + }, + "total": { + "description": "Total is the total number of items matching the query", + "type": "integer" + } + }, + "type": "object" + }, "pkg_api_v1.providerCapabilitiesResponse": { "description": "Capabilities of the secrets provider", "properties": { @@ -2690,6 +3005,23 @@ }, "type": "object" }, + "pkg_api_v1.serversV01Response": { + "description": "Paginated list of servers from the registry", + "properties": { + "metadata": { + "$ref": "#/components/schemas/pkg_api_v1.paginationV01Metadata" + }, + "servers": { + "description": "Servers is the list of servers on the current page", + "items": { + "$ref": "#/components/schemas/v0.ServerJSON" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "pkg_api_v1.setupSecretsRequest": { "description": "Request to setup a secrets provider", "properties": { @@ -2732,29 +3064,11 @@ }, "type": "object" }, - "pkg_api_v1.skillsV01Metadata": { - "description": "Metadata contains pagination information", - "properties": { - "limit": { - "description": "Limit is the maximum number of skills per page", - "type": "integer" - }, - "page": { - "description": "Page is the current page number (1-based)", - "type": "integer" - }, - "total": { - "description": "Total is the total number of skills matching the query", - "type": "integer" - } - }, - "type": "object" - }, "pkg_api_v1.skillsV01Response": { "description": "Paginated list of skills from the registry", "properties": { "metadata": { - "$ref": "#/components/schemas/pkg_api_v1.skillsV01Metadata" + "$ref": "#/components/schemas/pkg_api_v1.paginationV01Metadata" }, "skills": { "description": "Skills is the list of skills on the current page", @@ -3520,6 +3834,81 @@ }, "type": "object" }, + "v0.ServerJSON": { + "properties": { + "$schema": { + "example": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "format": "uri", + "minLength": 1, + "type": "string" + }, + "_meta": { + "$ref": "#/components/schemas/v0.ServerMeta" + }, + "description": { + "example": "MCP server providing weather data and forecasts via OpenWeatherMap API", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "icons": { + "items": { + "$ref": "#/components/schemas/model.Icon" + }, + "type": "array", + "uniqueItems": false + }, + "name": { + "example": "io.github.user/weather", + "maxLength": 200, + "minLength": 3, + "pattern": "^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$", + "type": "string" + }, + "packages": { + "items": { + "$ref": "#/components/schemas/model.Package" + }, + "type": "array", + "uniqueItems": false + }, + "remotes": { + "items": { + "$ref": "#/components/schemas/model.Transport" + }, + "type": "array", + "uniqueItems": false + }, + "repository": { + "$ref": "#/components/schemas/model.Repository" + }, + "title": { + "example": "Weather API", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "version": { + "example": "1.0.2", + "type": "string" + }, + "websiteUrl": { + "example": "https://modelcontextprotocol.io/examples", + "format": "uri", + "type": "string" + } + }, + "type": "object" + }, + "v0.ServerMeta": { + "properties": { + "io.modelcontextprotocol.registry/publisher-provided": { + "additionalProperties": {}, + "type": "object" + } + }, + "type": "object" + }, "v1.Duration": { "description": "RefillPeriod is the duration to fully refill the bucket from zero to maxTokens.\nThe effective refill rate is maxTokens / refillPeriod tokens per second.\nFormat: Go duration string (e.g., \"1m0s\", \"30s\", \"1h0m0s\").\n+kubebuilder:validation:Required", "type": "object" @@ -6269,6 +6658,163 @@ ] } }, + "/registry/{registryName}/v0.1/servers": { + "get": { + "description": "Get a paginated list of servers from the registry. Supports optional full-text search and pagination.", + "parameters": [ + { + "description": "Registry name (currently ignored, uses the default provider)", + "in": "path", + "name": "registryName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Search filter — matches against server name and description", + "in": "query", + "name": "q", + "schema": { + "type": "string" + } + }, + { + "description": "Page number, 1-based (default: 1)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "Items per page, max 200 (default: 50)", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.serversV01Response" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Registry authentication required or upstream registry unavailable" + } + }, + "summary": "List available registry servers", + "tags": [ + "registry-servers" + ] + } + }, + "/registry/{registryName}/v0.1/servers/{serverName}/versions/latest": { + "get": { + "description": "Retrieve a single server by name. Names use reverse-DNS format; URL-encode slashes.", + "parameters": [ + { + "description": "Registry name (currently ignored, uses the default provider)", + "in": "path", + "name": "registryName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Server name (URL-encoded reverse-DNS format)", + "in": "path", + "name": "serverName", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/v0.ServerJSON" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Invalid server name encoding" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Server not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/pkg_api_v1.registryErrorResponse" + } + } + }, + "description": "Registry authentication required or upstream registry unavailable" + } + }, + "summary": "Get a registry server", + "tags": [ + "registry-servers" + ] + } + }, "/registry/{registryName}/v0.1/x/dev.toolhive/skills": { "get": { "description": "Get a paginated list of skills from the registry. Supports optional full-text search and pagination.", diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 8822cc6d2f..e73d67d304 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1713,6 +1713,228 @@ components: WARNING: This should only be used for development/testing. type: boolean type: object + model.Argument: + properties: + choices: + items: + type: string + type: array + uniqueItems: false + default: + type: string + description: + type: string + format: + $ref: '#/components/schemas/model.Format' + isRepeated: + type: boolean + isRequired: + type: boolean + isSecret: + type: boolean + name: + example: --port + type: string + placeholder: + type: string + type: + $ref: '#/components/schemas/model.ArgumentType' + value: + type: string + valueHint: + example: file_path + type: string + variables: + additionalProperties: + $ref: '#/components/schemas/model.Input' + type: object + type: object + model.ArgumentType: + enum: + - positional + - named + example: positional + type: string + x-enum-varnames: + - ArgumentTypePositional + - ArgumentTypeNamed + model.Format: + enum: + - string + - number + - boolean + - filepath + type: string + x-enum-varnames: + - FormatString + - FormatNumber + - FormatBoolean + - FormatFilePath + model.Icon: + properties: + mimeType: + example: image/png + type: string + sizes: + items: + type: string + type: array + uniqueItems: false + src: + example: https://example.com/icon.png + format: uri + maxLength: 255 + type: string + theme: + type: string + type: object + model.Input: + properties: + choices: + items: + type: string + type: array + uniqueItems: false + default: + type: string + description: + type: string + format: + $ref: '#/components/schemas/model.Format' + isRequired: + type: boolean + isSecret: + type: boolean + placeholder: + type: string + value: + type: string + type: object + model.KeyValueInput: + properties: + choices: + items: + type: string + type: array + uniqueItems: false + default: + type: string + description: + type: string + format: + $ref: '#/components/schemas/model.Format' + isRequired: + type: boolean + isSecret: + type: boolean + name: + example: SOME_VARIABLE + type: string + placeholder: + type: string + value: + type: string + variables: + additionalProperties: + $ref: '#/components/schemas/model.Input' + type: object + type: object + model.Package: + properties: + environmentVariables: + description: EnvironmentVariables are set when running the package + items: + $ref: '#/components/schemas/model.KeyValueInput' + type: array + uniqueItems: false + fileSha256: + description: FileSHA256 is the SHA-256 hash for integrity verification (required + for mcpb, optional for others) + example: fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce + pattern: ^[a-f0-9]{64}$ + type: string + identifier: + description: |- + Identifier is the package identifier: + - For NPM/PyPI/NuGet: package name or ID + - For OCI: full image reference (e.g., "ghcr.io/owner/repo:v1.0.0") + - For MCPB: direct download URL + example: '@modelcontextprotocol/server-brave-search' + minLength: 1 + type: string + packageArguments: + description: PackageArguments are passed to the package's binary + items: + $ref: '#/components/schemas/model.Argument' + type: array + uniqueItems: false + registryBaseUrl: + description: RegistryBaseURL is the base URL of the package registry (used + by npm, pypi, nuget; not used by oci, mcpb) + example: https://registry.npmjs.org + format: uri + type: string + registryType: + description: RegistryType indicates how to download packages (e.g., "npm", + "pypi", "oci", "nuget", "mcpb") + example: npm + minLength: 1 + type: string + runtimeArguments: + description: RuntimeArguments are passed to the package's runtime command + (e.g., docker, npx) + items: + $ref: '#/components/schemas/model.Argument' + type: array + uniqueItems: false + runtimeHint: + description: RunTimeHint suggests the appropriate runtime for the package + example: npx + type: string + transport: + $ref: '#/components/schemas/model.Transport' + version: + description: Version is the package version (required for npm, pypi, nuget; + optional for mcpb; not used by oci where version is in the identifier) + example: 1.0.2 + minLength: 1 + type: string + type: object + model.Repository: + properties: + id: + example: b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9 + type: string + source: + example: github + type: string + subfolder: + example: src/everything + type: string + url: + example: https://github.com/modelcontextprotocol/servers + format: uri + type: string + type: object + model.Transport: + description: Transport is required and specifies the transport protocol configuration + properties: + headers: + items: + $ref: '#/components/schemas/model.KeyValueInput' + type: array + uniqueItems: false + type: + example: stdio + type: string + url: + example: https://api.example.com/mcp + type: string + variables: + additionalProperties: + $ref: '#/components/schemas/model.Input' + type: object + type: object permissions.InboundNetworkPermissions: description: Inbound defines inbound network permissions properties: @@ -2234,6 +2456,19 @@ components: type: array uniqueItems: false type: object + pkg_api_v1.paginationV01Metadata: + description: Metadata contains pagination information + properties: + limit: + description: Limit is the maximum number of items per page + type: integer + page: + description: Page is the current page number (1-based) + type: integer + total: + description: Total is the total number of items matching the query + type: integer + type: object pkg_api_v1.providerCapabilitiesResponse: description: Capabilities of the secrets provider properties: @@ -2372,6 +2607,18 @@ components: description: Secret key name type: string type: object + pkg_api_v1.serversV01Response: + description: Paginated list of servers from the registry + properties: + metadata: + $ref: '#/components/schemas/pkg_api_v1.paginationV01Metadata' + servers: + description: Servers is the list of servers on the current page + items: + $ref: '#/components/schemas/v0.ServerJSON' + type: array + uniqueItems: false + type: object pkg_api_v1.setupSecretsRequest: description: Request to setup a secrets provider properties: @@ -2404,24 +2651,11 @@ components: type: array uniqueItems: false type: object - pkg_api_v1.skillsV01Metadata: - description: Metadata contains pagination information - properties: - limit: - description: Limit is the maximum number of skills per page - type: integer - page: - description: Page is the current page number (1-based) - type: integer - total: - description: Total is the total number of skills matching the query - type: integer - type: object pkg_api_v1.skillsV01Response: description: Paginated list of skills from the registry properties: metadata: - $ref: '#/components/schemas/pkg_api_v1.skillsV01Metadata' + $ref: '#/components/schemas/pkg_api_v1.paginationV01Metadata' skills: description: Skills is the list of skills on the current page items: @@ -3084,6 +3318,63 @@ components: predicate_type: type: string type: object + v0.ServerJSON: + properties: + $schema: + example: https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json + format: uri + minLength: 1 + type: string + _meta: + $ref: '#/components/schemas/v0.ServerMeta' + description: + example: MCP server providing weather data and forecasts via OpenWeatherMap + API + maxLength: 100 + minLength: 1 + type: string + icons: + items: + $ref: '#/components/schemas/model.Icon' + type: array + uniqueItems: false + name: + example: io.github.user/weather + maxLength: 200 + minLength: 3 + pattern: ^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$ + type: string + packages: + items: + $ref: '#/components/schemas/model.Package' + type: array + uniqueItems: false + remotes: + items: + $ref: '#/components/schemas/model.Transport' + type: array + uniqueItems: false + repository: + $ref: '#/components/schemas/model.Repository' + title: + example: Weather API + maxLength: 100 + minLength: 1 + type: string + version: + example: 1.0.2 + type: string + websiteUrl: + example: https://modelcontextprotocol.io/examples + format: uri + type: string + type: object + v0.ServerMeta: + properties: + io.modelcontextprotocol.registry/publisher-provided: + additionalProperties: {} + type: object + type: object v1.Duration: description: |- RefillPeriod is the duration to fully refill the bucket from zero to maxTokens. @@ -4779,6 +5070,105 @@ paths: summary: Health check tags: - system + /registry/{registryName}/v0.1/servers: + get: + description: Get a paginated list of servers from the registry. Supports optional + full-text search and pagination. + parameters: + - description: Registry name (currently ignored, uses the default provider) + in: path + name: registryName + required: true + schema: + type: string + - description: Search filter — matches against server name and description + in: query + name: q + schema: + type: string + - description: 'Page number, 1-based (default: 1)' + in: query + name: page + schema: + type: integer + - description: 'Items per page, max 200 (default: 50)' + in: query + name: limit + schema: + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.serversV01Response' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.registryErrorResponse' + description: Internal server error + "503": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.registryErrorResponse' + description: Registry authentication required or upstream registry unavailable + summary: List available registry servers + tags: + - registry-servers + /registry/{registryName}/v0.1/servers/{serverName}/versions/latest: + get: + description: Retrieve a single server by name. Names use reverse-DNS format; + URL-encode slashes. + parameters: + - description: Registry name (currently ignored, uses the default provider) + in: path + name: registryName + required: true + schema: + type: string + - description: Server name (URL-encoded reverse-DNS format) + in: path + name: serverName + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/v0.ServerJSON' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.registryErrorResponse' + description: Invalid server name encoding + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.registryErrorResponse' + description: Server not found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.registryErrorResponse' + description: Internal server error + "503": + content: + application/json: + schema: + $ref: '#/components/schemas/pkg_api_v1.registryErrorResponse' + description: Registry authentication required or upstream registry unavailable + summary: Get a registry server + tags: + - registry-servers /registry/{registryName}/v0.1/x/dev.toolhive/skills: get: description: Get a paginated list of skills from the registry. Supports optional diff --git a/pkg/api/server.go b/pkg/api/server.go index 32a7a95c76..230ef96f53 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -354,7 +354,7 @@ func (b *ServerBuilder) setupDefaultRoutes(r *chi.Mux) { "/api/v1beta/secrets": v1.SecretsRouter(), "/api/v1beta/groups": v1.GroupsRouter(b.groupManager, b.workloadManager, b.clientManager), "/api/v1beta/skills": v1.SkillsRouter(b.skillManager), - "/registry": v1.RegistryV01SkillsRouter(), + "/registry": v1.RegistryV01Router(), } for prefix, router := range standardRouters { r.Mount(prefix, standardTimeout(router)) diff --git a/pkg/api/v1/registry_v01.go b/pkg/api/v1/registry_v01.go new file mode 100644 index 0000000000..7c69790093 --- /dev/null +++ b/pkg/api/v1/registry_v01.go @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "encoding/json" + "errors" + "log/slog" + "math" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/stacklok/toolhive/pkg/config" + regpkg "github.com/stacklok/toolhive/pkg/registry" + "github.com/stacklok/toolhive/pkg/registry/auth" +) + +const ( + v01DefaultLimit = 50 + v01MaxLimit = 200 +) + +// RegistryV01Router creates a router for the v0.1 registry API. +// It combines server endpoints and skills extension endpoints under +// a common {registryName}/v0.1 prefix. +// The {registryName} path param is currently ignored (always uses the default provider). +func RegistryV01Router() http.Handler { + r := chi.NewRouter() + r.Route("/{registryName}/v0.1", func(r chi.Router) { + r.Get("/servers", listServersV01) + r.Get("/servers/{serverName}/versions/latest", getServerV01) + r.Get("/x/dev.toolhive/skills", listSkillsV01) + r.Get("/x/dev.toolhive/skills/{namespace}/{skillName}", getSkillV01) + }) + return r +} + +// getRegistryProvider returns the default registry provider configured for +// non-interactive (serve) mode to prevent browser-based OAuth flows from +// HTTP request handlers. Returns false and writes a structured JSON error +// response if the provider cannot be obtained. +func getRegistryProvider(w http.ResponseWriter) (regpkg.Provider, bool) { + provider, err := regpkg.GetDefaultProviderWithConfig( + config.NewProvider(), + regpkg.WithInteractive(false), + ) + if err != nil { + if errors.Is(err, auth.ErrRegistryAuthRequired) { + writeRegistryAuthRequiredError(w) + return nil, false + } + var unavailableErr *regpkg.UnavailableError + if errors.As(err, &unavailableErr) { + slog.Error("upstream registry unavailable", "error", err) + writeRegistryUnavailableError(w, unavailableErr) + return nil, false + } + writeJSONError(w, http.StatusInternalServerError, "internal_error", "Failed to get registry provider") + slog.Error("failed to get registry provider", "error", err) + return nil, false + } + return provider, true +} + +// writeJSONError writes a structured JSON error response matching the +// registryErrorResponse format used by other registry endpoints. +func writeJSONError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(registryErrorResponse{ + Code: code, + Message: message, + }) +} + +// parsePaginationV01 extracts page and limit query parameters from the request. +// Returns 1-based page and clamped limit (default 50, max 200). +func parsePaginationV01(r *http.Request) (page, limit int) { + page = 1 + limit = v01DefaultLimit + + // Parse both values before computing the overflow cap. + if p := r.URL.Query().Get("page"); p != "" { + if v, err := strconv.Atoi(p); err == nil && v > 0 { + page = v + } + } + if l := r.URL.Query().Get("limit"); l != "" { + if v, err := strconv.Atoi(l); err == nil && v > 0 { + if v > v01MaxLimit { + v = v01MaxLimit + } + limit = v + } + } + + // Cap page so (page-1)*limit cannot overflow int. + if maxPage := math.MaxInt / limit; page > maxPage { + page = maxPage + } + + return page, limit +} + +// paginateSlice returns start and end indices for paginating a slice of the +// given total length. The returned start and end are safe to use directly +// as slice bounds. +func paginateSlice(total, page, limit int) (start, end int) { + start = (page - 1) * limit + if start > total { + start = total + } + end = start + limit + if end > total { + end = total + } + return start, end +} + +// paginationV01Metadata holds pagination metadata for v0.1 list responses. +type paginationV01Metadata struct { + // Total is the total number of items matching the query + Total int `json:"total"` + // Page is the current page number (1-based) + Page int `json:"page"` + // Limit is the maximum number of items per page + Limit int `json:"limit"` +} diff --git a/pkg/api/v1/registry_v01_servers.go b/pkg/api/v1/registry_v01_servers.go new file mode 100644 index 0000000000..cae9f866de --- /dev/null +++ b/pkg/api/v1/registry_v01_servers.go @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + v0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + + "github.com/stacklok/toolhive-core/registry/converters" + types "github.com/stacklok/toolhive-core/registry/types" + regpkg "github.com/stacklok/toolhive/pkg/registry" + "github.com/stacklok/toolhive/pkg/registry/api" +) + +// listServersV01 handles GET /registry/{registryName}/v0.1/servers +// +// @Summary List available registry servers +// @Description Get a paginated list of servers from the registry. Supports optional full-text search and pagination. +// @Tags registry-servers +// @Produce json +// @Param registryName path string true "Registry name (currently ignored, uses the default provider)" +// @Param q query string false "Search filter — matches against server name and description" +// @Param page query integer false "Page number, 1-based (default: 1)" +// @Param limit query integer false "Items per page, max 200 (default: 50)" +// @Success 200 {object} serversV01Response +// @Failure 500 {object} registryErrorResponse "Internal server error" +// @Failure 503 {object} registryErrorResponse "Registry authentication required or upstream registry unavailable" +// @Router /registry/{registryName}/v0.1/servers [get] +func listServersV01(w http.ResponseWriter, r *http.Request) { + provider, ok := getRegistryProvider(w) + if !ok { + return + } + + servers, err := provider.ListServers() + if err != nil { + slog.Error("failed to list servers", "error", err) + writeJSONError(w, http.StatusInternalServerError, "internal_error", "Failed to list servers") + return + } + if servers == nil { + servers = []types.ServerMetadata{} + } + + // Convert to ServerJSON + converted := make([]*v0.ServerJSON, 0, len(servers)) + for _, s := range servers { + sj, convErr := serverMetadataToJSON(s.GetName(), s) + if convErr != nil { + slog.Warn("failed to convert server metadata", "name", s.GetName(), "error", convErr) + continue + } + converted = append(converted, sj) + } + + // Apply search filter + if q := r.URL.Query().Get("q"); q != "" { + converted = filterServersV01(converted, q) + } + + // Paginate + page, limit := parsePaginationV01(r) + total := len(converted) + start, end := paginateSlice(total, page, limit) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(serversV01Response{ + Servers: converted[start:end], + Metadata: paginationV01Metadata{ + Total: total, + Page: page, + Limit: limit, + }, + }); err != nil { + slog.Error("failed to encode servers response", "error", err) + } +} + +// getServerV01 handles GET /registry/{registryName}/v0.1/servers/{serverName}/versions/latest +// +// @Summary Get a registry server +// @Description Retrieve a single server by name. Names use reverse-DNS format; URL-encode slashes. +// @Tags registry-servers +// @Produce json +// @Param registryName path string true "Registry name (currently ignored, uses the default provider)" +// @Param serverName path string true "Server name (URL-encoded reverse-DNS format)" +// @Success 200 {object} v0.ServerJSON +// @Failure 400 {object} registryErrorResponse "Invalid server name encoding" +// @Failure 404 {object} registryErrorResponse "Server not found" +// @Failure 500 {object} registryErrorResponse "Internal server error" +// @Failure 503 {object} registryErrorResponse "Registry authentication required or upstream registry unavailable" +// @Router /registry/{registryName}/v0.1/servers/{serverName}/versions/latest [get] +func getServerV01(w http.ResponseWriter, r *http.Request) { + serverName := chi.URLParam(r, "serverName") + + // Server names use reverse-DNS format with slashes (e.g. io.github.stacklok/fetch). + // Clients URL-encode the slash as %2F, so we must decode it here. + decoded, err := url.PathUnescape(serverName) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "bad_request", "Invalid server name encoding") + return + } + serverName = decoded + + provider, ok := getRegistryProvider(w) + if !ok { + return + } + + server, err := provider.GetServer(serverName) + if err != nil { + // Map upstream HTTP errors to appropriate responses + var httpErr *api.RegistryHTTPError + if errors.As(err, &httpErr) { + switch httpErr.StatusCode { + case http.StatusNotFound: + writeJSONError(w, http.StatusNotFound, "not_found", "Server not found") + return + case http.StatusUnauthorized, http.StatusForbidden: + writeRegistryAuthRequiredError(w) + return + } + } + // Sentinel error from base/API providers + if errors.Is(err, regpkg.ErrServerNotFound) { + writeJSONError(w, http.StatusNotFound, "not_found", "Server not found") + return + } + slog.Error("failed to get server", "name", serverName, "error", err) + writeJSONError(w, http.StatusInternalServerError, "internal_error", "Failed to get server") + return + } + if server == nil { + writeJSONError(w, http.StatusNotFound, "not_found", "Server not found") + return + } + + sj, convErr := serverMetadataToJSON(server.GetName(), server) + if convErr != nil { + slog.Error("failed to convert server metadata", "name", serverName, "error", convErr) + writeJSONError(w, http.StatusInternalServerError, "internal_error", "Failed to convert server metadata") + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(sj); err != nil { + slog.Error("failed to encode server response", "error", err) + } +} + +// serverMetadataToJSON converts a ServerMetadata interface value to the upstream +// ServerJSON format using the appropriate converter from toolhive-core. +func serverMetadataToJSON(name string, md types.ServerMetadata) (*v0.ServerJSON, error) { + switch m := md.(type) { + case *types.ImageMetadata: + return converters.ImageMetadataToServerJSON(name, m) + case *types.RemoteServerMetadata: + return converters.RemoteServerMetadataToServerJSON(name, m) + default: + return nil, fmt.Errorf("unknown server type: %T", md) + } +} + +// filterServersV01 returns servers whose name or description contains the +// query string (case-insensitive). +func filterServersV01(servers []*v0.ServerJSON, query string) []*v0.ServerJSON { + q := strings.ToLower(query) + result := make([]*v0.ServerJSON, 0) + for _, s := range servers { + if strings.Contains(strings.ToLower(s.Name), q) || + strings.Contains(strings.ToLower(s.Description), q) { + result = append(result, s) + } + } + return result +} + +// serversV01Response is the response body for the v0.1 servers list endpoint. +// +// @Description Paginated list of servers from the registry +type serversV01Response struct { + // Servers is the list of servers on the current page + Servers []*v0.ServerJSON `json:"servers"` + // Metadata contains pagination information + Metadata paginationV01Metadata `json:"metadata"` +} diff --git a/pkg/api/v1/registry_v01_servers_test.go b/pkg/api/v1/registry_v01_servers_test.go new file mode 100644 index 0000000000..d5054c11ec --- /dev/null +++ b/pkg/api/v1/registry_v01_servers_test.go @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + v0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegistryV01Router_ListServers(t *testing.T) { + t.Parallel() + + handler := RegistryV01Router() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + resp, err := http.Get(srv.URL + "/default/v0.1/servers") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Content-Type"), "application/json") + + var body serversV01Response + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + // Should return servers from the embedded catalog (may be empty in test env) + assert.NotNil(t, body.Servers) + assert.GreaterOrEqual(t, body.Metadata.Total, 0) +} + +func TestRegistryV01Router_GetServer_NotFound(t *testing.T) { + t.Parallel() + + handler := RegistryV01Router() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + // URL-encode a non-existent reverse-DNS server name + resp, err := http.Get(srv.URL + "/default/v0.1/servers/io.nonexistent%2Fnosuchserver/versions/latest") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Content-Type"), "application/json", + "Error responses should be JSON") + + var body registryErrorResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Equal(t, "not_found", body.Code) +} + +func TestFilterServersV01(t *testing.T) { + t.Parallel() + + servers := []*v0.ServerJSON{ + {Name: "io.github.stacklok/fetch", Description: "Fetch web content"}, + {Name: "io.github.stacklok/postgres", Description: "PostgreSQL database access"}, + {Name: "io.github.other/weather", Description: "Weather data and forecasts"}, + } + + tests := []struct { + name string + query string + wantCount int + }{ + {"match name", "fetch", 1}, + {"case insensitive", "FETCH", 1}, + {"match description", "database", 1}, + {"match namespace", "stacklok", 2}, + {"match multiple", "weather", 1}, + {"no match", "nonexistent", 0}, + {"partial description", "data", 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := filterServersV01(servers, tt.query) + assert.Len(t, result, tt.wantCount) + }) + } +} + +func TestFilterServersV01_EmptyResult_NotNull(t *testing.T) { + t.Parallel() + + servers := []*v0.ServerJSON{ + {Name: "io.github.stacklok/test", Description: "A test server"}, + } + + result := filterServersV01(servers, "nonexistent") + assert.NotNil(t, result, "Filter result should be empty slice, not nil") + assert.Empty(t, result) + + // Verify JSON encoding produces [] not null + data, err := json.Marshal(result) + require.NoError(t, err) + assert.Equal(t, "[]", string(data)) +} + +func TestRegistryV01Router_ListServers_PaginationBeyondResults(t *testing.T) { + t.Parallel() + + handler := RegistryV01Router() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + resp, err := http.Get(srv.URL + "/default/v0.1/servers?page=999&limit=10") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body serversV01Response + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Empty(t, body.Servers, "Page beyond results should return empty servers") + assert.Equal(t, 999, body.Metadata.Page) + assert.GreaterOrEqual(t, body.Metadata.Total, 0) +} + +func TestPaginateSlice(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + total int + page int + limit int + wantStart int + wantEnd int + }{ + {"first page", 100, 1, 10, 0, 10}, + {"second page", 100, 2, 10, 10, 20}, + {"last partial page", 25, 3, 10, 20, 25}, + {"beyond total", 10, 5, 10, 10, 10}, + {"single item", 1, 1, 10, 0, 1}, + {"empty", 0, 1, 10, 0, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + start, end := paginateSlice(tt.total, tt.page, tt.limit) + assert.Equal(t, tt.wantStart, start) + assert.Equal(t, tt.wantEnd, end) + }) + } +} diff --git a/pkg/api/v1/registry_v01_skills.go b/pkg/api/v1/registry_v01_skills.go index a08f1bccb5..ec19543e61 100644 --- a/pkg/api/v1/registry_v01_skills.go +++ b/pkg/api/v1/registry_v01_skills.go @@ -7,76 +7,15 @@ import ( "encoding/json" "errors" "log/slog" - "math" "net/http" - "strconv" "strings" "github.com/go-chi/chi/v5" types "github.com/stacklok/toolhive-core/registry/types" - "github.com/stacklok/toolhive/pkg/config" - regpkg "github.com/stacklok/toolhive/pkg/registry" "github.com/stacklok/toolhive/pkg/registry/api" - "github.com/stacklok/toolhive/pkg/registry/auth" ) -const ( - skillsDefaultLimit = 50 - skillsMaxLimit = 200 -) - -// RegistryV01SkillsRouter creates a router for the v0.1 skills extension API. -// Skills live under the x/dev.toolhive extension namespace, matching the -// registry server's route structure: /registry/{name}/v0.1/x/dev.toolhive/skills -// The {registryName} path param is currently ignored (always uses default provider). -func RegistryV01SkillsRouter() http.Handler { - r := chi.NewRouter() - r.Route("/{registryName}/v0.1/x/dev.toolhive", func(r chi.Router) { - r.Get("/skills", listSkillsV01) - r.Get("/skills/{namespace}/{skillName}", getSkillV01) - }) - return r -} - -// getSkillsProvider returns the default registry provider configured for -// non-interactive (serve) mode to prevent browser-based OAuth flows from -// HTTP request handlers. Returns false and writes a structured JSON error -// response if the provider cannot be obtained. -func getSkillsProvider(w http.ResponseWriter) (regpkg.Provider, bool) { - provider, err := regpkg.GetDefaultProviderWithConfig( - config.NewProvider(), - regpkg.WithInteractive(false), - ) - if err != nil { - if errors.Is(err, auth.ErrRegistryAuthRequired) { - writeRegistryAuthRequiredError(w) - return nil, false - } - var unavailableErr *regpkg.UnavailableError - if errors.As(err, &unavailableErr) { - slog.Error("upstream registry unavailable", "error", err) - writeRegistryUnavailableError(w, unavailableErr) - return nil, false - } - writeJSONError(w, http.StatusInternalServerError, "internal_error", "Failed to get registry provider") - slog.Error("failed to get registry provider", "error", err) - return nil, false - } - return provider, true -} - -// writeJSONError writes a structured JSON error response matching the -// registryErrorResponse format used by other registry endpoints. -func writeJSONError(w http.ResponseWriter, status int, code, message string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(registryErrorResponse{ - Code: code, - Message: message, - }) -} - // listSkillsV01 handles GET /registry/{registryName}/v0.1/x/dev.toolhive/skills // // @Summary List available registry skills @@ -92,7 +31,7 @@ func writeJSONError(w http.ResponseWriter, status int, code, message string) { // @Failure 503 {object} registryErrorResponse "Registry authentication required or upstream registry unavailable" // @Router /registry/{registryName}/v0.1/x/dev.toolhive/skills [get] func listSkillsV01(w http.ResponseWriter, r *http.Request) { - provider, ok := getSkillsProvider(w) + provider, ok := getRegistryProvider(w) if !ok { return } @@ -113,21 +52,14 @@ func listSkillsV01(w http.ResponseWriter, r *http.Request) { } // Paginate - page, limit := parseSkillsPagination(r) + page, limit := parsePaginationV01(r) total := len(skills) - start := (page - 1) * limit - if start > total { - start = total - } - end := start + limit - if end > total { - end = total - } + start, end := paginateSlice(total, page, limit) w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(skillsV01Response{ Skills: skills[start:end], - Metadata: skillsV01Metadata{ + Metadata: paginationV01Metadata{ Total: total, Page: page, Limit: limit, @@ -155,7 +87,7 @@ func getSkillV01(w http.ResponseWriter, r *http.Request) { namespace := chi.URLParam(r, "namespace") skillName := chi.URLParam(r, "skillName") - provider, ok := getSkillsProvider(w) + provider, ok := getRegistryProvider(w) if !ok { return } @@ -202,29 +134,6 @@ func filterSkillsV01(skills []types.Skill, query string) []types.Skill { return result } -func parseSkillsPagination(r *http.Request) (page, limit int) { - page = 1 - limit = skillsDefaultLimit - if p := r.URL.Query().Get("page"); p != "" { - if v, err := strconv.Atoi(p); err == nil && v > 0 { - page = v - } - } - // Cap page so (page-1)*limit cannot overflow int in the caller. - if maxPage := math.MaxInt / limit; page > maxPage { - page = maxPage - } - if l := r.URL.Query().Get("limit"); l != "" { - if v, err := strconv.Atoi(l); err == nil && v > 0 { - if v > skillsMaxLimit { - v = skillsMaxLimit - } - limit = v - } - } - return page, limit -} - // skillsV01Response is the response body for the v0.1 skills list endpoint. // // @Description Paginated list of skills from the registry @@ -232,15 +141,5 @@ type skillsV01Response struct { // Skills is the list of skills on the current page Skills []types.Skill `json:"skills"` // Metadata contains pagination information - Metadata skillsV01Metadata `json:"metadata"` -} - -// skillsV01Metadata holds pagination metadata for the v0.1 skills list response. -type skillsV01Metadata struct { - // Total is the total number of skills matching the query - Total int `json:"total"` - // Page is the current page number (1-based) - Page int `json:"page"` - // Limit is the maximum number of skills per page - Limit int `json:"limit"` + Metadata paginationV01Metadata `json:"metadata"` } diff --git a/pkg/api/v1/registry_v01_skills_test.go b/pkg/api/v1/registry_v01_skills_test.go index 3155d28d0a..290700d152 100644 --- a/pkg/api/v1/registry_v01_skills_test.go +++ b/pkg/api/v1/registry_v01_skills_test.go @@ -48,7 +48,7 @@ func TestFilterSkillsV01(t *testing.T) { } } -func TestParseSkillsPagination(t *testing.T) { +func TestParsePaginationV01(t *testing.T) { t.Parallel() tests := []struct { @@ -57,34 +57,34 @@ func TestParseSkillsPagination(t *testing.T) { wantPage int wantLimit int }{ - {"defaults", "", 1, skillsDefaultLimit}, - {"custom page", "page=3", 3, skillsDefaultLimit}, + {"defaults", "", 1, v01DefaultLimit}, + {"custom page", "page=3", 3, v01DefaultLimit}, {"custom limit", "limit=10", 1, 10}, {"both", "page=2&limit=25", 2, 25}, - {"invalid page", "page=-1", 1, skillsDefaultLimit}, - {"limit over max", "limit=999", 1, skillsMaxLimit}, - {"limit at max", "limit=200", 1, skillsMaxLimit}, - {"page overflow", fmt.Sprintf("page=%d", math.MaxInt), math.MaxInt / skillsDefaultLimit, skillsDefaultLimit}, - {"non-numeric", "page=abc&limit=xyz", 1, skillsDefaultLimit}, + {"invalid page", "page=-1", 1, v01DefaultLimit}, + {"limit over max", "limit=999", 1, v01MaxLimit}, + {"limit at max", "limit=200", 1, v01MaxLimit}, + {"page overflow", fmt.Sprintf("page=%d", math.MaxInt), math.MaxInt / v01DefaultLimit, v01DefaultLimit}, + {"non-numeric", "page=abc&limit=xyz", 1, v01DefaultLimit}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() r := httptest.NewRequest(http.MethodGet, "/skills?"+tt.query, nil) - page, limit := parseSkillsPagination(r) + page, limit := parsePaginationV01(r) assert.Equal(t, tt.wantPage, page) assert.Equal(t, tt.wantLimit, limit) }) } } -func TestRegistryV01SkillsRouter_ListSkills(t *testing.T) { +func TestRegistryV01Router_ListSkills(t *testing.T) { t.Parallel() - handler := RegistryV01SkillsRouter() + handler := RegistryV01Router() srv := httptest.NewServer(handler) - defer srv.Close() + t.Cleanup(srv.Close) resp, err := http.Get(srv.URL + "/default/v0.1/x/dev.toolhive/skills") require.NoError(t, err) @@ -100,12 +100,12 @@ func TestRegistryV01SkillsRouter_ListSkills(t *testing.T) { assert.GreaterOrEqual(t, body.Metadata.Total, 0) } -func TestRegistryV01SkillsRouter_GetSkill_NotFound(t *testing.T) { +func TestRegistryV01Router_GetSkill_NotFound(t *testing.T) { t.Parallel() - handler := RegistryV01SkillsRouter() + handler := RegistryV01Router() srv := httptest.NewServer(handler) - defer srv.Close() + t.Cleanup(srv.Close) resp, err := http.Get(srv.URL + "/default/v0.1/x/dev.toolhive/skills/nonexistent/noskill") require.NoError(t, err) @@ -137,12 +137,12 @@ func TestFilterSkillsV01_EmptyResult_NotNull(t *testing.T) { assert.Equal(t, "[]", string(data)) } -func TestRegistryV01SkillsRouter_ListSkills_PaginationBeyondResults(t *testing.T) { +func TestRegistryV01Router_ListSkills_PaginationBeyondResults(t *testing.T) { t.Parallel() - handler := RegistryV01SkillsRouter() + handler := RegistryV01Router() srv := httptest.NewServer(handler) - defer srv.Close() + t.Cleanup(srv.Close) resp, err := http.Get(srv.URL + "/default/v0.1/x/dev.toolhive/skills?page=999&limit=10") require.NoError(t, err) diff --git a/pkg/registry/errors.go b/pkg/registry/errors.go index 256b27f2d4..df1003c3be 100644 --- a/pkg/registry/errors.go +++ b/pkg/registry/errors.go @@ -3,7 +3,13 @@ package registry -import "fmt" +import ( + "errors" + "fmt" +) + +// ErrServerNotFound indicates a server was not found in the registry. +var ErrServerNotFound = errors.New("server not found") // UnavailableError indicates the upstream registry is unreachable // or returned an unexpected (non-auth) error such as 404, timeout, or diff --git a/pkg/registry/provider_api.go b/pkg/registry/provider_api.go index d302409f31..f8d6b6270e 100644 --- a/pkg/registry/provider_api.go +++ b/pkg/registry/provider_api.go @@ -167,7 +167,7 @@ func (p *APIRegistryProvider) GetServer(name string) (types.ServerMetadata, erro } } - return nil, fmt.Errorf("server %s not found in API", name) + return nil, fmt.Errorf("%w: %s", ErrServerNotFound, name) } // SearchServers searches for servers matching the query (queries API directly) diff --git a/pkg/registry/provider_base.go b/pkg/registry/provider_base.go index 49e76b7f75..2585b7f188 100644 --- a/pkg/registry/provider_base.go +++ b/pkg/registry/provider_base.go @@ -57,7 +57,7 @@ func (p *BaseProvider) GetServer(name string) (types.ServerMetadata, error) { } } - return nil, fmt.Errorf("server not found: %s", name) + return nil, fmt.Errorf("%w: %s", ErrServerNotFound, name) } type shortNameMatch struct { From 3c5ac7e1ff8703a5b9b6662e540560dcd24c5c28 Mon Sep 17 00:00:00 2001 From: Luis Juncal <48457038+lujunsan@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:18:17 +0200 Subject: [PATCH 058/117] =?UTF-8?q?Fix=20reconcile=20loop=20caused=20by=20?= =?UTF-8?q?non-deterministic=20env=20var=20ordering=20in=20di=E2=80=A6=20(?= =?UTF-8?q?#4783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix reconcile loop caused by non-deterministic env var ordering in discovered auth mode Signed-off-by: lujunsan <luisjuncaldev@gmail.com> --- .../virtualmcpserver_deployment.go | 17 + .../virtualmcpserver_externalauth_test.go | 330 ++++++++++++++++++ 2 files changed, 347 insertions(+) diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go index f3ef83c58c..7dc6377c7a 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go @@ -11,6 +11,7 @@ import ( "fmt" "os" "path" + "sort" "strings" appsv1 "k8s.io/api/apps/v1" @@ -538,6 +539,15 @@ func (r *VirtualMCPServerReconciler) discoverExternalAuthConfigSecrets( } } + // Sort by name for deterministic ordering. The Kubernetes informer cache returns + // items in non-deterministic order (Go map iteration), so without sorting the env + // vars appear in a different sequence on each reconcile. reflect.DeepEqual in + // containerNeedsUpdate is order-sensitive, so non-deterministic ordering causes a + // continuous deployment update loop with 4+ configs. + sort.Slice(envVars, func(i, j int) bool { + return envVars[i].Name < envVars[j].Name + }) + return envVars } @@ -577,6 +587,13 @@ func (r *VirtualMCPServerReconciler) discoverInlineExternalAuthConfigSecrets( } } + // Sort by name for the same reason as discoverExternalAuthConfigSecrets: Go map + // iteration over Spec.OutgoingAuth.Backends is non-deterministic, which would + // cause a continuous deployment update loop via reflect.DeepEqual in containerNeedsUpdate. + sort.Slice(envVars, func(i, j int) bool { + return envVars[i].Name < envVars[j].Name + }) + return envVars } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go index 372251b982..b3c77838f7 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go @@ -1196,6 +1196,336 @@ func TestBuildOutgoingAuthConfig_SubjectProviderInjection(t *testing.T) { "discovered backend SubjectProviderName should be injected from first upstream") } +// TestDiscoverExternalAuthConfigSecrets_DeterministicOrdering verifies that +// discoverExternalAuthConfigSecrets returns env vars sorted alphabetically by name regardless +// of the order in which workloads are provided. Without sorting the function appends env vars +// in the order of the typedWorkloads slice (which reflects non-deterministic informer cache +// ordering), causing reflect.DeepEqual-based update detection to fire on every reconcile. +func TestDiscoverExternalAuthConfigSecrets_DeterministicOrdering(t *testing.T) { + t.Parallel() + + // Each auth config has a distinct name so that GenerateUniqueTokenExchangeEnvVarName + // produces a distinct env var name, and the expected sorted order is known upfront. + // Auth config names chosen so that alphabetical order of their generated env var names + // differs from the order they are referenced by the workloads slice below. + // + // Generated env var names: + // "alpha-auth" → TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_ALPHA_AUTH + // "beta-auth" → TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_BETA_AUTH + // "mu-auth" → TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_MU_AUTH + // "zeta-auth" → TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_ZETA_AUTH + // + // Alphabetical order: ALPHA < BETA < MU < ZETA + // + // The workloads slice is intentionally in reverse-alphabetical order (ZETA, MU, BETA, ALPHA) + // so the test fails before sorting is implemented. + + tests := []struct { + name string + workloadOrder []workloads.TypedWorkload // order simulates non-deterministic informer cache + }{ + { + name: "reverse alphabetical workload order", + workloadOrder: []workloads.TypedWorkload{ + {Name: "server-zeta", Type: workloads.WorkloadTypeMCPServer}, + {Name: "server-mu", Type: workloads.WorkloadTypeMCPServer}, + {Name: "server-beta", Type: workloads.WorkloadTypeMCPServer}, + {Name: "server-alpha", Type: workloads.WorkloadTypeMCPServer}, + }, + }, + { + name: "mixed non-alphabetical workload order", + workloadOrder: []workloads.TypedWorkload{ + {Name: "server-mu", Type: workloads.WorkloadTypeMCPServer}, + {Name: "server-alpha", Type: workloads.WorkloadTypeMCPServer}, + {Name: "server-zeta", Type: workloads.WorkloadTypeMCPServer}, + {Name: "server-beta", Type: workloads.WorkloadTypeMCPServer}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + + vmcp := &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vmcp", + Namespace: "default", + }, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Source: "discovered", + }, + }, + } + + // Four MCPServers each referencing a distinct MCPExternalAuthConfig. + // The MCPServer names match the workload Names in tt.workloadOrder. + mcpServers := []client.Object{ + &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "server-alpha", Namespace: "default"}, + Spec: mcpv1alpha1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "alpha-auth"}, + }, + }, + &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "server-beta", Namespace: "default"}, + Spec: mcpv1alpha1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "beta-auth"}, + }, + }, + &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "server-mu", Namespace: "default"}, + Spec: mcpv1alpha1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "mu-auth"}, + }, + }, + &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "server-zeta", Namespace: "default"}, + Spec: mcpv1alpha1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "zeta-auth"}, + }, + }, + } + + // One MCPExternalAuthConfig per MCPServer, each with a client secret ref so + // getExternalAuthConfigSecretEnvVar returns a non-nil env var. + authConfigs := []client.Object{ + &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "alpha-auth", Namespace: "default"}, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://alpha.example.com/token", + Audience: "alpha-service", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "alpha-secret", Key: "client-secret"}, + }, + }, + }, + &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "beta-auth", Namespace: "default"}, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://beta.example.com/token", + Audience: "beta-service", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "beta-secret", Key: "client-secret"}, + }, + }, + }, + &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "mu-auth", Namespace: "default"}, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://mu.example.com/token", + Audience: "mu-service", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "mu-secret", Key: "client-secret"}, + }, + }, + }, + &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "zeta-auth", Namespace: "default"}, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://zeta.example.com/token", + Audience: "zeta-service", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "zeta-secret", Key: "client-secret"}, + }, + }, + }, + } + + objects := []client.Object{vmcp} + objects = append(objects, mcpServers...) + objects = append(objects, authConfigs...) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + Build() + + r := &VirtualMCPServerReconciler{ + Client: fakeClient, + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + ctx := context.Background() + envVars := r.discoverExternalAuthConfigSecrets(ctx, vmcp, tt.workloadOrder) + + // We expect exactly one env var per auth config that has a client secret. + require.Len(t, envVars, 4, "expected one env var per auth config with a client secret") + + // Env vars MUST be sorted alphabetically by Name. + // assert.Equal (not assert.ElementsMatch) is intentional — order matters for + // reflect.DeepEqual-based change detection in containerNeedsUpdate. + expectedNames := []string{ + "TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_ALPHA_AUTH", + "TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_BETA_AUTH", + "TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_MU_AUTH", + "TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_ZETA_AUTH", + } + actualNames := make([]string, len(envVars)) + for i, ev := range envVars { + actualNames[i] = ev.Name + } + assert.Equal(t, expectedNames, actualNames, + "env vars must be sorted alphabetically by Name to ensure deterministic reconcile behaviour") + }) + } +} + +// TestDiscoverInlineExternalAuthConfigSecrets_DeterministicOrdering verifies that +// discoverInlineExternalAuthConfigSecrets returns env vars sorted alphabetically by name +// regardless of map iteration order across reconcile loops. Without sorting the function +// appends env vars in the non-deterministic order of Go map iteration over +// vmcp.Spec.OutgoingAuth.Backends, triggering an infinite update loop. +func TestDiscoverInlineExternalAuthConfigSecrets_DeterministicOrdering(t *testing.T) { + t.Parallel() + + // Build a VirtualMCPServer whose Spec.OutgoingAuth.Backends map has four entries so that + // the probability of Go map iteration producing alphabetical order by chance is low enough + // to make a flaky pass in the unfixed code practically impossible. + // + // Generated env var names (token exchange): + // "inline-alpha-auth" → TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_INLINE_ALPHA_AUTH + // "inline-beta-auth" → TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_INLINE_BETA_AUTH + // "inline-mu-auth" → TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_INLINE_MU_AUTH + // "inline-zeta-auth" → TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_INLINE_ZETA_AUTH + // + // Alphabetical order: ALPHA < BETA < MU < ZETA + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + + vmcp := &mcpv1alpha1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vmcp", + Namespace: "default", + }, + Spec: mcpv1alpha1.VirtualMCPServerSpec{ + OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Source: "inline", + // Map with four backends — Go map iteration order is non-deterministic. + Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + "backend-zeta": { + Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "inline-zeta-auth", + }, + }, + "backend-mu": { + Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "inline-mu-auth", + }, + }, + "backend-beta": { + Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "inline-beta-auth", + }, + }, + "backend-alpha": { + Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: "inline-alpha-auth", + }, + }, + }, + }, + }, + } + + authConfigs := []client.Object{ + &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "inline-alpha-auth", Namespace: "default"}, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://alpha.example.com/token", + Audience: "alpha-service", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "inline-alpha-secret", Key: "client-secret"}, + }, + }, + }, + &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "inline-beta-auth", Namespace: "default"}, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://beta.example.com/token", + Audience: "beta-service", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "inline-beta-secret", Key: "client-secret"}, + }, + }, + }, + &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "inline-mu-auth", Namespace: "default"}, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://mu.example.com/token", + Audience: "mu-service", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "inline-mu-secret", Key: "client-secret"}, + }, + }, + }, + &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "inline-zeta-auth", Namespace: "default"}, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://zeta.example.com/token", + Audience: "zeta-service", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "inline-zeta-secret", Key: "client-secret"}, + }, + }, + }, + } + + objects := []client.Object{vmcp} + objects = append(objects, authConfigs...) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + Build() + + r := &VirtualMCPServerReconciler{ + Client: fakeClient, + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + ctx := context.Background() + envVars := r.discoverInlineExternalAuthConfigSecrets(ctx, vmcp) + + require.Len(t, envVars, 4, "expected one env var per inline auth config with a client secret") + + // Env vars MUST be sorted alphabetically by Name. + // assert.Equal (not assert.ElementsMatch) is intentional — order matters for + // reflect.DeepEqual-based change detection in containerNeedsUpdate. + expectedNames := []string{ + "TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_INLINE_ALPHA_AUTH", + "TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_INLINE_BETA_AUTH", + "TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_INLINE_MU_AUTH", + "TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET_INLINE_ZETA_AUTH", + } + actualNames := make([]string, len(envVars)) + for i, ev := range envVars { + actualNames[i] = ev.Name + } + assert.Equal(t, expectedNames, actualNames, + "env vars must be sorted alphabetically by Name to ensure deterministic reconcile behaviour") +} + // TestBuildOutgoingAuthConfig_InlineBackendSubjectProviderInjection verifies that // SubjectProviderName is auto-populated for the inline Spec.OutgoingAuth.Backends path // (virtualmcpserver_controller.go:2007) when AuthServerConfig is set. From 22e4fd9f5abed810ca3c74e786eff4a598fd0a58 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek <jakub.hrozek@posteo.se> Date: Thu, 16 Apr 2026 14:30:27 +0100 Subject: [PATCH 059/117] Store serverName on Cedar Authorizer (#4861) The AuthorizerFactory interface already passes serverName to CreateAuthorizer, but the Cedar implementation discarded it. Store it on the Authorizer struct so downstream enterprise features (#4769, behavior is identical to today. The serverName becomes the MCP parent on resource entities (added by policies like "resource in MCP::<server>". Per the authorization RFC, this prevents a deny rule compiled from one server's policy from silently affecting same-named tools on other servers when the enterprise controller merges policies into a single set. E2E tested in a Kind cluster with real Okta tokens. The key test deploys two MCPServers that share a single Cedar ConfigMap -- the same setup the enterprise controller will produce when it compiles policies from multiple CRDs into one set. Shared Cedar policy set: permit(principal in THVGroup::"engineering", action, resource in MCP::"<server-a>"); permit(principal in THVGroup::"engineering", action, resource in MCP::"<server-b>"); forbid(principal, action == Action::"call_tool", resource == Tool::"echo") when { resource in MCP::"<server-b>" }; Okta JWT: { "groups": ["Everyone", "engineering"], "sub": "jakub@stacklok.com" } The same user calling the same tool ("echo") gets allowed on server-a and 403'd on server-b. The only difference is the serverName stored on each authorizer, which determines the MCP parent on the resource entity. Cedar's diagnostic log confirms the forbid fires only where scoped and does not bleed across servers. Closes #4764 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> --- .../cedar/annotations_integration_test.go | 2 +- .../cedar/annotations_override_test.go | 2 +- pkg/authz/authorizers/cedar/core.go | 17 ++++++-- pkg/authz/authorizers/cedar/core_test.go | 43 +++++++++++++------ pkg/authz/integration_test.go | 4 +- pkg/authz/middleware_test.go | 12 +++--- pkg/authz/response_filter_test.go | 12 +++--- pkg/authz/tool_filter_test.go | 6 +-- 8 files changed, 62 insertions(+), 36 deletions(-) diff --git a/pkg/authz/authorizers/cedar/annotations_integration_test.go b/pkg/authz/authorizers/cedar/annotations_integration_test.go index c36a90f552..e7cb9fb81a 100644 --- a/pkg/authz/authorizers/cedar/annotations_integration_test.go +++ b/pkg/authz/authorizers/cedar/annotations_integration_test.go @@ -98,7 +98,7 @@ func TestAuthorizeWithToolAnnotations(t *testing.T) { authzr, err := NewCedarAuthorizer(ConfigOptions{ Policies: tc.policies, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Build context with identity diff --git a/pkg/authz/authorizers/cedar/annotations_override_test.go b/pkg/authz/authorizers/cedar/annotations_override_test.go index b6161c1737..6bc7c6fb92 100644 --- a/pkg/authz/authorizers/cedar/annotations_override_test.go +++ b/pkg/authz/authorizers/cedar/annotations_override_test.go @@ -128,7 +128,7 @@ func TestAnnotationAttributesCannotOverrideStandardAttributes(t *testing.T) { authzr, err := NewCedarAuthorizer(ConfigOptions{ Policies: tc.policies, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") ctx := t.Context() diff --git a/pkg/authz/authorizers/cedar/core.go b/pkg/authz/authorizers/cedar/core.go index 4c173085b4..122b3e6e20 100644 --- a/pkg/authz/authorizers/cedar/core.go +++ b/pkg/authz/authorizers/cedar/core.go @@ -119,7 +119,7 @@ func (*Factory) ValidateConfig(rawConfig json.RawMessage) error { // CreateAuthorizer creates a Cedar Authorizer from the configuration. // It receives the full raw config and extracts the Cedar-specific portion. -func (*Factory) CreateAuthorizer(rawConfig json.RawMessage, _ string) (authorizers.Authorizer, error) { +func (*Factory) CreateAuthorizer(rawConfig json.RawMessage, serverName string) (authorizers.Authorizer, error) { var config Config if err := json.Unmarshal(rawConfig, &config); err != nil { return nil, fmt.Errorf("failed to parse configuration: %w", err) @@ -129,7 +129,7 @@ func (*Factory) CreateAuthorizer(rawConfig json.RawMessage, _ string) (authorize return nil, fmt.Errorf("cedar configuration is required (missing 'cedar' field)") } - return NewCedarAuthorizer(*config.Options) + return NewCedarAuthorizer(*config.Options, serverName) } // Common errors for Cedar authorization @@ -167,6 +167,12 @@ type Authorizer struct { // roleClaimName is the JWT claim key that contains role membership. // When empty, no role extraction is performed (backward compatible). roleClaimName string + // serverName is the identity of the MCP server this authorizer is scoped to. + // Used by downstream enterprise features for server-scoped Cedar policies + // (e.g. resource in MCP::"<server>"). When empty (standalone Cedar usage + // with no enterprise controller), the authorizer behaves identically to + // the unscoped case. + serverName string // claimKeyLog rate-limits the diagnostic log of resolved JWT claim keys // so it emits at most once per 30 seconds instead of once per authorization check. claimKeyLog *syncutil.AtMost @@ -201,7 +207,11 @@ type ConfigOptions struct { } // NewCedarAuthorizer creates a new Cedar authorizer. -func NewCedarAuthorizer(options ConfigOptions) (authorizers.Authorizer, error) { +// serverName is a runtime-injected value (not user-authored config) that +// identifies which MCP server this authorizer is scoped to. +// If a second runtime-injected value is needed, bundle both into a +// RuntimeContext struct to keep the factory interface stable. +func NewCedarAuthorizer(options ConfigOptions, serverName string) (authorizers.Authorizer, error) { authorizer := &Authorizer{ policySet: cedar.NewPolicySet(), entities: cedar.EntityMap{}, @@ -209,6 +219,7 @@ func NewCedarAuthorizer(options ConfigOptions) (authorizers.Authorizer, error) { primaryUpstreamProvider: options.PrimaryUpstreamProvider, groupClaimName: options.GroupClaimName, roleClaimName: options.RoleClaimName, + serverName: serverName, claimKeyLog: syncutil.NewAtMost(30 * time.Second), } diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index 92976cbff6..416f51da8d 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -37,9 +37,11 @@ func TestNewCedarAuthorizer(t *testing.T) { policies []string entitiesJSON string roleClaimName string + serverName string expectError bool errorType error wantRoleClaimName string + wantServerName string }{ { name: "Valid policy and empty entities", @@ -94,6 +96,14 @@ func TestNewCedarAuthorizer(t *testing.T) { expectError: false, wantRoleClaimName: "https://example.com/roles", }, + { + name: "Stores server name", + policies: []string{`permit(principal, action, resource);`}, + entitiesJSON: `[]`, + serverName: "my-mcp-server", + expectError: false, + wantServerName: "my-mcp-server", + }, } // Run test cases @@ -105,7 +115,7 @@ func TestNewCedarAuthorizer(t *testing.T) { Policies: tc.policies, EntitiesJSON: tc.entitiesJSON, RoleClaimName: tc.roleClaimName, - }) + }, tc.serverName) // Check error expectations if tc.expectError { @@ -121,6 +131,7 @@ func TestNewCedarAuthorizer(t *testing.T) { cedarAuthz, ok := authorizer.(*Authorizer) require.True(t, ok) assert.Equal(t, tc.wantRoleClaimName, cedarAuthz.roleClaimName) + assert.Equal(t, tc.wantServerName, cedarAuthz.serverName) } }) } @@ -351,7 +362,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{tc.policy}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Create a context with JWT claims @@ -376,7 +387,7 @@ func TestAuthorizeWithJWTClaimsErrors(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Test cases @@ -675,6 +686,10 @@ func TestFactoryCreateAuthorizer(t *testing.T) { require.NoError(t, err) require.NotNil(t, authorizer) + + cedarAuthz, ok := authorizer.(*Authorizer) + require.True(t, ok) + assert.Equal(t, "testServer", cedarAuthz.serverName) }) } } @@ -687,7 +702,7 @@ func TestUpdatePolicies(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) // Cast to concrete type to access UpdatePolicies @@ -748,7 +763,7 @@ func TestUpdateEntities(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) // Cast to concrete type to access UpdateEntities @@ -799,7 +814,7 @@ func TestEntityOperations(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) // Cast to concrete type to access entity methods @@ -839,7 +854,7 @@ func TestGetEntityNotFound(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) // Cast to concrete type @@ -863,7 +878,7 @@ func TestIsAuthorizedErrors(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) // Cast to concrete type @@ -958,7 +973,7 @@ func TestIsAuthorizedWithEntities(t *testing.T) { ); `}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) // Cast to concrete type @@ -1097,7 +1112,7 @@ func TestAuthorizeWithJWTClaims_UpstreamProvider(t *testing.T) { Policies: []string{policy}, EntitiesJSON: `[]`, PrimaryUpstreamProvider: providerName, - }) + }, "") require.NoError(t, err) upstreamToken := makeUnsignedJWT(jwt.MapClaims{ @@ -1242,7 +1257,7 @@ func TestAuthorizeWithJWTClaims_GroupMembership(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{policy}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) tests := []struct { @@ -1313,7 +1328,7 @@ func TestAuthorizeWithJWTClaims_DoesNotMutateIdentity(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{policy}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) identity := &auth.Identity{ @@ -1361,7 +1376,7 @@ func TestAuthorizeWithJWTClaims_CustomGroupClaimName(t *testing.T) { Policies: []string{policy}, EntitiesJSON: `[]`, GroupClaimName: "https://example.com/groups", - }) + }, "") require.NoError(t, err) // The custom claim holds "platform"; the well-known "groups" key holds other groups. @@ -1410,7 +1425,7 @@ func TestAuthorizeWithJWTClaims_UpstreamProviderWithGroups(t *testing.T) { Policies: []string{policy}, EntitiesJSON: `[]`, PrimaryUpstreamProvider: providerName, - }) + }, "") require.NoError(t, err) tests := []struct { diff --git a/pkg/authz/integration_test.go b/pkg/authz/integration_test.go index 990a298b45..4c17bd6dc3 100644 --- a/pkg/authz/integration_test.go +++ b/pkg/authz/integration_test.go @@ -48,7 +48,7 @@ func TestIntegrationListFiltering(t *testing.T) { `permit(principal, action == Action::"read_resource", resource) when { principal.claim_role == "admin" };`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") testCases := []struct { @@ -335,7 +335,7 @@ func TestIntegrationNonListOperations(t *testing.T) { `permit(principal, action == Action::"call_tool", resource) when { principal.claim_role == "admin" };`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") testCases := []struct { diff --git a/pkg/authz/middleware_test.go b/pkg/authz/middleware_test.go index 2def6ff1a0..2c3a815561 100644 --- a/pkg/authz/middleware_test.go +++ b/pkg/authz/middleware_test.go @@ -61,7 +61,7 @@ func TestMiddleware(t *testing.T) { `permit(principal, action == Action::"read_resource", resource == Resource::"data");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Test cases @@ -443,7 +443,7 @@ func TestMiddlewareWithGETRequest(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Create a handler that records if it was called @@ -808,7 +808,7 @@ func TestMiddlewareToolsListTestkit(t *testing.T) { cedar.ConfigOptions{ Policies: tc.policies, EntitiesJSON: `[]`, - }, + }, "", ) require.NoError(t, err, "Failed to create Cedar authorizer") @@ -978,7 +978,7 @@ func TestMiddlewareToolsCallTestkit(t *testing.T) { cedar.ConfigOptions{ Policies: tc.policies, EntitiesJSON: `[]`, - }, + }, "", ) require.NoError(t, err, "Failed to create Cedar authorizer") @@ -1049,7 +1049,7 @@ func TestMiddlewareOptimizerMetaTools(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"allowed_backend");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) passThroughTools := map[string]struct{}{ @@ -1150,7 +1150,7 @@ func TestMiddlewareOptimizerCallToolJSONRoundTrip(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"backend_fetch");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) passThroughTools := map[string]struct{}{ diff --git a/pkg/authz/response_filter_test.go b/pkg/authz/response_filter_test.go index 450068e184..f36c70e772 100644 --- a/pkg/authz/response_filter_test.go +++ b/pkg/authz/response_filter_test.go @@ -88,7 +88,7 @@ func TestFindToolResponseFilter(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ @@ -239,7 +239,7 @@ func TestResponseFilteringWriter(t *testing.T) { `permit(principal, action == Action::"read_resource", resource == Resource::"data");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") testCases := []struct { @@ -432,7 +432,7 @@ func TestResponseFilteringWriter_NonListOperations(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Test that non-list operations pass through unchanged @@ -481,7 +481,7 @@ func TestResponseFilteringWriter_ErrorResponse(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Create an error response @@ -534,7 +534,7 @@ func TestResponseFilteringWriter_ContentLengthMismatch(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Build the backend response: a tools/list result with 3 tools. @@ -727,7 +727,7 @@ func TestOptimizerPassThroughToolsInResponseFilter(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: "[]", - }) + }, "") require.NoError(t, err) // Build a tools/list response as the optimizer would produce it: diff --git a/pkg/authz/tool_filter_test.go b/pkg/authz/tool_filter_test.go index 061eda93ef..0043de1526 100644 --- a/pkg/authz/tool_filter_test.go +++ b/pkg/authz/tool_filter_test.go @@ -181,7 +181,7 @@ func TestFilterToolsByPolicy_WithCedarAuthorizer(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) t.Run("keeps only permitted tool", func(t *testing.T) { @@ -222,7 +222,7 @@ func TestAuthorizeToolCall_WithCedarAuthorizer(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) t.Run("permits authorized tool", func(t *testing.T) { @@ -252,7 +252,7 @@ func TestAuthorizeToolCall_WithArguments(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"deploy") when { context.arg_mode == "safe" };`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) t.Run("permits when arguments satisfy policy", func(t *testing.T) { From 26aab50dd95defc2ae652ac8ea502d6bc701be9a Mon Sep 17 00:00:00 2001 From: Stacklok Bot <140063061+stacklokbot@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:03:40 -0400 Subject: [PATCH 060/117] Release v0.21.0 (#4902) * Update VERSION for release Release-Triggered-By: ChrisJBurns * Update Chart.yaml for release Release-Triggered-By: ChrisJBurns * Update Chart.yaml for release Release-Triggered-By: ChrisJBurns * Update Chart.yaml for release Release-Triggered-By: ChrisJBurns * Update Chart.yaml for release Release-Triggered-By: ChrisJBurns * Update values.yaml for release Release-Triggered-By: ChrisJBurns * Update values.yaml for release Release-Triggered-By: ChrisJBurns * Update values.yaml for release Release-Triggered-By: ChrisJBurns * Update VERSION for release Release-Triggered-By: ChrisJBurns * Update Chart.yaml for release Release-Triggered-By: ChrisJBurns * Update README.md for release Release-Triggered-By: ChrisJBurns * Update Chart.yaml for release Release-Triggered-By: ChrisJBurns * Update README.md for release Release-Triggered-By: ChrisJBurns * Update values.yaml for release Release-Triggered-By: ChrisJBurns --- VERSION | 2 +- deploy/charts/operator-crds/Chart.yaml | 4 ++-- deploy/charts/operator-crds/README.md | 2 +- deploy/charts/operator/Chart.yaml | 4 ++-- deploy/charts/operator/README.md | 10 +++++----- deploy/charts/operator/values.yaml | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index 5a03fb737b..885415662f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.20.0 +0.21.0 diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index 8149dffaaa..c670e4bcd0 100644 --- a/deploy/charts/operator-crds/Chart.yaml +++ b/deploy/charts/operator-crds/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator-crds description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. type: application -version: 0.20.0 -appVersion: "v0.20.0" +version: 0.21.0 +appVersion: "v0.21.0" diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index 84c6ecd60d..22358cc82a 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -1,6 +1,6 @@ # ToolHive Operator CRDs Helm Chart -![Version: 0.20.0](https://img.shields.io/badge/Version-0.20.0-informational?style=flat-square) +![Version: 0.21.0](https://img.shields.io/badge/Version-0.21.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. diff --git a/deploy/charts/operator/Chart.yaml b/deploy/charts/operator/Chart.yaml index 488c63fcd8..ffff9b3260 100644 --- a/deploy/charts/operator/Chart.yaml +++ b/deploy/charts/operator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator description: A Helm chart for deploying the ToolHive Operator into Kubernetes. type: application -version: 0.20.0 -appVersion: "v0.20.0" +version: 0.21.0 +appVersion: "v0.21.0" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index 107ee17f1e..5d155b1c33 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -1,6 +1,6 @@ # ToolHive Operator Helm Chart -![Version: 0.20.0](https://img.shields.io/badge/Version-0.20.0-informational?style=flat-square) +![Version: 0.21.0](https://img.shields.io/badge/Version-0.21.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for deploying the ToolHive Operator into Kubernetes. @@ -46,7 +46,7 @@ The command removes all the Kubernetes components associated with the chart and |-----|------|---------|-------------| | fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources | | nameOverride | string | `""` | Override the name of the chart | -| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"env":[],"features":{"experimental":false,"registry":true,"server":true,"virtualMCP":true},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.20.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.20.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.20.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | +| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"env":[],"features":{"experimental":false,"registry":true,"server":true,"virtualMCP":true},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.21.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.21.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.21.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | | operator.affinity | object | `{}` | Affinity settings for the operator pod | | operator.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | Configuration for horizontal pod autoscaling | | operator.autoscaling.enabled | bool | `false` | Enable autoscaling for the operator | @@ -62,7 +62,7 @@ The command removes all the Kubernetes components associated with the chart and | operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container | | operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container | | operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container | -| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.20.0"` | Container image for the operator | +| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.21.0"` | Container image for the operator | | operator.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy for the operator container | | operator.imagePullSecrets | list | `[]` | List of image pull secrets to use | | operator.leaderElectionRole | object | `{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]}` | Leader election role configuration | @@ -89,8 +89,8 @@ The command removes all the Kubernetes components associated with the chart and | operator.serviceAccount.labels | object | `{}` | Labels to add to the service account | | operator.serviceAccount.name | string | `"toolhive-operator"` | The name of the service account to use. If not set and create is true, a name is generated. | | operator.tolerations | list | `[]` | Tolerations for the operator pod | -| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.20.0"` | Image to use for Toolhive runners | -| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.20.0"` | Image to use for Virtual MCP Server (vMCP) deployments | +| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.21.0"` | Image to use for Toolhive runners | +| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.21.0"` | Image to use for Virtual MCP Server (vMCP) deployments | | operator.volumeMounts | list | `[]` | Additional volume mounts on the operator container | | operator.volumes | list | `[]` | Additional volumes to mount on the operator pod | | registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.1.1"}` | All values for the registry API deployment and associated resources | diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index 7d1022831e..556fa9b3fc 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -26,15 +26,15 @@ operator: # -- List of image pull secrets to use imagePullSecrets: [] # -- Container image for the operator - image: ghcr.io/stacklok/toolhive/operator:v0.20.0 + image: ghcr.io/stacklok/toolhive/operator:v0.21.0 # -- Image pull policy for the operator container imagePullPolicy: IfNotPresent # -- Image to use for Toolhive runners - toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.20.0 + toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.21.0 # -- Image to use for Virtual MCP Server (vMCP) deployments - vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.20.0 + vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.21.0 # -- Host for the proxy deployed by the operator proxyHost: 0.0.0.0 From dfd794da4ecbaa0fdb02e48e7701f081dc0782ad Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:04:33 +0200 Subject: [PATCH 061/117] Remove empty parent directories on skill uninstall (#4895) * Add RemoveEmptyParents helper to skill installer * Remove empty parent directories after skill uninstall * Test RemoveEmptyParents behavior and edge cases --- pkg/skills/installer.go | 15 +++++++ pkg/skills/installer_test.go | 73 +++++++++++++++++++++++++++++++++ pkg/skills/skillsvc/skillsvc.go | 12 ++++++ 3 files changed, 100 insertions(+) diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index 78c0f72181..8d6e7b0de3 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -217,6 +217,21 @@ func ValidatePathNoSymlinks(targetDir string) error { return nil } +// RemoveEmptyParents walks up from dir, removing each directory that is empty, +// stopping at stopAt (which is never removed) or when a non-empty directory is +// encountered. Errors are silently ignored — this is best-effort cleanup. +func RemoveEmptyParents(dir, stopAt string) { + dir = filepath.Clean(dir) + stopAt = filepath.Clean(stopAt) + for dir != stopAt && filepath.Dir(dir) != dir { + if err := os.Remove(dir); err != nil { + // Directory is not empty, doesn't exist, or we lack permission — stop. + return + } + dir = filepath.Dir(dir) + } +} + // pathDepth counts the number of non-empty components in an absolute path. // For example, "/var/home/user/skills" returns 4. func pathDepth(absPath string) int { diff --git a/pkg/skills/installer_test.go b/pkg/skills/installer_test.go index 6f4e1bfa07..8e06d62ce6 100644 --- a/pkg/skills/installer_test.go +++ b/pkg/skills/installer_test.go @@ -231,3 +231,76 @@ func TestNewInstaller(t *testing.T) { inst := NewInstaller() require.NotNil(t, inst) } + +func TestRemoveEmptyParents(t *testing.T) { + t.Parallel() + + t.Run("removes a chain of empty directories up to stop boundary", func(t *testing.T) { + t.Parallel() + // Create: stopAt/a/b/c (all empty) + stopAt, _ := filepath.EvalSymlinks(t.TempDir()) + chain := filepath.Join(stopAt, "a", "b", "c") + require.NoError(t, os.MkdirAll(chain, 0750)) + + RemoveEmptyParents(chain, stopAt) + + // a, a/b, a/b/c should all be gone + _, err := os.Stat(filepath.Join(stopAt, "a")) + assert.True(t, os.IsNotExist(err), "directory 'a' should have been removed") + }) + + t.Run("stops when a directory is not empty", func(t *testing.T) { + t.Parallel() + // Create: stopAt/a/b/c (c is empty; b has an extra file) + stopAt, _ := filepath.EvalSymlinks(t.TempDir()) + chain := filepath.Join(stopAt, "a", "b", "c") + require.NoError(t, os.MkdirAll(chain, 0750)) + require.NoError(t, os.WriteFile(filepath.Join(stopAt, "a", "b", "other.txt"), []byte("x"), 0600)) + + RemoveEmptyParents(chain, stopAt) + + // c should be gone but b (and a) should remain because b is not empty + _, errC := os.Stat(chain) + assert.True(t, os.IsNotExist(errC), "directory 'c' should have been removed") + + _, errB := os.Stat(filepath.Join(stopAt, "a", "b")) + assert.NoError(t, errB, "directory 'b' should still exist (not empty)") + }) + + t.Run("never removes the stop directory itself", func(t *testing.T) { + t.Parallel() + stopAt, _ := filepath.EvalSymlinks(t.TempDir()) + // The stop dir is also the immediate parent of what we pass in. + child := filepath.Join(stopAt, "skill") + require.NoError(t, os.MkdirAll(child, 0750)) + + RemoveEmptyParents(child, stopAt) + + // child is removed but stopAt must remain + _, errChild := os.Stat(child) + assert.True(t, os.IsNotExist(errChild), "child directory should have been removed") + + _, errStop := os.Stat(stopAt) + assert.NoError(t, errStop, "stop directory must not be removed") + }) + + t.Run("is a no-op when directory does not exist", func(t *testing.T) { + t.Parallel() + stopAt, _ := filepath.EvalSymlinks(t.TempDir()) + missing := filepath.Join(stopAt, "does-not-exist") + + // Should not panic or error. + RemoveEmptyParents(missing, stopAt) + + _, err := os.Stat(stopAt) + assert.NoError(t, err, "stop directory must not be touched") + }) + + t.Run("stop and dir equal — does nothing", func(t *testing.T) { + t.Parallel() + dir, _ := filepath.EvalSymlinks(t.TempDir()) + RemoveEmptyParents(dir, dir) + _, err := os.Stat(dir) + assert.NoError(t, err, "directory must not be removed when it equals stopAt") + }) +} diff --git a/pkg/skills/skillsvc/skillsvc.go b/pkg/skills/skillsvc/skillsvc.go index 18ec509b12..45f3a634d6 100644 --- a/pkg/skills/skillsvc/skillsvc.go +++ b/pkg/skills/skillsvc/skillsvc.go @@ -359,6 +359,14 @@ func (s *service) Uninstall(ctx context.Context, opts skills.UninstallOptions) e return err } + // Determine the boundary directory for empty-parent cleanup. + stopDir := opts.ProjectRoot + if scope == skills.ScopeUser { + if homeDir, err := os.UserHomeDir(); err == nil { + stopDir = homeDir + } + } + // Remove files for each client — best-effort: collect errors but don't // abort on the first failure so we clean up as much as possible. var cleanupErrs []error @@ -371,6 +379,10 @@ func (s *service) Uninstall(ctx context.Context, opts skills.UninstallOptions) e } if rmErr := s.installer.Remove(skillPath); rmErr != nil { cleanupErrs = append(cleanupErrs, fmt.Errorf("removing files for client %q: %w", clientType, rmErr)) + continue + } + if stopDir != "" { + skills.RemoveEmptyParents(filepath.Dir(skillPath), stopDir) } } } From ab2a8210744af07c5e888ce0ce6d81f5bd58c280 Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:04:41 +0200 Subject: [PATCH 062/117] Only install skills for clients detected on the system (#4899) * Extract IsClientInstalled from GetClientStatus in ClientManager * Filter skill installs to clients detected on the system * Improve error when no installed clients are detected for skill install * Seed claude-code stub config in e2e temp home for skill installs --- pkg/api/server.go | 10 ++++-- pkg/client/discovery.go | 36 ++++++++++---------- pkg/client/discovery_test.go | 60 +++++++++++++++++++++++++++++++++ pkg/skills/skillsvc/skillsvc.go | 5 +-- test/e2e/api_helpers.go | 6 ++++ 5 files changed, 95 insertions(+), 22 deletions(-) diff --git a/pkg/api/server.go b/pkg/api/server.go index 230ef96f53..bc23dcd490 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -772,9 +772,13 @@ func (a *clientPathAdapter) GetSkillPath(clientType, skillName string, scope ski func (a *clientPathAdapter) ListSkillSupportingClients() []string { clients := a.cm.ListSkillSupportingClients() - result := make([]string, len(clients)) - for i, c := range clients { - result[i] = string(c) + var result []string + for _, c := range clients { + if a.cm.IsClientInstalled(c) { + result = append(result, string(c)) + } else { + slog.Debug("skipping client for skill install: not detected on system", "client", c) + } } return result } diff --git a/pkg/client/discovery.go b/pkg/client/discovery.go index 0f47f51eda..685fb566bc 100644 --- a/pkg/client/discovery.go +++ b/pkg/client/discovery.go @@ -77,6 +77,24 @@ type ClientAppStatus struct { SupportsSkills bool `json:"supports_skills"` } +// IsClientInstalled reports whether the given client appears to be installed on +// the current system. Detection is based on the presence of the client's +// configuration directory (or settings file when no relative path is defined). +func (cm *ClientManager) IsClientInstalled(clientType ClientApp) bool { + cfg := cm.lookupClientAppConfig(clientType) + if cfg == nil { + return false + } + var pathToCheck string + if len(cfg.RelPath) == 0 { + pathToCheck = filepath.Join(cm.homeDir, cfg.SettingsFile) + } else { + pathToCheck = buildConfigDirectoryPath(cfg.RelPath, cfg.PlatformPrefix, []string{cm.homeDir}) + } + _, err := os.Stat(pathToCheck) + return err == nil +} + // GetClientStatus returns the status of all supported MCP clients using this manager's dependencies func (cm *ClientManager) GetClientStatus(ctx context.Context) ([]ClientAppStatus, error) { var statuses []ClientAppStatus @@ -106,26 +124,10 @@ func (cm *ClientManager) GetClientStatus(ctx context.Context) ([]ClientAppStatus for _, cfg := range cm.clientIntegrations { status := ClientAppStatus{ ClientType: cfg.ClientType, - Installed: false, // start with assuming client is not installed + Installed: cm.IsClientInstalled(cfg.ClientType), Registered: registeredClients[string(cfg.ClientType)], SupportsSkills: cfg.SupportsSkills, } - - // Determine path to check based on configuration - var pathToCheck string - if len(cfg.RelPath) == 0 { - // If RelPath is empty, look at just the settings file - pathToCheck = filepath.Join(cm.homeDir, cfg.SettingsFile) - } else { - // Otherwise build the directory path using RelPath - pathToCheck = buildConfigDirectoryPath(cfg.RelPath, cfg.PlatformPrefix, []string{cm.homeDir}) - } - - // Check if the path exists - if _, err := os.Stat(pathToCheck); err == nil { - status.Installed = true - } - statuses = append(statuses, status) } diff --git a/pkg/client/discovery_test.go b/pkg/client/discovery_test.go index 5bb5ed4b92..dc6b37dd9e 100644 --- a/pkg/client/discovery_test.go +++ b/pkg/client/discovery_test.go @@ -200,6 +200,66 @@ func TestGetClientStatus_Sorting(t *testing.T) { } } +func TestIsClientInstalled(t *testing.T) { + t.Parallel() + + tempHome := t.TempDir() + + // Create a .claude.json file (simulates ClaudeCode installed) + _, err := os.Create(filepath.Join(tempHome, ".claude.json")) + require.NoError(t, err) + + // Create a .cursor directory (simulates Cursor installed via RelPath) + err = os.Mkdir(filepath.Join(tempHome, ".cursor"), 0700) + require.NoError(t, err) + + // VSCode path (.config/Code/User) is intentionally not created + + clientIntegrations := []clientAppConfig{ + { + ClientType: ClaudeCode, + SettingsFile: ".claude.json", + RelPath: []string{}, // file directly in home dir + }, + { + ClientType: Cursor, + SettingsFile: "mcp.json", + RelPath: []string{".cursor"}, // directory in home dir + }, + { + ClientType: VSCode, + SettingsFile: "mcp.json", + RelPath: []string{".config", "Code", "User"}, // not created + }, + { + // unknown client, no config + ClientType: ClientApp("nonexistent"), + SettingsFile: "settings.json", + RelPath: []string{".nonexistent"}, + }, + } + + manager := NewTestClientManager(tempHome, nil, clientIntegrations, nil) + + tests := []struct { + name string + clientType ClientApp + want bool + }{ + {name: "ClaudeCode settings file present", clientType: ClaudeCode, want: true}, + {name: "Cursor directory present", clientType: Cursor, want: true}, + {name: "VSCode directory absent", clientType: VSCode, want: false}, + {name: "client not in integrations", clientType: ClientApp("not-registered"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, manager.IsClientInstalled(tt.clientType)) + }) + } +} + func TestGetClientStatus_WithGroups(t *testing.T) { t.Parallel() diff --git a/pkg/skills/skillsvc/skillsvc.go b/pkg/skills/skillsvc/skillsvc.go index 45f3a634d6..b195beccfa 100644 --- a/pkg/skills/skillsvc/skillsvc.go +++ b/pkg/skills/skillsvc/skillsvc.go @@ -1460,8 +1460,9 @@ func (s *service) resolveAndValidateClients( clients := s.pathResolver.ListSkillSupportingClients() if len(clients) == 0 { return nil, nil, httperr.WithCode( - errors.New("no skill-supporting clients configured"), - http.StatusInternalServerError, + errors.New("no supported clients detected on this system; "+ + "use --clients to target a specific client explicitly"), + http.StatusBadRequest, ) } requested = clients diff --git a/test/e2e/api_helpers.go b/test/e2e/api_helpers.go index fcb8da6f13..ad8c4cc5f6 100644 --- a/test/e2e/api_helpers.go +++ b/test/e2e/api_helpers.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "os/exec" + "path/filepath" "strconv" "strings" "time" @@ -78,6 +79,11 @@ func NewServer(config *ServerConfig) (*Server, error) { tempHome = GinkgoT().TempDir() } + // Create a stub claude-code settings file so that at least one skill-supporting + // client is detected as installed. Without this, installs that omit --clients + // would fail because no client config paths exist in the temp home dir. + _ = os.WriteFile(filepath.Join(tempHome, ".claude.json"), []byte("{}"), 0600) + ctx, cancel := context.WithCancel(context.Background()) // Create string builders to capture output From 74335226cae32b50cd1b17fae8fdc90eda54e1b4 Mon Sep 17 00:00:00 2001 From: Guillermo Gomez <86891850+gmogmzGithub@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:37:51 -0600 Subject: [PATCH 063/117] Detect JSON-RPC application errors in proxy audit middleware (#4709) * Detect JSON-RPC application errors in proxy audit middleware The audit middleware previously determined outcome solely from the HTTP status code. Because MCP servers return errors inside HTTP 200 responses per the JSON-RPC spec, application-level failures (expired tokens, API errors, invalid parameters) were logged as outcome=success, making them invisible in audit logs. This change inspects the response body for a JSON-RPC error field when the HTTP status is 200. If found, the outcome is recorded as application_error with the error code and truncated message in metadata. A new DetectApplicationErrors config flag (default true) controls the behavior independently of IncludeResponseData, avoiding the need to enable full response capture just for error detection. Fixes #4678 Signed-off-by: Guillermo Gomez <guillermogomezmora@gmail.com> * Fix lint and regenerate swagger docs - Extract detectApplicationError into separate method to reduce cyclomatic complexity of logAuditEvent (gocyclo > 15) - Fix gci import formatting in config.go - Regenerate swagger docs for new DetectApplicationErrors config field Signed-off-by: Guillermo Gomez <guillermogomezmora@gmail.com> * Wire detectApplicationError into logAuditEvent Replace inline JSON-RPC error detection logic with a call to the extracted detectApplicationError method, fixing both the gocyclo complexity violation and the unused function lint error. Signed-off-by: Guillermo Gomez <guillermogomezmora@gmail.com> * Regenerate CRD manifests and docs for DetectApplicationErrors field Signed-off-by: Gustavo Gomez <gmogmz@indeed.com> --------- Signed-off-by: Guillermo Gomez <guillermogomezmora@gmail.com> Signed-off-by: Gustavo Gomez <gmogmz@indeed.com> --- ...olhive.stacklok.dev_virtualmcpservers.yaml | 9 + ...olhive.stacklok.dev_virtualmcpservers.yaml | 9 + docs/operator/crd-api.md | 1 + docs/server/docs.go | 4 + docs/server/swagger.json | 4 + docs/server/swagger.yaml | 10 + pkg/audit/auditor.go | 75 ++++++- pkg/audit/auditor_test.go | 199 ++++++++++++++++++ pkg/audit/config.go | 25 ++- pkg/audit/event.go | 4 + pkg/mcp/response.go | 62 ++++++ pkg/mcp/response_test.go | 92 ++++++++ 12 files changed, 490 insertions(+), 4 deletions(-) create mode 100644 pkg/mcp/response.go create mode 100644 pkg/mcp/response_test.go diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 6addc27430..86f25d9e6c 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -785,6 +785,15 @@ spec: description: Component is the component name to use in audit events. type: string + detectApplicationErrors: + default: true + description: |- + DetectApplicationErrors controls whether the audit middleware inspects + JSON-RPC response bodies for application-level errors when the HTTP + status code indicates success (2xx). When enabled, a small prefix of + the response body is buffered to detect JSON-RPC error fields, + independent of the IncludeResponseData setting. + type: boolean enabled: default: false description: |- diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index f54942a1fc..087b640b78 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -788,6 +788,15 @@ spec: description: Component is the component name to use in audit events. type: string + detectApplicationErrors: + default: true + description: |- + DetectApplicationErrors controls whether the audit middleware inspects + JSON-RPC response bodies for application-level errors when the HTTP + status code indicates success (2xx). When enabled, a small prefix of + the response body is buffered to detect JSON-RPC error fields, + independent of the IncludeResponseData setting. + type: boolean enabled: default: false description: |- diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index b5d39e5f71..4f7f2f72ba 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -36,6 +36,7 @@ _Appears in:_ | `excludeEventTypes` _string array_ | ExcludeEventTypes specifies which event types to exclude from auditing.<br />This takes precedence over EventTypes. | | Optional: \{\} <br /> | | `includeRequestData` _boolean_ | IncludeRequestData determines whether to include request data in audit logs. | false | Optional: \{\} <br /> | | `includeResponseData` _boolean_ | IncludeResponseData determines whether to include response data in audit logs. | false | Optional: \{\} <br /> | +| `detectApplicationErrors` _boolean_ | DetectApplicationErrors controls whether the audit middleware inspects<br />JSON-RPC response bodies for application-level errors when the HTTP<br />status code indicates success (2xx). When enabled, a small prefix of<br />the response body is buffered to detect JSON-RPC error fields,<br />independent of the IncludeResponseData setting. | true | Optional: \{\} <br /> | | `maxDataSize` _integer_ | MaxDataSize limits the size of request/response data included in audit logs (in bytes). | 1024 | Optional: \{\} <br /> | | `logFile` _string_ | LogFile specifies the file path for audit logs. If empty, logs to stdout. | | Optional: \{\} <br /> | diff --git a/docs/server/docs.go b/docs/server/docs.go index 46cf506bc7..6dce85e76c 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -99,6 +99,10 @@ const docTemplate = `{ "description": "Component is the component name to use in audit events.\n+optional", "type": "string" }, + "detectApplicationErrors": { + "description": "DetectApplicationErrors controls whether the audit middleware inspects\nJSON-RPC response bodies for application-level errors when the HTTP\nstatus code indicates success (2xx). When enabled, a small prefix of\nthe response body is buffered to detect JSON-RPC error fields,\nindependent of the IncludeResponseData setting.\n+kubebuilder:default=true\n+optional", + "type": "boolean" + }, "enabled": { "description": "Enabled controls whether audit logging is enabled.\nWhen true, enables audit logging with the configured options.\n+kubebuilder:default=false\n+optional", "type": "boolean" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 11fdd2c973..cc51703a91 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -92,6 +92,10 @@ "description": "Component is the component name to use in audit events.\n+optional", "type": "string" }, + "detectApplicationErrors": { + "description": "DetectApplicationErrors controls whether the audit middleware inspects\nJSON-RPC response bodies for application-level errors when the HTTP\nstatus code indicates success (2xx). When enabled, a small prefix of\nthe response body is buffered to detect JSON-RPC error fields,\nindependent of the IncludeResponseData setting.\n+kubebuilder:default=true\n+optional", + "type": "boolean" + }, "enabled": { "description": "Enabled controls whether audit logging is enabled.\nWhen true, enables audit logging with the configured options.\n+kubebuilder:default=false\n+optional", "type": "boolean" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index e73d67d304..7881799fe9 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -92,6 +92,16 @@ components: Component is the component name to use in audit events. +optional type: string + detectApplicationErrors: + description: |- + DetectApplicationErrors controls whether the audit middleware inspects + JSON-RPC response bodies for application-level errors when the HTTP + status code indicates success (2xx). When enabled, a small prefix of + the response body is buffered to detect JSON-RPC error fields, + independent of the IncludeResponseData setting. + +kubebuilder:default=true + +optional + type: boolean enabled: description: |- Enabled controls whether audit logging is enabled. diff --git a/pkg/audit/auditor.go b/pkg/audit/auditor.go index 7ae2885446..b383c63c4d 100644 --- a/pkg/audit/auditor.go +++ b/pkg/audit/auditor.go @@ -106,12 +106,26 @@ func (a *Auditor) isSSETransport() bool { return a.transportType == types.TransportTypeSSE.String() } +// errorDetectionBufferSize is the maximum number of bytes buffered from the +// response body for JSON-RPC error detection. JSON-RPC error responses have +// the "error" field near the top of the object, so a small prefix is +// sufficient. This buffer is allocated independently of IncludeResponseData. +const errorDetectionBufferSize = 512 + +// maxAuditErrorMessageLength caps the JSON-RPC error message length stored +// in audit event metadata to keep log entries compact. +const maxAuditErrorMessageLength = 256 + // responseWriter wraps http.ResponseWriter to capture response data and status. type responseWriter struct { http.ResponseWriter statusCode int body *bytes.Buffer - auditor *Auditor + // errorDetectionBody is a small prefix buffer used exclusively for + // JSON-RPC error detection. It is allocated when DetectApplicationErrors + // is true, independent of IncludeResponseData. + errorDetectionBody *bytes.Buffer + auditor *Auditor } func (rw *responseWriter) WriteHeader(statusCode int) { @@ -127,6 +141,15 @@ func (rw *responseWriter) Write(data []byte) (int, error) { rw.body.Write(data) } } + // Capture a small prefix for JSON-RPC error detection + if rw.errorDetectionBody != nil && rw.errorDetectionBody.Len() < errorDetectionBufferSize { + remaining := errorDetectionBufferSize - rw.errorDetectionBody.Len() + if len(data) <= remaining { + rw.errorDetectionBody.Write(data) + } else { + rw.errorDetectionBody.Write(data[:remaining]) + } + } return rw.ResponseWriter.Write(data) } @@ -201,6 +224,13 @@ func (a *Auditor) Middleware(next http.Handler) http.Handler { rw.body = &bytes.Buffer{} } + // Allocate a small prefix buffer for JSON-RPC error detection, + // independent of IncludeResponseData. When IncludeResponseData + // is already true, we reuse rw.body instead of double-buffering. + if a.config.ShouldDetectApplicationErrors() && !a.config.IncludeResponseData { + rw.errorDetectionBody = &bytes.Buffer{} + } + // Process the request next.ServeHTTP(rw, r) @@ -220,6 +250,16 @@ func (a *Auditor) logAuditEvent(r *http.Request, rw *responseWriter, requestData // Determine outcome based on status code outcome := a.determineOutcome(rw.statusCode) + // When HTTP status indicates success, check for JSON-RPC errors + // hidden inside HTTP 200 responses. + var mcpResponse *mcp.ParsedMCPResponse + if outcome == OutcomeSuccess && a.config.ShouldDetectApplicationErrors() { + mcpResponse = a.detectApplicationError(rw) + if mcpResponse != nil && mcpResponse.HasError { + outcome = OutcomeApplicationError + } + } + // Check if we should audit this event if !a.config.ShouldAuditEvent(eventType) { return @@ -246,6 +286,20 @@ func (a *Auditor) logAuditEvent(r *http.Request, rw *responseWriter, requestData // Add metadata a.addMetadata(event, r, duration, rw) + // Attach JSON-RPC error details so operators can see the error code + // and message without enabling full response data capture. + if outcome == OutcomeApplicationError { + if event.Metadata.Extra == nil { + event.Metadata.Extra = make(map[string]any) + } + event.Metadata.Extra["jsonrpc_error_code"] = mcpResponse.ErrorCode + msg := mcpResponse.ErrorMessage + if len(msg) > maxAuditErrorMessageLength { + msg = msg[:maxAuditErrorMessageLength] + } + event.Metadata.Extra["jsonrpc_error_message"] = msg + } + // Add request/response data if configured a.addEventData(event, r, rw, requestData) @@ -321,6 +375,25 @@ func (*Auditor) determineOutcome(statusCode int) string { } } +// detectApplicationError inspects the captured response body prefix for a +// JSON-RPC error field. It reuses rw.body when IncludeResponseData is +// enabled to avoid double-buffering. +func (*Auditor) detectApplicationError(rw *responseWriter) *mcp.ParsedMCPResponse { + var prefix []byte + if rw.body != nil && rw.body.Len() > 0 { + prefix = rw.body.Bytes() + if len(prefix) > errorDetectionBufferSize { + prefix = prefix[:errorDetectionBufferSize] + } + } else if rw.errorDetectionBody != nil && rw.errorDetectionBody.Len() > 0 { + prefix = rw.errorDetectionBody.Bytes() + } + if len(prefix) > 0 && prefix[0] == '{' { + return mcp.ParseMCPResponse(prefix) + } + return nil +} + // extractSource extracts source information from the HTTP request. func (a *Auditor) extractSource(r *http.Request) EventSource { // Get the client IP address diff --git a/pkg/audit/auditor_test.go b/pkg/audit/auditor_test.go index 48f987fcd2..bee29b45b5 100644 --- a/pkg/audit/auditor_test.go +++ b/pkg/audit/auditor_test.go @@ -803,3 +803,202 @@ func TestExtractSourceWithHeaders(t *testing.T) { assert.Equal(t, "TestAgent/1.0", source.Extra[SourceExtraKeyUserAgent]) assert.Equal(t, "req-12345", source.Extra[SourceExtraKeyRequestID]) } + +func TestErrorDetectionBodyCapture(t *testing.T) { + t.Parallel() + + t.Run("captures prefix when DetectApplicationErrors is enabled", func(t *testing.T) { + t.Parallel() + detectErrors := true + config := &Config{ + DetectApplicationErrors: &detectErrors, + } + auditor, err := NewAuditorWithTransport(config, "streamable-http") + require.NoError(t, err) + + rw := &responseWriter{ + ResponseWriter: httptest.NewRecorder(), + statusCode: http.StatusOK, + auditor: auditor, + errorDetectionBody: &bytes.Buffer{}, + } + + responseData := `{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"test error"}}` + _, err = rw.Write([]byte(responseData)) + require.NoError(t, err) + + assert.Equal(t, responseData, rw.errorDetectionBody.String()) + }) + + t.Run("does not capture when DetectApplicationErrors is disabled", func(t *testing.T) { + t.Parallel() + detectErrors := false + config := &Config{ + DetectApplicationErrors: &detectErrors, + } + auditor, err := NewAuditorWithTransport(config, "streamable-http") + require.NoError(t, err) + + rw := &responseWriter{ + ResponseWriter: httptest.NewRecorder(), + statusCode: http.StatusOK, + auditor: auditor, + // errorDetectionBody is nil when detection is disabled + } + + _, err = rw.Write([]byte(`{"error":{"code":-32603}}`)) + require.NoError(t, err) + + assert.Nil(t, rw.errorDetectionBody) + }) + + t.Run("truncates capture at buffer size limit", func(t *testing.T) { + t.Parallel() + detectErrors := true + config := &Config{ + DetectApplicationErrors: &detectErrors, + } + auditor, err := NewAuditorWithTransport(config, "streamable-http") + require.NoError(t, err) + + rw := &responseWriter{ + ResponseWriter: httptest.NewRecorder(), + statusCode: http.StatusOK, + auditor: auditor, + errorDetectionBody: &bytes.Buffer{}, + } + + // Write more than errorDetectionBufferSize bytes + largeData := bytes.Repeat([]byte("x"), errorDetectionBufferSize+100) + _, err = rw.Write(largeData) + require.NoError(t, err) + + assert.Equal(t, errorDetectionBufferSize, rw.errorDetectionBody.Len()) + }) + + t.Run("captures independently of IncludeResponseData", func(t *testing.T) { + t.Parallel() + detectErrors := true + config := &Config{ + IncludeResponseData: false, + DetectApplicationErrors: &detectErrors, + } + auditor, err := NewAuditorWithTransport(config, "streamable-http") + require.NoError(t, err) + + rw := &responseWriter{ + ResponseWriter: httptest.NewRecorder(), + statusCode: http.StatusOK, + auditor: auditor, + errorDetectionBody: &bytes.Buffer{}, + // body is nil because IncludeResponseData is false + } + + responseData := `{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"unauthorized"}}` + _, err = rw.Write([]byte(responseData)) + require.NoError(t, err) + + // errorDetectionBody should capture even though body is nil + assert.Equal(t, responseData, rw.errorDetectionBody.String()) + assert.Nil(t, rw.body) + }) +} + +func TestMiddlewareDetectsJSONRPCErrors(t *testing.T) { + t.Parallel() + + t.Run("overrides outcome to application_error for JSON-RPC error response", func(t *testing.T) { + t.Parallel() + var logBuf bytes.Buffer + detectErrors := true + config := &Config{ + DetectApplicationErrors: &detectErrors, + } + auditor, err := NewAuditorWithTransport(config, "streamable-http") + require.NoError(t, err) + auditor.auditLogger = NewAuditLogger(&logBuf) + + errorResponse := `{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"GitLab API error: 401 Unauthorized"}}` + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(errorResponse)) + require.NoError(t, err) + }) + + middleware := auditor.Middleware(handler) + req := httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"test"}}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + middleware.ServeHTTP(rr, req) + + // The response should still be passed through unchanged + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, errorResponse, rr.Body.String()) + + // The audit log should contain application_error + logOutput := logBuf.String() + assert.Contains(t, logOutput, OutcomeApplicationError) + assert.Contains(t, logOutput, "jsonrpc_error_code") + }) + + t.Run("keeps outcome=success for valid JSON-RPC result", func(t *testing.T) { + t.Parallel() + var logBuf bytes.Buffer + detectErrors := true + config := &Config{ + DetectApplicationErrors: &detectErrors, + } + auditor, err := NewAuditorWithTransport(config, "streamable-http") + require.NoError(t, err) + auditor.auditLogger = NewAuditLogger(&logBuf) + + successResponse := `{"jsonrpc":"2.0","id":"1","result":{"content":[{"type":"text","text":"hello"}]}}` + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(successResponse)) + require.NoError(t, err) + }) + + middleware := auditor.Middleware(handler) + req := httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"test"}}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + middleware.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + logOutput := logBuf.String() + assert.NotContains(t, logOutput, OutcomeApplicationError) + }) + + t.Run("does not inspect body when DetectApplicationErrors is disabled", func(t *testing.T) { + t.Parallel() + var logBuf bytes.Buffer + detectErrors := false + config := &Config{ + DetectApplicationErrors: &detectErrors, + } + auditor, err := NewAuditorWithTransport(config, "streamable-http") + require.NoError(t, err) + auditor.auditLogger = NewAuditLogger(&logBuf) + + errorResponse := `{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"should not be detected"}}` + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(errorResponse)) + require.NoError(t, err) + }) + + middleware := auditor.Middleware(handler) + req := httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"test"}}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + middleware.ServeHTTP(rr, req) + + logOutput := logBuf.String() + assert.NotContains(t, logOutput, OutcomeApplicationError) + }) +} diff --git a/pkg/audit/config.go b/pkg/audit/config.go index 8887efa497..b7a7a0b728 100644 --- a/pkg/audit/config.go +++ b/pkg/audit/config.go @@ -40,6 +40,14 @@ type Config struct { // +kubebuilder:default=false // +optional IncludeResponseData bool `json:"includeResponseData,omitempty" yaml:"includeResponseData,omitempty"` + // DetectApplicationErrors controls whether the audit middleware inspects + // JSON-RPC response bodies for application-level errors when the HTTP + // status code indicates success (2xx). When enabled, a small prefix of + // the response body is buffered to detect JSON-RPC error fields, + // independent of the IncludeResponseData setting. + // +kubebuilder:default=true + // +optional + DetectApplicationErrors *bool `json:"detectApplicationErrors,omitempty" yaml:"detectApplicationErrors,omitempty"` // MaxDataSize limits the size of request/response data included in audit logs (in bytes). // +kubebuilder:default=1024 // +optional @@ -66,13 +74,24 @@ func (c *Config) GetLogWriter() (io.Writer, error) { // DefaultConfig returns a default audit configuration. func DefaultConfig() *Config { + detectErrors := true return &Config{ // Note, these defaults are also present on the kubebuilder annotations above. // If you change these defaults, you must also change the kubebuilder annotations. - IncludeRequestData: false, // Disabled by default for privacy - IncludeResponseData: false, // Disabled by default for privacy - MaxDataSize: 1024, // 1KB default limit + IncludeRequestData: false, // Disabled by default for privacy + IncludeResponseData: false, // Disabled by default for privacy + MaxDataSize: 1024, // 1KB default limit + DetectApplicationErrors: &detectErrors, // Enabled by default to surface JSON-RPC errors + } +} + +// ShouldDetectApplicationErrors returns whether JSON-RPC error detection is enabled. +// Defaults to true when DetectApplicationErrors is nil. +func (c *Config) ShouldDetectApplicationErrors() bool { + if c.DetectApplicationErrors == nil { + return true } + return *c.DetectApplicationErrors } // LoadFromFile loads audit configuration from a file. diff --git a/pkg/audit/event.go b/pkg/audit/event.go index 6589e2dcdb..b93aff1eea 100644 --- a/pkg/audit/event.go +++ b/pkg/audit/event.go @@ -210,6 +210,10 @@ const ( OutcomeError = "error" // OutcomeDenied indicates the event was denied (e.g., by authorization) OutcomeDenied = "denied" + // OutcomeApplicationError indicates the HTTP transport succeeded but the + // JSON-RPC response body contained an application-level error (e.g., + // expired tokens, backend failures, invalid parameters). + OutcomeApplicationError = "application_error" ) // Common source types diff --git a/pkg/mcp/response.go b/pkg/mcp/response.go new file mode 100644 index 0000000000..9a50b75161 --- /dev/null +++ b/pkg/mcp/response.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +import ( + "encoding/json" +) + +// ParsedMCPResponse contains the result of inspecting a JSON-RPC response +// body for application-level errors. Only the error-related fields are +// extracted; the full result payload is intentionally not captured to avoid +// duplicating the privacy-sensitive IncludeResponseData path. +type ParsedMCPResponse struct { + // HasError is true when the response body contains a top-level "error" field. + HasError bool + // ErrorCode is the JSON-RPC error code (e.g., -32603 for internal error). + ErrorCode int + // ErrorMessage is the raw error message from the JSON-RPC response. + ErrorMessage string +} + +// jsonRPCError mirrors the JSON-RPC 2.0 error object for decoding purposes. +// We use a minimal custom struct rather than jsonrpc2.DecodeMessage because +// the library's wireError type is unexported, making it impossible to extract +// the numeric error code from outside the package. +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// jsonRPCResponseEnvelope is the minimal structure needed to detect a +// JSON-RPC error in a response body. We intentionally omit "result" to +// keep the parse lightweight. +type jsonRPCResponseEnvelope struct { + Error *jsonRPCError `json:"error,omitempty"` +} + +// ParseMCPResponse inspects a response body and returns a ParsedMCPResponse +// indicating whether it contains a JSON-RPC error. The function is +// intentionally lenient: if the body is not valid JSON or does not contain +// an "error" field, it returns HasError=false rather than an error. +func ParseMCPResponse(body []byte) *ParsedMCPResponse { + if len(body) == 0 { + return &ParsedMCPResponse{} + } + + var envelope jsonRPCResponseEnvelope + if err := json.Unmarshal(body, &envelope); err != nil { + return &ParsedMCPResponse{} + } + + if envelope.Error == nil { + return &ParsedMCPResponse{} + } + + return &ParsedMCPResponse{ + HasError: true, + ErrorCode: envelope.Error.Code, + ErrorMessage: envelope.Error.Message, + } +} diff --git a/pkg/mcp/response_test.go b/pkg/mcp/response_test.go new file mode 100644 index 0000000000..aa963b9ed4 --- /dev/null +++ b/pkg/mcp/response_test.go @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseMCPResponse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + wantHasError bool + wantCode int + wantMessage string + }{ + { + name: "empty body", + body: "", + wantHasError: false, + }, + { + name: "success response with result", + body: `{"jsonrpc":"2.0","id":"1","result":{"content":[{"type":"text","text":"hello"}]}}`, + wantHasError: false, + }, + { + name: "error response with internal error", + body: `{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"GitLab API error: 401 Unauthorized"}}`, + wantHasError: true, + wantCode: -32603, + wantMessage: "GitLab API error: 401 Unauthorized", + }, + { + name: "error response with method not found", + body: `{"jsonrpc":"2.0","id":"1","error":{"code":-32601,"message":"Method not found"}}`, + wantHasError: true, + wantCode: -32601, + wantMessage: "Method not found", + }, + { + name: "error response with expired token", + body: `{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"GitLab API error: 401 Unauthorized\n{\"error\":\"invalid_token\",\"error_description\":\"Token is expired.\"}"}}`, + wantHasError: true, + wantCode: -32603, + }, + { + name: "invalid JSON", + body: `not json at all`, + wantHasError: false, + }, + { + name: "valid JSON without error field", + body: `{"jsonrpc":"2.0","id":"1"}`, + wantHasError: false, + }, + { + name: "long error message is preserved in full", + body: `{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"` + strings.Repeat("a", 300) + `"}}`, + wantHasError: true, + wantCode: -32603, + wantMessage: strings.Repeat("a", 300), + }, + { + name: "error with zero code", + body: `{"jsonrpc":"2.0","id":"1","error":{"code":0,"message":"unknown error"}}`, + wantHasError: true, + wantCode: 0, + wantMessage: "unknown error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := ParseMCPResponse([]byte(tt.body)) + assert.Equal(t, tt.wantHasError, result.HasError) + if tt.wantHasError { + assert.Equal(t, tt.wantCode, result.ErrorCode) + if tt.wantMessage != "" { + assert.Equal(t, tt.wantMessage, result.ErrorMessage) + } + } + }) + } +} From 98894b8dd3ae0280659a5df9bfa9d332efe8027b Mon Sep 17 00:00:00 2001 From: Trey <tgrunnagle@gmail.com> Date: Thu, 16 Apr 2026 11:13:17 -0700 Subject: [PATCH 064/117] Update operator architecture docs for authServerRef (#4780) RFC-0050 introduced `authServerRef` as a dedicated field for configuring the embedded OAuth 2.0/OIDC authorization server (incoming auth), separating it from `externalAuthConfigRef` (outgoing auth). After the CRD types and controller logic landed in #4640, the operator architecture documentation needed to reflect this new field, its relationship to existing auth references, and the combined configuration pattern. This PR updates `docs/arch/09-operator-architecture.md` to document these changes. --- docs/arch/09-operator-architecture.md | 37 ++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/arch/09-operator-architecture.md b/docs/arch/09-operator-architecture.md index 105567c9b4..510e6690e2 100644 --- a/docs/arch/09-operator-architecture.md +++ b/docs/arch/09-operator-architecture.md @@ -133,12 +133,14 @@ graph TB Server -->|groupRef| Group Server -.->|externalAuthConfigRef| ExtAuth + Server -.->|authServerRef| ExtAuth Server -.->|toolConfigRef| ToolCfg Server -.->|oidcConfigRef| OIDCCfg Server -.->|telemetryConfigRef| TelCfg Proxy -->|groupRef| Group Proxy -.->|externalAuthConfigRef| ExtAuth + Proxy -.->|authServerRef| ExtAuth Proxy -.->|toolConfigRef| ToolCfg Proxy -.->|oidcConfigRef| OIDCCfg Proxy -.->|telemetryConfigRef| TelCfg @@ -159,8 +161,12 @@ MCPServer resources support various transport types (stdio, SSE, streamable-http MCPServer supports referencing shared configuration CRDs: - `oidcConfigRef` — references an MCPOIDCConfig for shared OIDC settings - `telemetryConfigRef` — references an MCPTelemetryConfig for shared telemetry settings +- `externalAuthConfigRef` — references an MCPExternalAuthConfig for outgoing auth (token exchange, AWS STS, bearer token injection, etc.) +- `authServerRef` — references an MCPExternalAuthConfig of type `embeddedAuthServer` for incoming auth (the embedded OAuth 2.0/OIDC authorization server that authenticates MCP clients). This is the preferred path for configuring the embedded auth server, keeping incoming auth separate from `externalAuthConfigRef` which handles outgoing auth. -**Status fields** include phase (Ready, Pending, Failed, Terminating), the accessible URL, and config hashes (`oidcConfigHash`, `telemetryConfigHash`) for change detection on referenced CRDs. +**Backward compatibility**: Existing configurations using `externalAuthConfigRef` with `type: embeddedAuthServer` continue to work. The `authServerRef` field is optional and additive. + +**Status fields** include phase (Ready, Pending, Failed, Terminating), the accessible URL, and config hashes (`oidcConfigHash`, `telemetryConfigHash`, `authServerConfigHash`) for change detection on referenced CRDs. For examples, see: - [`examples/operator/mcp-servers/mcpserver_github.yaml`](../../examples/operator/mcp-servers/mcpserver_github.yaml) - Basic GitHub MCP server @@ -198,9 +204,13 @@ Manages external authentication configurations that can be shared across multipl **Implementation**: `cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go` -MCPExternalAuthConfig allows you to define reusable OIDC authentication configurations that can be referenced by multiple MCPServer resources. This is useful for sharing authentication settings across servers. When using the embedded auth server type, the `storage` field supports configuring Redis Sentinel as a shared storage backend for horizontal scaling. See [Auth Server Storage](11-auth-server-storage.md) for details. +MCPExternalAuthConfig allows you to define reusable authentication configurations that can be referenced by multiple MCPServer and MCPRemoteProxy resources. When using the embedded auth server type, the `storage` field supports configuring Redis Sentinel as a shared storage backend for horizontal scaling. See [Auth Server Storage](11-auth-server-storage.md) for details. + +MCPExternalAuthConfig resources can be referenced via two paths: +- `externalAuthConfigRef` — for outgoing auth types (token exchange, AWS STS, bearer token injection). This is the original reference path. +- `authServerRef` — for the embedded auth server type (`embeddedAuthServer`) only. This dedicated reference path makes it possible to configure both incoming auth (embedded auth server) and outgoing auth (e.g., AWS STS) on the same workload resource. -**Referenced by MCPServer** using `oidcConfig.type: external`. +**Referenced by MCPServer and MCPRemoteProxy** using `externalAuthConfigRef` or `authServerRef`. **Controller**: `cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go` @@ -257,12 +267,31 @@ Defines a proxy for remote MCP servers with authentication, authorization, audit **Key fields:** - `remoteUrl` - URL of the remote MCP server to proxy - `oidcConfigRef` - Reference to shared MCPOIDCConfig (with per-server `audience`, `scopes`, and `resourceUrl`) -- `externalAuthConfigRef` - Token exchange for remote service authentication +- `externalAuthConfigRef` - Outgoing auth for remote service authentication (token exchange, AWS STS, bearer token injection) +- `authServerRef` - Incoming auth via the embedded OAuth 2.0/OIDC authorization server (references an MCPExternalAuthConfig of type `embeddedAuthServer`) - `authzConfig` - Authorization policies +- `telemetryConfigRef` - Reference to shared MCPTelemetryConfig (replaces deprecated inline `telemetry`) - `toolConfigRef` - Tool filtering and renaming OIDC is optional — omit `oidcConfigRef` for unauthenticated proxies. +**Combined auth pattern**: `authServerRef` and `externalAuthConfigRef` can be used together on the same MCPRemoteProxy to enable both incoming client authentication (embedded auth server) and outgoing remote service authentication (e.g., AWS STS) simultaneously. This is the primary use case for `authServerRef` on MCPRemoteProxy. If both fields point to an `embeddedAuthServer` resource, the controller produces a validation error. + +```yaml +# MCPRemoteProxy with embedded auth server (incoming) + AWS STS (outgoing) +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPRemoteProxy +metadata: + name: bedrock-proxy +spec: + remoteUrl: https://bedrock-mcp.example.com + authServerRef: + kind: MCPExternalAuthConfig + name: my-auth-server # type: embeddedAuthServer + externalAuthConfigRef: + name: bedrock-sts-config # type: awsSts +``` + **Implementation**: `cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go` **Controller**: `cmd/thv-operator/controllers/mcpremoteproxy_controller.go` From 50044f3d3c7f60a315f51a547c3968580f19c78b Mon Sep 17 00:00:00 2001 From: Jakub Hrozek <jakub.hrozek@posteo.se> Date: Thu, 16 Apr 2026 21:05:15 +0100 Subject: [PATCH 065/117] Add AdditionalAuthorizationParams to upstream OAuth configs (#4862) * Add AdditionalAuthorizationParams to CommonOAuthConfig Google Drive MCP tokens can't be refreshed because Google requires access_type=offline as an authorization URL query parameter, but the provider config has no field for extra auth URL params. Add AdditionalAuthorizationParams map[string]string to CommonOAuthConfig with validation that rejects framework-managed params (response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, nonce). Config-level params are merged in buildAuthorizationURL() before caller opts so per-call and OIDC-specific params can override. Also fix OIDC provider construction to copy full CommonOAuthConfig instead of cherry-picking fields, ensuring new and future fields propagate to the embedded BaseOAuth2Provider. Ref: #4840 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add AdditionalAuthorizationParams to RunConfig types Add the field to both OIDCUpstreamRunConfig and OAuth2UpstreamRunConfig so the runner can thread config-level authorization params from the operator conversion layer through to the upstream provider config. Ref: #4840 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Thread AdditionalAuthorizationParams through runner Copy AdditionalAuthorizationParams from RunConfig into the upstream provider config in both buildOIDCConfig() and buildPureOAuth2Config() so that config-level authorization params reach the provider. Ref: #4840 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add AdditionalAuthorizationParams to CRD upstream configs Add the field to both OIDCUpstreamConfig and OAuth2UpstreamConfig in the MCPExternalAuthConfig CRD types. Capped at 16 entries via kubebuilder MaxProperties validation to prevent abuse. Ref: #4840 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Thread AdditionalAuthorizationParams through operator conversion Copy the field from CRD types to RunConfig types in buildUpstreamRunConfig() for both OIDC and OAuth2 branches. Ref: #4840 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Regenerate CRD artifacts for AdditionalAuthorizationParams Run operator-generate (deepcopy) and operator-manifests (CRD YAML) after adding the AdditionalAuthorizationParams field to CRD types. Ref: #4840 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Validate reserved params at admission time Reserved OAuth2 parameters (response_type, client_id, etc.) in additionalAuthorizationParams were only rejected at auth-server startup, allowing invalid CRs to be admitted and fail later at runtime. Add early validation in the MCPExternalAuthConfig webhook and VirtualMCPServer controller so bad specs are rejected at admission time. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Document scope interaction with access_type parameter Providers like Google use access_type=offline for refresh tokens instead of the standard offline_access scope. Since the OIDC runner defaults to ["openid", "offline_access"], users combining additionalAuthorizationParams with access_type should set explicit scopes to avoid redundancy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Extract shared oauthparams package for reserved param validation The reserved OAuth2 authorization parameter list and validation logic were duplicated in pkg/authserver/upstream and the CRD types package. Extract into pkg/authserver/oauthparams (a leaf package with no transitive dependencies) so both sites share a single source of truth. Also add an AdditionalAuthorizationParams() accessor on UpstreamProviderConfig to eliminate the repeated if/else extraction chain in the webhook and controller validation paths. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Regenerate Swagger docs for AdditionalAuthorizationParams Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Regenerate CRD manifests for scope interaction docs The doc comment updates from "Document scope interaction with access_type parameter" were not reflected in the generated CRD YAML. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../v1alpha1/mcpexternalauthconfig_types.go | 49 ++++++ .../mcpexternalauthconfig_types_test.go | 67 +++++++ .../api/v1alpha1/zz_generated.deepcopy.go | 14 ++ .../virtualmcpserver_controller.go | 18 ++ .../pkg/controllerutil/authserver.go | 20 ++- .../pkg/controllerutil/authserver_test.go | 74 ++++++++ ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 29 +++ ...olhive.stacklok.dev_virtualmcpservers.yaml | 29 +++ ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 29 +++ ...olhive.stacklok.dev_virtualmcpservers.yaml | 29 +++ docs/operator/crd-api.md | 4 +- docs/server/docs.go | 16 +- docs/server/swagger.json | 16 +- docs/server/swagger.yaml | 19 ++ pkg/authserver/config.go | 15 ++ pkg/authserver/oauthparams/reserved.go | 33 ++++ pkg/authserver/runner/embeddedauthserver.go | 23 ++- .../runner/embeddedauthserver_test.go | 47 +++++ pkg/authserver/upstream/oauth2.go | 16 ++ pkg/authserver/upstream/oauth2_test.go | 165 ++++++++++++++++++ pkg/authserver/upstream/oidc.go | 13 +- pkg/authserver/upstream/oidc_test.go | 37 +++- 22 files changed, 734 insertions(+), 28 deletions(-) create mode 100644 pkg/authserver/oauthparams/reserved.go diff --git a/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go index acf7dd0ee4..1dcdbf1e47 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go @@ -7,6 +7,8 @@ import ( "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stacklok/toolhive/pkg/authserver/oauthparams" ) // External auth configuration types @@ -322,6 +324,9 @@ type OIDCUpstreamConfig struct { // Scopes are the OAuth scopes to request from the upstream IDP. // If not specified, defaults to ["openid", "offline_access"]. + // When using additionalAuthorizationParams with provider-specific refresh token + // mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid + // sending both offline_access and the provider-specific parameter. // +listType=atomic // +optional Scopes []string `json:"scopes,omitempty"` @@ -332,6 +337,18 @@ type OIDCUpstreamConfig struct { // that return non-standard claim names in their UserInfo response. // +optional UserInfoOverride *UserInfoConfig `json:"userInfoOverride,omitempty"` + + // AdditionalAuthorizationParams are extra query parameters to include in + // authorization requests sent to the upstream provider. + // This is useful for providers that require custom parameters, such as + // Google's access_type=offline for obtaining refresh tokens. + // Note: when using access_type=offline, also set explicit scopes to avoid + // the default offline_access scope being sent alongside it. + // Framework-managed parameters (response_type, client_id, redirect_uri, + // scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + // +kubebuilder:validation:MaxProperties=16 + // +optional + AdditionalAuthorizationParams map[string]string `json:"additionalAuthorizationParams,omitempty"` } // OAuth2UpstreamConfig contains configuration for pure OAuth 2.0 providers. @@ -379,6 +396,16 @@ type OAuth2UpstreamConfig struct { // If nil, standard OAuth 2.0 token response parsing is used. // +optional TokenResponseMapping *TokenResponseMapping `json:"tokenResponseMapping,omitempty"` + + // AdditionalAuthorizationParams are extra query parameters to include in + // authorization requests sent to the upstream provider. + // This is useful for providers that require custom parameters, such as + // Google's access_type=offline for obtaining refresh tokens. + // Framework-managed parameters (response_type, client_id, redirect_uri, + // scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + // +kubebuilder:validation:MaxProperties=16 + // +optional + AdditionalAuthorizationParams map[string]string `json:"additionalAuthorizationParams,omitempty"` } // TokenResponseMapping maps non-standard token response fields to standard OAuth 2.0 fields @@ -887,6 +914,28 @@ func (*MCPExternalAuthConfig) validateUpstreamProvider(index int, provider *Upst return fmt.Errorf("%s: unsupported provider type: %s", prefix, provider.Type) } + // Validate additionalAuthorizationParams does not contain reserved keys + return ValidateAdditionalAuthorizationParams(prefix, provider.AdditionalAuthorizationParams()) +} + +// AdditionalAuthorizationParams returns the additional authorization parameters +// from whichever upstream config is set, or nil if none. +func (p *UpstreamProviderConfig) AdditionalAuthorizationParams() map[string]string { + if p.OIDCConfig != nil { + return p.OIDCConfig.AdditionalAuthorizationParams + } + if p.OAuth2Config != nil { + return p.OAuth2Config.AdditionalAuthorizationParams + } + return nil +} + +// ValidateAdditionalAuthorizationParams checks that no reserved OAuth2 parameters +// are present in the additional authorization params map. +func ValidateAdditionalAuthorizationParams(prefix string, params map[string]string) error { + if err := oauthparams.Validate(params); err != nil { + return fmt.Errorf("%s.additionalAuthorizationParams: %w", prefix, err) + } return nil } diff --git a/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types_test.go b/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types_test.go index a3d6be0809..07b0cdc8fa 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types_test.go +++ b/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types_test.go @@ -504,6 +504,73 @@ func TestMCPExternalAuthConfig_validateUpstreamProvider(t *testing.T) { expectErr: true, errMsg: "oidcConfig must be set when type is 'oidc' and must not be set otherwise", }, + { + name: "OIDC provider with valid additionalAuthorizationParams", + provider: UpstreamProviderConfig{ + Name: "google", + Type: UpstreamProviderTypeOIDC, + OIDCConfig: &OIDCUpstreamConfig{ + IssuerURL: "https://accounts.google.com", + ClientID: "client-id", + AdditionalAuthorizationParams: map[string]string{ + "access_type": "offline", + "prompt": "consent", + }, + }, + }, + expectErr: false, + }, + { + name: "OIDC provider with reserved param client_id", + provider: UpstreamProviderConfig{ + Name: "google", + Type: UpstreamProviderTypeOIDC, + OIDCConfig: &OIDCUpstreamConfig{ + IssuerURL: "https://accounts.google.com", + ClientID: "client-id", + AdditionalAuthorizationParams: map[string]string{ + "client_id": "override-attempt", + }, + }, + }, + expectErr: true, + errMsg: "reserved parameter \"client_id\" is managed by the framework", + }, + { + name: "OAuth2 provider with reserved param response_type", + provider: UpstreamProviderConfig{ + Name: "custom", + Type: UpstreamProviderTypeOAuth2, + OAuth2Config: &OAuth2UpstreamConfig{ + AuthorizationEndpoint: "https://oauth.example.com/authorize", + TokenEndpoint: "https://oauth.example.com/token", + ClientID: "client-id", + UserInfo: &UserInfoConfig{EndpointURL: "https://oauth.example.com/userinfo"}, + AdditionalAuthorizationParams: map[string]string{ + "response_type": "token", + }, + }, + }, + expectErr: true, + errMsg: "reserved parameter \"response_type\" is managed by the framework", + }, + { + name: "OAuth2 provider with valid additionalAuthorizationParams", + provider: UpstreamProviderConfig{ + Name: "github", + Type: UpstreamProviderTypeOAuth2, + OAuth2Config: &OAuth2UpstreamConfig{ + AuthorizationEndpoint: "https://github.com/login/oauth/authorize", + TokenEndpoint: "https://github.com/login/oauth/access_token", + ClientID: "client-id", + UserInfo: &UserInfoConfig{EndpointURL: "https://api.github.com/user"}, + AdditionalAuthorizationParams: map[string]string{ + "allow_signup": "false", + }, + }, + }, + expectErr: false, + }, } for _, tt := range tests { diff --git a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go index f52c305e52..eee5a0a73b 100644 --- a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -1957,6 +1957,13 @@ func (in *OAuth2UpstreamConfig) DeepCopyInto(out *OAuth2UpstreamConfig) { *out = new(TokenResponseMapping) **out = **in } + if in.AdditionalAuthorizationParams != nil { + in, out := &in.AdditionalAuthorizationParams, &out.AdditionalAuthorizationParams + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OAuth2UpstreamConfig. @@ -1987,6 +1994,13 @@ func (in *OIDCUpstreamConfig) DeepCopyInto(out *OIDCUpstreamConfig) { *out = new(UserInfoConfig) (*in).DeepCopyInto(*out) } + if in.AdditionalAuthorizationParams != nil { + in, out := &in.AdditionalAuthorizationParams, &out.AdditionalAuthorizationParams + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OIDCUpstreamConfig. diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index c8d65b25f3..5070f3c7ed 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -439,6 +439,24 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( return fmt.Errorf("%s", message) } + // Validate additionalAuthorizationParams on each upstream provider + for i := range cfg.UpstreamProviders { + prefix := fmt.Sprintf("spec.authServerConfig.upstreamProviders[%d]", i) + params := cfg.UpstreamProviders[i].AdditionalAuthorizationParams() + if err := mcpv1alpha1.ValidateAdditionalAuthorizationParams(prefix, params); err != nil { + message := err.Error() + statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed) + statusManager.SetMessage(message) + statusManager.SetAuthServerConfigValidatedCondition( + mcpv1alpha1.ConditionReasonAuthServerConfigInvalid, + message, + metav1.ConditionFalse, + ) + statusManager.SetObservedGeneration(vmcp.Generation) + return fmt.Errorf("%s", message) + } + } + // AuthServerConfig is valid statusManager.SetAuthServerConfigValidatedCondition( mcpv1alpha1.ConditionReasonAuthServerConfigValid, diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go index e3057082b4..cf8fbf4207 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver.go @@ -616,10 +616,11 @@ func buildUpstreamRunConfig( case mcpv1alpha1.UpstreamProviderTypeOIDC: if provider.OIDCConfig != nil { config.OIDCConfig = &authserver.OIDCUpstreamRunConfig{ - IssuerURL: provider.OIDCConfig.IssuerURL, - ClientID: provider.OIDCConfig.ClientID, - RedirectURI: provider.OIDCConfig.RedirectURI, - Scopes: provider.OIDCConfig.Scopes, + IssuerURL: provider.OIDCConfig.IssuerURL, + ClientID: provider.OIDCConfig.ClientID, + RedirectURI: provider.OIDCConfig.RedirectURI, + Scopes: provider.OIDCConfig.Scopes, + AdditionalAuthorizationParams: provider.OIDCConfig.AdditionalAuthorizationParams, } // If client secret is configured, reference it via env var if provider.OIDCConfig.ClientSecretRef != nil { @@ -632,11 +633,12 @@ func buildUpstreamRunConfig( case mcpv1alpha1.UpstreamProviderTypeOAuth2: if provider.OAuth2Config != nil { config.OAuth2Config = &authserver.OAuth2UpstreamRunConfig{ - AuthorizationEndpoint: provider.OAuth2Config.AuthorizationEndpoint, - TokenEndpoint: provider.OAuth2Config.TokenEndpoint, - ClientID: provider.OAuth2Config.ClientID, - RedirectURI: provider.OAuth2Config.RedirectURI, - Scopes: provider.OAuth2Config.Scopes, + AuthorizationEndpoint: provider.OAuth2Config.AuthorizationEndpoint, + TokenEndpoint: provider.OAuth2Config.TokenEndpoint, + ClientID: provider.OAuth2Config.ClientID, + RedirectURI: provider.OAuth2Config.RedirectURI, + Scopes: provider.OAuth2Config.Scopes, + AdditionalAuthorizationParams: provider.OAuth2Config.AdditionalAuthorizationParams, } // If client secret is configured, reference it via env var if provider.OAuth2Config.ClientSecretRef != nil { diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_test.go index 07d0b53616..1c59a7741d 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go @@ -967,6 +967,80 @@ func TestBuildAuthServerRunConfig(t *testing.T) { assert.Equal(t, UpstreamClientSecretEnvVar+"_GITHUB", github.OAuth2Config.ClientSecretEnvVar) }, }, + { + name: "OIDC upstream propagates AdditionalAuthorizationParams", + authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + { + Name: "okta", + Type: mcpv1alpha1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + IssuerURL: "https://okta.example.com", + ClientID: "okta-client-id", + RedirectURI: "https://auth.example.com/callback", + Scopes: []string{"openid", "profile"}, + AdditionalAuthorizationParams: map[string]string{ + "access_type": "offline", + }, + }, + }, + }, + }, + allowedAudiences: defaultAudiences, + scopesSupported: defaultScopes, + checkFunc: func(t *testing.T, config *authserver.RunConfig) { + t.Helper() + require.Len(t, config.Upstreams, 1) + upstream := config.Upstreams[0] + require.NotNil(t, upstream.OIDCConfig) + assert.Equal(t, map[string]string{"access_type": "offline"}, + upstream.OIDCConfig.AdditionalAuthorizationParams) + }, + }, + { + name: "OAuth2 upstream propagates AdditionalAuthorizationParams", + authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + { + Name: "github", + Type: mcpv1alpha1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1alpha1.OAuth2UpstreamConfig{ + AuthorizationEndpoint: "https://github.com/login/oauth/authorize", + TokenEndpoint: "https://github.com/login/oauth/access_token", + ClientID: "github-client-id", + RedirectURI: "https://auth.example.com/callback", + AdditionalAuthorizationParams: map[string]string{ + "access_type": "offline", + }, + }, + }, + }, + }, + allowedAudiences: defaultAudiences, + scopesSupported: defaultScopes, + checkFunc: func(t *testing.T, config *authserver.RunConfig) { + t.Helper() + require.Len(t, config.Upstreams, 1) + upstream := config.Upstreams[0] + require.NotNil(t, upstream.OAuth2Config) + assert.Equal(t, map[string]string{"access_type": "offline"}, + upstream.OAuth2Config.AdditionalAuthorizationParams) + }, + }, } for _, tt := range tests { diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 4f80856ae6..be7e9a3dd0 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -479,6 +479,18 @@ spec: OAuth2Config contains OAuth 2.0-specific configuration. Required when Type is "oauth2", must be nil when Type is "oidc". properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object authorizationEndpoint: description: AuthorizationEndpoint is the URL for the OAuth authorization endpoint. @@ -625,6 +637,20 @@ spec: OIDCConfig contains OIDC-specific configuration. Required when Type is "oidc", must be nil when Type is "oauth2". properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Note: when using access_type=offline, also set explicit scopes to avoid + the default offline_access scope being sent alongside it. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object clientId: description: ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -660,6 +686,9 @@ spec: description: |- Scopes are the OAuth scopes to request from the upstream IDP. If not specified, defaults to ["openid", "offline_access"]. + When using additionalAuthorizationParams with provider-specific refresh token + mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid + sending both offline_access and the provider-specific parameter. items: type: string type: array diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 86f25d9e6c..d9d0552db2 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -364,6 +364,18 @@ spec: OAuth2Config contains OAuth 2.0-specific configuration. Required when Type is "oauth2", must be nil when Type is "oidc". properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object authorizationEndpoint: description: AuthorizationEndpoint is the URL for the OAuth authorization endpoint. @@ -510,6 +522,20 @@ spec: OIDCConfig contains OIDC-specific configuration. Required when Type is "oidc", must be nil when Type is "oauth2". properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Note: when using access_type=offline, also set explicit scopes to avoid + the default offline_access scope being sent alongside it. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object clientId: description: ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -545,6 +571,9 @@ spec: description: |- Scopes are the OAuth scopes to request from the upstream IDP. If not specified, defaults to ["openid", "offline_access"]. + When using additionalAuthorizationParams with provider-specific refresh token + mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid + sending both offline_access and the provider-specific parameter. items: type: string type: array diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 61a556bb59..e74b98d8ee 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -482,6 +482,18 @@ spec: OAuth2Config contains OAuth 2.0-specific configuration. Required when Type is "oauth2", must be nil when Type is "oidc". properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object authorizationEndpoint: description: AuthorizationEndpoint is the URL for the OAuth authorization endpoint. @@ -628,6 +640,20 @@ spec: OIDCConfig contains OIDC-specific configuration. Required when Type is "oidc", must be nil when Type is "oauth2". properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Note: when using access_type=offline, also set explicit scopes to avoid + the default offline_access scope being sent alongside it. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object clientId: description: ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -663,6 +689,9 @@ spec: description: |- Scopes are the OAuth scopes to request from the upstream IDP. If not specified, defaults to ["openid", "offline_access"]. + When using additionalAuthorizationParams with provider-specific refresh token + mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid + sending both offline_access and the provider-specific parameter. items: type: string type: array diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 087b640b78..1c075ae8a5 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -367,6 +367,18 @@ spec: OAuth2Config contains OAuth 2.0-specific configuration. Required when Type is "oauth2", must be nil when Type is "oidc". properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object authorizationEndpoint: description: AuthorizationEndpoint is the URL for the OAuth authorization endpoint. @@ -513,6 +525,20 @@ spec: OIDCConfig contains OIDC-specific configuration. Required when Type is "oidc", must be nil when Type is "oauth2". properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Note: when using access_type=offline, also set explicit scopes to avoid + the default offline_access scope being sent alongside it. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object clientId: description: ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -548,6 +574,9 @@ spec: description: |- Scopes are the OAuth scopes to request from the upstream IDP. If not specified, defaults to ["openid", "offline_access"]. + When using additionalAuthorizationParams with provider-specific refresh token + mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid + sending both offline_access and the provider-specific parameter. items: type: string type: array diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 4f7f2f72ba..d514fa4e33 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -2405,6 +2405,7 @@ _Appears in:_ | `redirectUri` _string_ | RedirectURI is the callback URL where the upstream IDP will redirect after authentication.<br />When not specified, defaults to `\{resourceUrl\}/oauth/callback` where `resourceUrl` is the<br />URL associated with the resource (e.g., MCPServer or vMCP) using this config. | | Optional: \{\} <br /> | | `scopes` _string array_ | Scopes are the OAuth scopes to request from the upstream IDP. | | Optional: \{\} <br /> | | `tokenResponseMapping` _[api.v1alpha1.TokenResponseMapping](#apiv1alpha1tokenresponsemapping)_ | TokenResponseMapping configures custom field extraction from non-standard token responses.<br />Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths<br />instead of returning them at the top level. When set, ToolHive performs the token<br />exchange HTTP call directly and extracts fields using the configured dot-notation paths.<br />If nil, standard OAuth 2.0 token response parsing is used. | | Optional: \{\} <br /> | +| `additionalAuthorizationParams` _object (keys:string, values:string)_ | AdditionalAuthorizationParams are extra query parameters to include in<br />authorization requests sent to the upstream provider.<br />This is useful for providers that require custom parameters, such as<br />Google's access_type=offline for obtaining refresh tokens.<br />Framework-managed parameters (response_type, client_id, redirect_uri,<br />scope, state, code_challenge, code_challenge_method, nonce) are not allowed. | | MaxProperties: 16 <br />Optional: \{\} <br /> | #### api.v1alpha1.OIDCUpstreamConfig @@ -2425,8 +2426,9 @@ _Appears in:_ | `clientId` _string_ | ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. | | Required: \{\} <br /> | | `clientSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret.<br />Optional for public clients using PKCE instead of client secret. | | Optional: \{\} <br /> | | `redirectUri` _string_ | RedirectURI is the callback URL where the upstream IDP will redirect after authentication.<br />When not specified, defaults to `\{resourceUrl\}/oauth/callback` where `resourceUrl` is the<br />URL associated with the resource (e.g., MCPServer or vMCP) using this config. | | Optional: \{\} <br /> | -| `scopes` _string array_ | Scopes are the OAuth scopes to request from the upstream IDP.<br />If not specified, defaults to ["openid", "offline_access"]. | | Optional: \{\} <br /> | +| `scopes` _string array_ | Scopes are the OAuth scopes to request from the upstream IDP.<br />If not specified, defaults to ["openid", "offline_access"].<br />When using additionalAuthorizationParams with provider-specific refresh token<br />mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid<br />sending both offline_access and the provider-specific parameter. | | Optional: \{\} <br /> | | `userInfoOverride` _[api.v1alpha1.UserInfoConfig](#apiv1alpha1userinfoconfig)_ | UserInfoOverride allows customizing UserInfo fetching behavior for OIDC providers.<br />By default, the UserInfo endpoint is discovered automatically via OIDC discovery.<br />Use this to override the endpoint URL, HTTP method, or field mappings for providers<br />that return non-standard claim names in their UserInfo response. | | Optional: \{\} <br /> | +| `additionalAuthorizationParams` _object (keys:string, values:string)_ | AdditionalAuthorizationParams are extra query parameters to include in<br />authorization requests sent to the upstream provider.<br />This is useful for providers that require custom parameters, such as<br />Google's access_type=offline for obtaining refresh tokens.<br />Note: when using access_type=offline, also set explicit scopes to avoid<br />the default offline_access scope being sent alongside it.<br />Framework-managed parameters (response_type, client_id, redirect_uri,<br />scope, state, code_challenge, code_challenge_method, nonce) are not allowed. | | MaxProperties: 16 <br />Optional: \{\} <br /> | #### api.v1alpha1.OpenTelemetryMetricsConfig diff --git a/docs/server/docs.go b/docs/server/docs.go index 6dce85e76c..88e07fcd08 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -410,6 +410,13 @@ const docTemplate = `{ "github_com_stacklok_toolhive_pkg_authserver.OAuth2UpstreamRunConfig": { "description": "OAuth2Config contains OAuth 2.0-specific configuration.\nRequired when Type is \"oauth2\", must be nil when Type is \"oidc\".", "properties": { + "additional_authorization_params": { + "additionalProperties": { + "type": "string" + }, + "description": "AdditionalAuthorizationParams are extra query parameters to include in\nauthorization requests. Useful for provider-specific parameters like\nGoogle's access_type=offline.", + "type": "object" + }, "authorization_endpoint": { "description": "AuthorizationEndpoint is the URL for the OAuth authorization endpoint.", "type": "string" @@ -454,6 +461,13 @@ const docTemplate = `{ "github_com_stacklok_toolhive_pkg_authserver.OIDCUpstreamRunConfig": { "description": "OIDCConfig contains OIDC-specific configuration.\nRequired when Type is \"oidc\", must be nil when Type is \"oauth2\".", "properties": { + "additional_authorization_params": { + "additionalProperties": { + "type": "string" + }, + "description": "AdditionalAuthorizationParams are extra query parameters to include in\nauthorization requests. Useful for provider-specific parameters like\nGoogle's access_type=offline.", + "type": "object" + }, "client_id": { "description": "ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.", "type": "string" @@ -475,7 +489,7 @@ const docTemplate = `{ "type": "string" }, "scopes": { - "description": "Scopes are the OAuth scopes to request from the upstream IDP.\nIf not specified, defaults to [\"openid\", \"offline_access\"].", + "description": "Scopes are the OAuth scopes to request from the upstream IDP.\nIf not specified, defaults to [\"openid\", \"offline_access\"].\nWhen using AdditionalAuthorizationParams with provider-specific refresh\ntoken mechanisms (e.g., Google's access_type=offline), set explicit scopes\nto avoid sending both offline_access and the provider-specific parameter.", "items": { "type": "string" }, diff --git a/docs/server/swagger.json b/docs/server/swagger.json index cc51703a91..be4022ac58 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -403,6 +403,13 @@ "github_com_stacklok_toolhive_pkg_authserver.OAuth2UpstreamRunConfig": { "description": "OAuth2Config contains OAuth 2.0-specific configuration.\nRequired when Type is \"oauth2\", must be nil when Type is \"oidc\".", "properties": { + "additional_authorization_params": { + "additionalProperties": { + "type": "string" + }, + "description": "AdditionalAuthorizationParams are extra query parameters to include in\nauthorization requests. Useful for provider-specific parameters like\nGoogle's access_type=offline.", + "type": "object" + }, "authorization_endpoint": { "description": "AuthorizationEndpoint is the URL for the OAuth authorization endpoint.", "type": "string" @@ -447,6 +454,13 @@ "github_com_stacklok_toolhive_pkg_authserver.OIDCUpstreamRunConfig": { "description": "OIDCConfig contains OIDC-specific configuration.\nRequired when Type is \"oidc\", must be nil when Type is \"oauth2\".", "properties": { + "additional_authorization_params": { + "additionalProperties": { + "type": "string" + }, + "description": "AdditionalAuthorizationParams are extra query parameters to include in\nauthorization requests. Useful for provider-specific parameters like\nGoogle's access_type=offline.", + "type": "object" + }, "client_id": { "description": "ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.", "type": "string" @@ -468,7 +482,7 @@ "type": "string" }, "scopes": { - "description": "Scopes are the OAuth scopes to request from the upstream IDP.\nIf not specified, defaults to [\"openid\", \"offline_access\"].", + "description": "Scopes are the OAuth scopes to request from the upstream IDP.\nIf not specified, defaults to [\"openid\", \"offline_access\"].\nWhen using AdditionalAuthorizationParams with provider-specific refresh\ntoken mechanisms (e.g., Google's access_type=offline), set explicit scopes\nto avoid sending both offline_access and the provider-specific parameter.", "items": { "type": "string" }, diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 7881799fe9..5fa8ca2e2c 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -404,6 +404,14 @@ components: OAuth2Config contains OAuth 2.0-specific configuration. Required when Type is "oauth2", must be nil when Type is "oidc". properties: + additional_authorization_params: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests. Useful for provider-specific parameters like + Google's access_type=offline. + type: object authorization_endpoint: description: AuthorizationEndpoint is the URL for the OAuth authorization endpoint. @@ -446,6 +454,14 @@ components: OIDCConfig contains OIDC-specific configuration. Required when Type is "oidc", must be nil when Type is "oauth2". properties: + additional_authorization_params: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests. Useful for provider-specific parameters like + Google's access_type=offline. + type: object client_id: description: ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -474,6 +490,9 @@ components: description: |- Scopes are the OAuth scopes to request from the upstream IDP. If not specified, defaults to ["openid", "offline_access"]. + When using AdditionalAuthorizationParams with provider-specific refresh + token mechanisms (e.g., Google's access_type=offline), set explicit scopes + to avoid sending both offline_access and the provider-specific parameter. items: type: string type: array diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index a19a1cc45b..a4a3eb3ada 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -185,11 +185,20 @@ type OIDCUpstreamRunConfig struct { // Scopes are the OAuth scopes to request from the upstream IDP. // If not specified, defaults to ["openid", "offline_access"]. + // When using AdditionalAuthorizationParams with provider-specific refresh + // token mechanisms (e.g., Google's access_type=offline), set explicit scopes + // to avoid sending both offline_access and the provider-specific parameter. Scopes []string `json:"scopes,omitempty" yaml:"scopes,omitempty"` // UserInfoOverride allows customizing UserInfo fetching behavior for OIDC providers. // By default, the UserInfo endpoint is discovered automatically via OIDC discovery. UserInfoOverride *UserInfoRunConfig `json:"userinfo_override,omitempty" yaml:"userinfo_override,omitempty"` + + // AdditionalAuthorizationParams are extra query parameters to include in + // authorization requests. Useful for provider-specific parameters like + // Google's access_type=offline. + //nolint:lll // field tags require full JSON+YAML names + AdditionalAuthorizationParams map[string]string `json:"additional_authorization_params,omitempty" yaml:"additional_authorization_params,omitempty"` } // OAuth2UpstreamRunConfig contains configuration for pure OAuth 2.0 providers. @@ -227,6 +236,12 @@ type OAuth2UpstreamRunConfig struct { // the configured dot-notation paths. //nolint:lll // field tags require full JSON+YAML names TokenResponseMapping *TokenResponseMappingRunConfig `json:"token_response_mapping,omitempty" yaml:"token_response_mapping,omitempty"` + + // AdditionalAuthorizationParams are extra query parameters to include in + // authorization requests. Useful for provider-specific parameters like + // Google's access_type=offline. + //nolint:lll // field tags require full JSON+YAML names + AdditionalAuthorizationParams map[string]string `json:"additional_authorization_params,omitempty" yaml:"additional_authorization_params,omitempty"` } // TokenResponseMappingRunConfig maps non-standard token response fields to standard fields. diff --git a/pkg/authserver/oauthparams/reserved.go b/pkg/authserver/oauthparams/reserved.go new file mode 100644 index 0000000000..fd8ffbf7c7 --- /dev/null +++ b/pkg/authserver/oauthparams/reserved.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package oauthparams provides shared definitions for reserved OAuth2 +// authorization parameters that are managed by the framework. +package oauthparams + +import "fmt" + +// ReservedAuthorizationParams are OAuth2 parameters managed by the framework +// that must not be set via AdditionalAuthorizationParams. +var ReservedAuthorizationParams = map[string]bool{ + "response_type": true, + "client_id": true, + "redirect_uri": true, + "scope": true, + "state": true, + "code_challenge": true, + "code_challenge_method": true, + "nonce": true, +} + +// Validate checks that no key in params is a reserved OAuth2 authorization +// parameter. Reserved parameters are managed by the framework and cannot be +// overridden via additional authorization params. +func Validate(params map[string]string) error { + for k := range params { + if ReservedAuthorizationParams[k] { + return fmt.Errorf("reserved parameter %q is managed by the framework and cannot be overridden", k) + } + } + return nil +} diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go index 7cdb4d7eee..5b9ac8e114 100644 --- a/pkg/authserver/runner/embeddedauthserver.go +++ b/pkg/authserver/runner/embeddedauthserver.go @@ -344,7 +344,10 @@ func buildOIDCConfig(rc *authserver.UpstreamRunConfig) (*upstream.OIDCConfig, er return nil, fmt.Errorf("failed to resolve OIDC client secret: %w", err) } - // Default scopes if not specified + // Default scopes if not specified. The default includes offline_access + // (standard OIDC mechanism for refresh tokens). Providers like Google that + // use access_type=offline instead should specify explicit scopes in their + // config to avoid sending both mechanisms. scopes := oidc.Scopes if len(scopes) == 0 { scopes = []string{"openid", "offline_access"} @@ -352,10 +355,11 @@ func buildOIDCConfig(rc *authserver.UpstreamRunConfig) (*upstream.OIDCConfig, er return &upstream.OIDCConfig{ CommonOAuthConfig: upstream.CommonOAuthConfig{ - ClientID: oidc.ClientID, - ClientSecret: clientSecret, - RedirectURI: oidc.RedirectURI, - Scopes: scopes, + ClientID: oidc.ClientID, + ClientSecret: clientSecret, + RedirectURI: oidc.RedirectURI, + Scopes: scopes, + AdditionalAuthorizationParams: oidc.AdditionalAuthorizationParams, }, Issuer: oidc.IssuerURL, }, nil @@ -375,10 +379,11 @@ func buildPureOAuth2Config(rc *authserver.UpstreamRunConfig) (*upstream.OAuth2Co cfg := &upstream.OAuth2Config{ CommonOAuthConfig: upstream.CommonOAuthConfig{ - ClientID: oauth2.ClientID, - ClientSecret: clientSecret, - RedirectURI: oauth2.RedirectURI, - Scopes: oauth2.Scopes, + ClientID: oauth2.ClientID, + ClientSecret: clientSecret, + RedirectURI: oauth2.RedirectURI, + Scopes: oauth2.Scopes, + AdditionalAuthorizationParams: oauth2.AdditionalAuthorizationParams, }, AuthorizationEndpoint: oauth2.AuthorizationEndpoint, TokenEndpoint: oauth2.TokenEndpoint, diff --git a/pkg/authserver/runner/embeddedauthserver_test.go b/pkg/authserver/runner/embeddedauthserver_test.go index 154fd5eb15..48aab6144c 100644 --- a/pkg/authserver/runner/embeddedauthserver_test.go +++ b/pkg/authserver/runner/embeddedauthserver_test.go @@ -497,6 +497,30 @@ func TestBuildPureOAuth2Config(t *testing.T) { require.NotNil(t, cfg.UserInfo) assert.Equal(t, "https://example.com/userinfo", cfg.UserInfo.EndpointURL) }) + + t.Run("propagates AdditionalAuthorizationParams", func(t *testing.T) { + t.Parallel() + + rc := &authserver.UpstreamRunConfig{ + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://example.com/authorize", + TokenEndpoint: "https://example.com/token", + ClientID: "my-client-id", + RedirectURI: "https://my-app.com/callback", + AdditionalAuthorizationParams: map[string]string{ + "access_type": "offline", + }, + }, + } + + cfg, err := buildPureOAuth2Config(rc) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, map[string]string{"access_type": "offline"}, + cfg.AdditionalAuthorizationParams) + }) } // TestBuildPureOAuth2ConfigWithEnvVar tests buildPureOAuth2Config with environment variables. @@ -919,6 +943,29 @@ func TestBuildOIDCConfig(t *testing.T) { assert.Equal(t, "https://example.com", cfg.Issuer) assert.Equal(t, "test-client-id", cfg.ClientID) }) + + t.Run("propagates AdditionalAuthorizationParams", func(t *testing.T) { + t.Parallel() + + rc := &authserver.UpstreamRunConfig{ + Type: authserver.UpstreamProviderTypeOIDC, + OIDCConfig: &authserver.OIDCUpstreamRunConfig{ + IssuerURL: "https://example.com", + ClientID: "test-client-id", + RedirectURI: "http://localhost:8080/callback", + AdditionalAuthorizationParams: map[string]string{ + "access_type": "offline", + }, + }, + } + + cfg, err := buildOIDCConfig(rc) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, map[string]string{"access_type": "offline"}, + cfg.AdditionalAuthorizationParams) + }) } func TestCreateStorage(t *testing.T) { diff --git a/pkg/authserver/upstream/oauth2.go b/pkg/authserver/upstream/oauth2.go index 9f7501931b..f740d0c53a 100644 --- a/pkg/authserver/upstream/oauth2.go +++ b/pkg/authserver/upstream/oauth2.go @@ -32,6 +32,7 @@ import ( "golang.org/x/oauth2" + "github.com/stacklok/toolhive/pkg/authserver/oauthparams" "github.com/stacklok/toolhive/pkg/networking" oauthproto "github.com/stacklok/toolhive/pkg/oauth" ) @@ -110,6 +111,15 @@ type CommonOAuthConfig struct { // RedirectURI is the callback URL where the upstream IDP will redirect // after authentication. RedirectURI string `json:"redirect_uri" yaml:"redirect_uri"` + + // AdditionalAuthorizationParams are extra query parameters to include in the + // authorization URL. This is useful for providers that require custom parameters + // such as Google's access_type=offline for obtaining refresh tokens. + // Framework-managed parameters (response_type, client_id, redirect_uri, scope, + // state, code_challenge, code_challenge_method, nonce) are not allowed here + // and will be rejected during validation. + //nolint:lll // field tags require full JSON+YAML names + AdditionalAuthorizationParams map[string]string `json:"additional_authorization_params,omitempty" yaml:"additional_authorization_params,omitempty"` } // Validate checks that CommonOAuthConfig has all required fields. @@ -120,6 +130,9 @@ func (c *CommonOAuthConfig) Validate() error { if c.RedirectURI == "" { return errors.New("redirect_uri is required") } + if err := oauthparams.Validate(c.AdditionalAuthorizationParams); err != nil { + return err + } return validateRedirectURI(c.RedirectURI) } @@ -359,6 +372,9 @@ func (p *BaseOAuth2Provider) buildAuthorizationURL( } authOpts := &authorizationOptions{} + if len(p.config.AdditionalAuthorizationParams) > 0 { + WithAdditionalParams(p.config.AdditionalAuthorizationParams)(authOpts) + } for _, opt := range opts { opt(authOpts) } diff --git a/pkg/authserver/upstream/oauth2_test.go b/pkg/authserver/upstream/oauth2_test.go index 71c402a313..b7df055427 100644 --- a/pkg/authserver/upstream/oauth2_test.go +++ b/pkg/authserver/upstream/oauth2_test.go @@ -1702,3 +1702,168 @@ func TestBaseOAuth2Provider_fetchUserInfo_FieldMapping(t *testing.T) { assert.Contains(t, err.Error(), "missing required subject claim") }) } + +func TestValidateAdditionalAuthorizationParams(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + params map[string]string + wantErr bool + errContains string + }{ + { + name: "nil map", + params: nil, + }, + { + name: "empty map", + params: map[string]string{}, + }, + { + name: "valid single param", + params: map[string]string{"access_type": "offline"}, + }, + { + name: "valid multiple params", + params: map[string]string{"access_type": "offline", "prompt": "consent"}, + }, + { + name: "reserved: state", + params: map[string]string{"state": "x"}, + wantErr: true, + errContains: "state", + }, + { + name: "reserved: nonce", + params: map[string]string{"nonce": "x"}, + wantErr: true, + errContains: "nonce", + }, + { + name: "reserved: response_type", + params: map[string]string{"response_type": "token"}, + wantErr: true, + errContains: "response_type", + }, + { + name: "reserved: code_challenge", + params: map[string]string{"code_challenge": "x"}, + wantErr: true, + errContains: "code_challenge", + }, + { + name: "reserved: code_challenge_method", + params: map[string]string{"code_challenge_method": "S256"}, + wantErr: true, + errContains: "code_challenge_method", + }, + { + name: "reserved: client_id", + params: map[string]string{"client_id": "x"}, + wantErr: true, + errContains: "client_id", + }, + { + name: "reserved: redirect_uri", + params: map[string]string{"redirect_uri": "http://evil.com"}, + wantErr: true, + errContains: "redirect_uri", + }, + { + name: "reserved: scope", + params: map[string]string{"scope": "admin"}, + wantErr: true, + errContains: "scope", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + config := &CommonOAuthConfig{ + ClientID: "test-client", + RedirectURI: "http://localhost:8080/callback", + AdditionalAuthorizationParams: tt.params, + } + + err := config.Validate() + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestAuthorizationURL_AdditionalAuthorizationParams(t *testing.T) { + t.Parallel() + + t.Run("config params appear on URL", func(t *testing.T) { + t.Parallel() + + mock := newMockOAuth2Server() + t.Cleanup(mock.Close) + + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + RedirectURI: "http://localhost:8080/callback", + AdditionalAuthorizationParams: map[string]string{ + "access_type": "offline", + "prompt": "consent", + }, + }, + AuthorizationEndpoint: mock.URL + "/authorize", + TokenEndpoint: mock.URL + "/token", + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + authURL, err := provider.AuthorizationURL("test-state", "test-challenge") + require.NoError(t, err) + + parsed, err := url.Parse(authURL) + require.NoError(t, err) + + query := parsed.Query() + assert.Equal(t, "offline", query.Get("access_type")) + assert.Equal(t, "consent", query.Get("prompt")) + }) + + t.Run("caller opts override config params", func(t *testing.T) { + t.Parallel() + + mock := newMockOAuth2Server() + t.Cleanup(mock.Close) + + config := &OAuth2Config{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: "test-client", + RedirectURI: "http://localhost:8080/callback", + AdditionalAuthorizationParams: map[string]string{ + "custom": "config-value", + }, + }, + AuthorizationEndpoint: mock.URL + "/authorize", + TokenEndpoint: mock.URL + "/token", + } + + provider, err := NewOAuth2Provider(config) + require.NoError(t, err) + + authURL, err := provider.AuthorizationURL("test-state", "", + WithAdditionalParams(map[string]string{"custom": "caller-value"})) + require.NoError(t, err) + + parsed, err := url.Parse(authURL) + require.NoError(t, err) + + assert.Equal(t, "caller-value", parsed.Query().Get("custom")) + }) +} diff --git a/pkg/authserver/upstream/oidc.go b/pkg/authserver/upstream/oidc.go index c869c55aeb..b22bac8f55 100644 --- a/pkg/authserver/upstream/oidc.go +++ b/pkg/authserver/upstream/oidc.go @@ -183,13 +183,14 @@ func NewOIDCProvider( // This allows the embedded BaseOAuth2Provider to use the discovered endpoints // for token requests while preserving the original OIDC config. // Note: UserInfoEndpoint is stored in p.endpoints, not in OAuth2Config. + // Copy the full CommonOAuthConfig so that all fields (including + // AdditionalAuthorizationParams and any future additions) propagate + // to the embedded BaseOAuth2Provider. Override Scopes since OIDC + // applies default scope logic above. + commonCfg := config.CommonOAuthConfig + commonCfg.Scopes = scopes oauth2Config := &OAuth2Config{ - CommonOAuthConfig: CommonOAuthConfig{ - ClientID: config.ClientID, - ClientSecret: config.ClientSecret, - Scopes: scopes, - RedirectURI: config.RedirectURI, - }, + CommonOAuthConfig: commonCfg, AuthorizationEndpoint: p.endpoints.AuthorizationEndpoint, TokenEndpoint: p.endpoints.TokenEndpoint, } diff --git a/pkg/authserver/upstream/oidc_test.go b/pkg/authserver/upstream/oidc_test.go index 853e514fc3..be3b580583 100644 --- a/pkg/authserver/upstream/oidc_test.go +++ b/pkg/authserver/upstream/oidc_test.go @@ -371,7 +371,42 @@ func TestNewOIDCProvider(t *testing.T) { provider, err := NewOIDCProvider(ctx, config, WithForceConsentScreen(true)) require.NoError(t, err) require.NotNil(t, provider) - // Force consent screen is tested in commit 2 with AuthorizationURL tests + }) + + t.Run("force consent screen overrides config-level prompt", func(t *testing.T) { + t.Parallel() + + mock := newMockOIDCServer(t) + t.Cleanup(mock.Close) + + config := &OIDCConfig{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: testClientID, + ClientSecret: testClientSecret, + RedirectURI: testRedirectURI, + AdditionalAuthorizationParams: map[string]string{ + "prompt": "login", + "access_type": "offline", + }, + }, + Issuer: mock.issuer, + } + + ctx := context.Background() + provider, err := NewOIDCProvider(ctx, config, WithForceConsentScreen(true)) + require.NoError(t, err) + + authURL, err := provider.AuthorizationURL("test-state", "") + require.NoError(t, err) + + parsed, err := url.Parse(authURL) + require.NoError(t, err) + + query := parsed.Query() + // ForceConsentScreen should override config-level prompt=login + assert.Equal(t, "consent", query.Get("prompt")) + // Config-level access_type should be preserved + assert.Equal(t, "offline", query.Get("access_type")) }) t.Run("scopes without openid returns error", func(t *testing.T) { From 34e877d58744a04ad555af1bc09dcbd71568a2b3 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek <jakub.hrozek@posteo.se> Date: Thu, 16 Apr 2026 21:05:48 +0100 Subject: [PATCH 066/117] Replace groups parameter with variadic parents on entity factory (#4901) CreatePrincipalEntity previously accepted groups []string and internally created THVGroup entities with empty parent sets. This caused a merge-order hazard: dynamically created THVGroup entities overwrote any static ones loaded from entities_json, severing parent relationships defined there (e.g. a group that is a child of a role in a transitive hierarchy). Refactor CreatePrincipalEntity to accept parents ...cedar.EntityUID so callers pass pre-built parent UIDs. The function no longer inserts separate THVGroup entities into the entity map, preserving the static entity hierarchy from entities_json. Add the same variadic parents parameter to CreateResourceEntity for server-scoped policies (#4769). CreateEntitiesForRequest still converts groups to THVGroup parent UIDs on the principal but the entity map now contains exactly 3 entries (principal, action, resource) instead of 3+N. E2E tested in a Kind cluster with real Okta and Entra ID tokens. Proxy debug logs confirm the refactored entity hierarchy: Okta JWT: { "groups": ["Everyone", "engineering"] } Entra JWT: { "roles": ["mcp-admin", "developer"] } Both produce entityCount:3 with THVGroup parent UIDs on the principal entity. Cedar policies using "principal in THVGroup::..." evaluate correctly -- the parent UIDs are sufficient for Cedar's "in" operator without separate THVGroup entities in the map. Fixes #4765 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> --- pkg/authz/authorizers/cedar/core.go | 9 +- pkg/authz/authorizers/cedar/core_test.go | 70 +++++++- pkg/authz/authorizers/cedar/entity.go | 63 +++---- pkg/authz/authorizers/cedar/entity_test.go | 198 ++++++++++++++------- 4 files changed, 235 insertions(+), 105 deletions(-) diff --git a/pkg/authz/authorizers/cedar/core.go b/pkg/authz/authorizers/cedar/core.go index 122b3e6e20..a42cdd6ed3 100644 --- a/pkg/authz/authorizers/cedar/core.go +++ b/pkg/authz/authorizers/cedar/core.go @@ -325,12 +325,9 @@ func (a *Authorizer) GetEntityFactory() *EntityFactory { // - resource: The object being accessed (e.g., "Tool::weather") // - context: Additional information about the request // -// Note: group-based Cedar policies (e.g. "principal in THVGroup::\"eng\"") only -// work when entities are constructed via AuthorizeWithJWTClaims, which calls -// CreateEntitiesForRequest with the extracted groups slice and adds THVGroup -// parent entities. Callers that bypass AuthorizeWithJWTClaims and pass their -// own entity map must include THVGroup entities manually for group policies to -// evaluate correctly. +// Note: group-based Cedar policies (e.g. "principal in THVGroup::\"eng\"") require +// that THVGroup parent entities are included in the entity map. See #4768 for the +// group parent wiring that will set these up via CreatePrincipalEntity. // - entities: Optional Cedar entity map with attributes func (a *Authorizer) IsAuthorized( principal, action, resource string, diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index 416f51da8d..e39e16cd1a 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -826,9 +826,9 @@ func TestEntityOperations(t *testing.T) { require.NotNil(t, factory) // Create a test entity using the factory - uid, entity, _ := factory.CreatePrincipalEntity("Client", "testuser", map[string]interface{}{ + uid, entity := factory.CreatePrincipalEntity("Client", "testuser", map[string]interface{}{ "name": "Test User", - }, nil) + }) // Add entity cedarAuthorizer.AddEntity(entity) @@ -863,7 +863,7 @@ func TestGetEntityNotFound(t *testing.T) { // Create a UID that doesn't exist factory := cedarAuthorizer.GetEntityFactory() - uid, _, _ := factory.CreatePrincipalEntity("Client", "nonexistent", nil, nil) + uid, _ := factory.CreatePrincipalEntity("Client", "nonexistent", nil) // Try to get it _, found := cedarAuthorizer.GetEntity(uid) @@ -1315,6 +1315,70 @@ func TestAuthorizeWithJWTClaims_GroupMembership(t *testing.T) { } } +// TestAuthorizeWithJWTClaims_TransitiveHierarchyPreserved is a regression test +// for the merge-order hazard fixed in 40119c8e. When entities_json defines a +// THVGroup with a THVRole parent, the transitive policy "principal in THVRole" +// must still evaluate correctly after the request entity merge in IsAuthorized. +// Before the fix, CreateEntitiesForRequest inserted bare THVGroup entities that +// overwrote the static ones (which carry THVRole parents), severing the hierarchy. +func TestAuthorizeWithJWTClaims_TransitiveHierarchyPreserved(t *testing.T) { + t.Parallel() + + // Policy: only members of THVRole::"developer" may call the deploy tool. + // The user is in THVGroup::"engineering" which is a child of THVRole::"developer" + // in entities_json — so this requires transitive "in" evaluation. + policy := ` + permit( + principal in THVRole::"developer", + action == Action::"call_tool", + resource == Tool::"deploy" + ); + ` + + // Static entities: THVGroup::"engineering" → THVRole::"developer". + entitiesJSON := `[ + { + "uid": {"type": "THVGroup", "id": "engineering"}, + "attrs": {}, + "parents": [{"type": "THVRole", "id": "developer"}] + }, + { + "uid": {"type": "THVRole", "id": "developer"}, + "attrs": {}, + "parents": [] + } + ]` + + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{policy}, + EntitiesJSON: entitiesJSON, + }, "") + require.NoError(t, err) + + // User belongs to "engineering" via JWT groups claim. + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "user1", + Claims: map[string]any{ + "sub": "user1", + "groups": []interface{}{"engineering"}, + }, + }, + } + ctx := auth.WithIdentity(context.Background(), identity) + + authorized, err := authorizer.AuthorizeWithJWTClaims( + ctx, + authorizers.MCPFeatureTool, + authorizers.MCPOperationCall, + "deploy", + nil, + ) + require.NoError(t, err) + assert.True(t, authorized, + "transitive hierarchy THVGroup→THVRole from entities_json must survive entity merge") +} + // TestAuthorizeWithJWTClaims_DoesNotMutateIdentity verifies that // AuthorizeWithJWTClaims does not mutate the Identity stored in context. // The Identity contract (see auth.Identity) requires that the struct MUST NOT diff --git a/pkg/authz/authorizers/cedar/entity.go b/pkg/authz/authorizers/cedar/entity.go index baeddd1eab..a495f154f3 100644 --- a/pkg/authz/authorizers/cedar/entity.go +++ b/pkg/authz/authorizers/cedar/entity.go @@ -22,40 +22,26 @@ func NewEntityFactory() *EntityFactory { } // CreatePrincipalEntity creates a principal entity with the given ID, attributes, -// and group memberships. Each group is added as a parent entity so that Cedar's -// in operator works for group-based policies (e.g. principal in THVGroup::"engineering"). -// Returns the principal UID, the principal entity, and a slice of group entities -// (one per group) that must also be added to the entity map. +// and optional parent entity UIDs. +// When no parents are provided, the entity has an empty parent set (backward compatible). +// NOTE: This replaces the previous groups []string parameter from 5c258a11. +// Callers are now responsible for building parent UIDs (see #4768). func (*EntityFactory) CreatePrincipalEntity( principalType, principalID string, attributes map[string]interface{}, - groups []string, -) (cedar.EntityUID, cedar.Entity, []cedar.Entity) { + parents ...cedar.EntityUID, +) (cedar.EntityUID, cedar.Entity) { uid := cedar.NewEntityUID(cedar.EntityType(principalType), cedar.String(principalID)) attrs := convertMapToCedarRecord(attributes) - // Build parent UIDs and group entities from the groups slice. - parentUIDs := make([]cedar.EntityUID, 0, len(groups)) - var groupEntities []cedar.Entity - for _, g := range groups { - groupUID := cedar.NewEntityUID(EntityTypeTHVGroup, cedar.String(g)) - parentUIDs = append(parentUIDs, groupUID) - groupEntities = append(groupEntities, cedar.Entity{ - UID: groupUID, - Parents: cedar.NewEntityUIDSet(), - Attributes: cedar.NewRecord(cedar.RecordMap{}), - Tags: cedar.NewRecord(cedar.RecordMap{}), - }) - } - entity := cedar.Entity{ UID: uid, - Parents: cedar.NewEntityUIDSet(parentUIDs...), + Parents: cedar.NewEntityUIDSet(parents...), Attributes: attrs, Tags: cedar.NewRecord(cedar.RecordMap{}), } - return uid, entity, groupEntities + return uid, entity } // CreateActionEntity creates an action entity with the given ID and attributes. @@ -83,10 +69,13 @@ func (*EntityFactory) CreateActionEntity( return uid, entity } -// CreateResourceEntity creates a resource entity with the given ID and attributes. +// CreateResourceEntity creates a resource entity with the given ID, attributes, +// and optional parent entity UIDs. +// When no parents are provided, the entity has an empty parent set (backward compatible). func (*EntityFactory) CreateResourceEntity( resourceType, resourceID string, attributes map[string]interface{}, + parents ...cedar.EntityUID, ) (cedar.EntityUID, cedar.Entity) { uid := cedar.NewEntityUID(cedar.EntityType(resourceType), cedar.String(resourceID)) @@ -100,7 +89,7 @@ func (*EntityFactory) CreateResourceEntity( entity := cedar.Entity{ UID: uid, - Parents: cedar.NewEntityUIDSet(), + Parents: cedar.NewEntityUIDSet(parents...), Attributes: attrs, Tags: cedar.NewRecord(cedar.RecordMap{}), } @@ -109,9 +98,10 @@ func (*EntityFactory) CreateResourceEntity( } // CreateEntitiesForRequest creates entities for a specific authorization request. -// groups is an optional slice of group names; when non-empty, group parent entities -// are created and the principal entity's Parents set is populated accordingly so that -// Cedar's in operator works for group-membership policies. +// Groups are converted to THVGroup parent UIDs on the principal entity so that +// Cedar's `in` operator works for group-based policies. Unlike the pre-refactor +// code, no separate THVGroup entities are inserted into the entity map — those +// must come from entities_json to preserve the role hierarchy. func (f *EntityFactory) CreateEntitiesForRequest( principal, action, resource string, claimsMap map[string]interface{}, @@ -137,15 +127,20 @@ func (f *EntityFactory) CreateEntitiesForRequest( // Create Cedar entities entities := make(cedar.EntityMap) - // Create principal entity with group parents - principalUID, principalEntity, groupEntities := f.CreatePrincipalEntity(principalType, principalID, claimsMap, groups) - entities[principalUID] = principalEntity - - // Add group entities so Cedar can resolve the parent hierarchy - for _, ge := range groupEntities { - entities[ge.UID] = ge + // Build parent UIDs from groups so the principal's Parents set contains + // THVGroup references (needed for Cedar's `in` operator). Unlike the + // pre-refactor code, we do NOT insert separate THVGroup entities into the + // entity map — those come from entities_json and must not be overwritten + // (see merge-order hazard described in the RFC). #4768 will restructure + // this further for full role hierarchy support. + parentUIDs := make([]cedar.EntityUID, 0, len(groups)) + for _, g := range groups { + parentUIDs = append(parentUIDs, cedar.NewEntityUID(EntityTypeTHVGroup, cedar.String(g))) } + principalUID, principalEntity := f.CreatePrincipalEntity(principalType, principalID, claimsMap, parentUIDs...) + entities[principalUID] = principalEntity + // Create action entity actionUID, actionEntity := f.CreateActionEntity(actionType, actionID, nil) entities[actionUID] = actionEntity diff --git a/pkg/authz/authorizers/cedar/entity_test.go b/pkg/authz/authorizers/cedar/entity_test.go index ff20483d3d..8569c3d125 100644 --- a/pkg/authz/authorizers/cedar/entity_test.go +++ b/pkg/authz/authorizers/cedar/entity_test.go @@ -11,42 +11,40 @@ import ( "github.com/stretchr/testify/require" ) -// TestCreatePrincipalEntity_WithGroups tests that CreatePrincipalEntity -// correctly builds group parent entities and populates the Parents set. -func TestCreatePrincipalEntity_WithGroups(t *testing.T) { +// TestCreatePrincipalEntity_Parents tests that CreatePrincipalEntity correctly +// populates the Parents set from variadic parent UIDs. +func TestCreatePrincipalEntity_Parents(t *testing.T) { t.Parallel() factory := NewEntityFactory() + groupUID := cedar.NewEntityUID(EntityTypeTHVGroup, cedar.String("engineering")) + roleUID := cedar.NewEntityUID("THVRole", cedar.String("admin")) + tests := []struct { name string - groups []string + parents []cedar.EntityUID wantParents int - wantGroups int }{ { - name: "no_groups", - groups: nil, + name: "no_parents", + parents: nil, wantParents: 0, - wantGroups: 0, }, { - name: "empty_groups_slice", - groups: []string{}, - wantParents: 0, - wantGroups: 0, + name: "single_parent", + parents: []cedar.EntityUID{groupUID}, + wantParents: 1, }, { - name: "single_group", - groups: []string{"engineering"}, - wantParents: 1, - wantGroups: 1, + name: "multiple_parents", + parents: []cedar.EntityUID{groupUID, roleUID}, + wantParents: 2, }, { - name: "multiple_groups", - groups: []string{"engineering", "platform", "security"}, - wantParents: 3, - wantGroups: 3, + name: "duplicate_parents_are_deduplicated", + parents: []cedar.EntityUID{groupUID, groupUID}, + wantParents: 1, }, } @@ -54,10 +52,10 @@ func TestCreatePrincipalEntity_WithGroups(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - uid, entity, groupEntities := factory.CreatePrincipalEntity( + uid, entity := factory.CreatePrincipalEntity( "Client", "user1", map[string]interface{}{"name": "Test User"}, - tt.groups, + tt.parents..., ) // UID must be correct. @@ -67,60 +65,134 @@ func TestCreatePrincipalEntity_WithGroups(t *testing.T) { // Entity UID must match. assert.Equal(t, uid, entity.UID) - // Parents set must contain one entry per group. + // Parents set must contain exactly the supplied parents. assert.Equal(t, tt.wantParents, entity.Parents.Len(), "expected %d parent(s) in entity.Parents", tt.wantParents) - // Returned group-entity slice must have the right length. - assert.Len(t, groupEntities, tt.wantGroups) + for _, p := range tt.parents { + assert.True(t, entity.Parents.Contains(p), + "expected parent %v to be in entity.Parents", p) + } + }) + } +} + +// TestCreatePrincipalEntity_NoGroupEntities is a regression test verifying that +// CreatePrincipalEntity does NOT create THVGroup entities internally — callers +// are responsible for constructing parent UIDs (fixes merge-order hazard from 5c258a11). +func TestCreatePrincipalEntity_NoGroupEntities(t *testing.T) { + t.Parallel() + + factory := NewEntityFactory() - // Each group entity must be a THVGroup with the correct ID. - for i, g := range tt.groups { - ge := groupEntities[i] - assert.Equal(t, string(EntityTypeTHVGroup), string(ge.UID.Type)) - assert.Equal(t, g, string(ge.UID.ID)) - // Group entities have no parents of their own. - assert.Equal(t, 0, ge.Parents.Len()) + // Pass a THVGroup parent UID — the function must NOT return extra entities. + groupUID := cedar.NewEntityUID(EntityTypeTHVGroup, cedar.String("engineering")) + uid, entity := factory.CreatePrincipalEntity("Client", "user1", nil, groupUID) - // The principal's Parents set must contain this group UID. - assert.True(t, entity.Parents.Contains(ge.UID), - "expected parent %v to be in entity.Parents", ge.UID) + assert.Equal(t, "Client", string(uid.Type)) + assert.Equal(t, 1, entity.Parents.Len()) + assert.True(t, entity.Parents.Contains(groupUID)) + // The function returns only (uid, entity) — no group entity slice. +} + +// TestCreateResourceEntity_Parents tests that CreateResourceEntity correctly +// populates the Parents set from variadic parent UIDs. +func TestCreateResourceEntity_Parents(t *testing.T) { + t.Parallel() + + factory := NewEntityFactory() + + mcpUID := cedar.NewEntityUID("MCP", cedar.String("server-a")) + orgUID := cedar.NewEntityUID("Org", cedar.String("stacklok")) + + tests := []struct { + name string + parents []cedar.EntityUID + wantParents int + }{ + { + name: "no_parents", + parents: nil, + wantParents: 0, + }, + { + name: "single_parent", + parents: []cedar.EntityUID{mcpUID}, + wantParents: 1, + }, + { + name: "multiple_parents", + parents: []cedar.EntityUID{mcpUID, orgUID}, + wantParents: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + uid, entity := factory.CreateResourceEntity( + "Tool", "weather", + map[string]interface{}{"description": "Weather tool"}, + tt.parents..., + ) + + // UID must be correct. + assert.Equal(t, "Tool", string(uid.Type)) + assert.Equal(t, "weather", string(uid.ID)) + + // Entity UID must match. + assert.Equal(t, uid, entity.UID) + + // Parents set must contain exactly the supplied parents. + assert.Equal(t, tt.wantParents, entity.Parents.Len(), + "expected %d parent(s) in entity.Parents", tt.wantParents) + + for _, p := range tt.parents { + assert.True(t, entity.Parents.Contains(p), + "expected parent %v to be in entity.Parents", p) } + + // Name attribute must always be set. + nameVal, found := entity.Attributes.Get(cedar.String("name")) + assert.True(t, found, "name attribute must be set") + assert.Equal(t, "weather", string(nameVal.(cedar.String))) }) } } -// TestCreateEntitiesForRequest_WithGroups verifies that group entities are -// added to the entity map when groups are supplied, and that the principal -// entity's Parents set references them. -func TestCreateEntitiesForRequest_WithGroups(t *testing.T) { +// TestCreateEntitiesForRequest_GroupsAsParents verifies that +// CreateEntitiesForRequest sets THVGroup parent UIDs on the principal but +// does NOT insert separate THVGroup entities into the entity map (fixing +// the merge-order hazard where dynamic group entities overwrote static ones). +func TestCreateEntitiesForRequest_GroupsAsParents(t *testing.T) { t.Parallel() factory := NewEntityFactory() tests := []struct { - name string - groups []string - wantEntityLen int // principal + action + resource + N groups - wantGroupUIDs []string + name string + groups []string + wantEntityCount int // always 3: principal + action + resource + wantParentCount int }{ { - name: "no_groups", - groups: nil, - wantEntityLen: 3, - wantGroupUIDs: nil, + name: "no_groups", + groups: nil, + wantEntityCount: 3, + wantParentCount: 0, }, { - name: "one_group", - groups: []string{"engineering"}, - wantEntityLen: 4, - wantGroupUIDs: []string{"engineering"}, + name: "one_group", + groups: []string{"engineering"}, + wantEntityCount: 3, + wantParentCount: 1, }, { - name: "two_groups", - groups: []string{"engineering", "platform"}, - wantEntityLen: 5, - wantGroupUIDs: []string{"engineering", "platform"}, + name: "two_groups", + groups: []string{"engineering", "platform"}, + wantEntityCount: 3, + wantParentCount: 2, }, } @@ -139,19 +211,21 @@ func TestCreateEntitiesForRequest_WithGroups(t *testing.T) { require.NoError(t, err) require.NotNil(t, entities) - assert.Len(t, entities, tt.wantEntityLen) + // Entity map must contain only principal + action + resource (no THVGroup entries). + assert.Len(t, entities, tt.wantEntityCount) + for uid := range entities { + assert.NotEqual(t, string(EntityTypeTHVGroup), string(uid.Type), + "THVGroup entity should not be in the entity map") + } + // Principal's Parents set must reference THVGroup UIDs. principalUID := cedar.NewEntityUID("Client", cedar.String("user1")) principalEntity, found := entities[principalUID] require.True(t, found, "principal entity not found in map") + assert.Equal(t, tt.wantParentCount, principalEntity.Parents.Len()) - // Verify group entities are present and principal has them as parents. - for _, g := range tt.wantGroupUIDs { + for _, g := range tt.groups { groupUID := cedar.NewEntityUID(EntityTypeTHVGroup, cedar.String(g)) - - _, groupFound := entities[groupUID] - assert.True(t, groupFound, "THVGroup::%q entity not found in map", g) - assert.True(t, principalEntity.Parents.Contains(groupUID), "expected THVGroup::%q in principal.Parents", g) } From 0e5e576e2d599bdb5a97823cde874c5e224dae9d Mon Sep 17 00:00:00 2001 From: Jakub Hrozek <jakub.hrozek@posteo.se> Date: Thu, 16 Apr 2026 21:07:08 +0100 Subject: [PATCH 067/117] Fix partialFailureMode validator to accept CRD enum values (#4865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The camelCase config migration (PR #3195) accidentally changed the accepted enum value from "best_effort" to "bestEffort" in the validator, but the CRD still defines the enum as "best_effort". This made it impossible to enable best-effort partial failure mode — the CRD blocks "bestEffort" at admission and the validator blocks "best_effort" at runtime. Fixes #4856 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- pkg/vmcp/config/validator.go | 2 +- pkg/vmcp/config/validator_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/vmcp/config/validator.go b/pkg/vmcp/config/validator.go index fc12225785..f710a814a7 100644 --- a/pkg/vmcp/config/validator.go +++ b/pkg/vmcp/config/validator.go @@ -404,7 +404,7 @@ func (*DefaultValidator) validateFailureHandling(fh *FailureHandlingConfig) erro } } - validModes := []string{"fail", "bestEffort"} + validModes := []string{"fail", "best_effort"} if !slices.Contains(validModes, fh.PartialFailureMode) { return fmt.Errorf("partialFailureMode must be one of: %s", strings.Join(validModes, ", ")) } diff --git a/pkg/vmcp/config/validator_test.go b/pkg/vmcp/config/validator_test.go index 9d13229a32..1fabf51be2 100644 --- a/pkg/vmcp/config/validator_test.go +++ b/pkg/vmcp/config/validator_test.go @@ -697,7 +697,7 @@ func TestValidator_ValidateFailureHandling(t *testing.T) { fh: &FailureHandlingConfig{ HealthCheckInterval: Duration(30 * time.Second), UnhealthyThreshold: 3, - PartialFailureMode: "bestEffort", + PartialFailureMode: "best_effort", CircuitBreaker: &CircuitBreakerConfig{ Enabled: false, }, @@ -800,7 +800,7 @@ func TestValidator_ValidateFailureHandling(t *testing.T) { PartialFailureMode: "invalid", }, wantErr: true, - errMsg: "partialFailureMode must be one of: fail, bestEffort", + errMsg: "partialFailureMode must be one of: fail, best_effort", }, { name: "negative health check interval", From 03f502501dc205a90c3cdd1785da2f890a86d7b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:18:17 -0400 Subject: [PATCH 068/117] Bump github.com/moby/spdystream from 0.5.0 to 0.5.1 (#4906) Bumps [github.com/moby/spdystream](https://github.com/moby/spdystream) from 0.5.0 to 0.5.1. - [Release notes](https://github.com/moby/spdystream/releases) - [Commits](https://github.com/moby/spdystream/compare/v0.5.0...v0.5.1) --- updated-dependencies: - dependency-name: github.com/moby/spdystream dependency-version: 0.5.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 7e7330ee30..c62444beb9 100644 --- a/go.mod +++ b/go.mod @@ -203,7 +203,7 @@ require ( github.com/moby/go-archive v0.1.0 // indirect github.com/moby/moby/api v1.54.1 // indirect github.com/moby/patternmatcher v0.6.0 // indirect - github.com/moby/spdystream v0.5.0 // indirect + github.com/moby/spdystream v0.5.1 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect diff --git a/go.sum b/go.sum index d12d7207a5..7edd692292 100644 --- a/go.sum +++ b/go.sum @@ -605,8 +605,8 @@ github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjI github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= -github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= +github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= From d96922d77459ca93804bb22a5e79e83179529898 Mon Sep 17 00:00:00 2001 From: Trey <tgrunnagle@gmail.com> Date: Thu, 16 Apr 2026 15:14:10 -0700 Subject: [PATCH 069/117] Validate audience matches resourceUrl for embedded auth server (#4904) * Validate audience matches resourceUrl when embedded auth server is active (#4860) The embedded auth server mints tokens with aud set to the ResourceURL (the RFC 8707 resource parameter), but the token validator checks aud against the user-specified OIDCConfigRef.Audience. When these diverge, every authenticated request fails silently. Add reconciler-time validation requiring audience == resourceUrl when an embedded auth server is configured, with a clear error message guiding operators to fix the mismatch. This mirrors the existing validation in the vMCP inline config path (ValidateAuthServerIntegration). Fixes #4860 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Extract audience validation into shared helper and improve error messages Consolidate the duplicated audience/resourceUrl validation from AddEmbeddedAuthServerConfigOptions and AddAuthServerRefOptions into validateOIDCConfigForEmbeddedAuthServer. Add a distinct error for empty audience (missing field) vs mismatched audience (wrong value) to help operators identify the root cause faster. Document the rationale for validation-based enforcement (Option D) over silent override (Option A): operators see exactly what values are in play and control both sides explicitly, consistent with the existing vMCP inline config validation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix integration tests to set matching audience and resourceUrl Integration test fixtures set OIDCConfigRef.Audience but not ResourceURL, so the resolver auto-computed a different ResourceURL from the proxy/server name. The new audience validation correctly rejects this mismatch. Set ResourceURL to match Audience in all embedded auth server integration test fixtures so the audience consistency check passes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../mcpserver_externalauth_runconfig_test.go | 3 +- .../pkg/controllerutil/authserver.go | 52 +++++++++++++----- .../pkg/controllerutil/authserver_test.go | 54 +++++++++++++++++++ .../mcp-remote-proxy/remoteproxy_helpers.go | 9 ++-- ...cpserver_authserverref_integration_test.go | 15 +++--- 5 files changed, 111 insertions(+), 22 deletions(-) diff --git a/cmd/thv-operator/controllers/mcpserver_externalauth_runconfig_test.go b/cmd/thv-operator/controllers/mcpserver_externalauth_runconfig_test.go index 65c23611a1..51ff64f0e9 100644 --- a/cmd/thv-operator/controllers/mcpserver_externalauth_runconfig_test.go +++ b/cmd/thv-operator/controllers/mcpserver_externalauth_runconfig_test.go @@ -250,6 +250,7 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, }, oidcConfig: &oidc.OIDCConfig{ + Audience: "http://test-server.default.svc.cluster.local:8080", ResourceURL: "http://test-server.default.svc.cluster.local:8080", Scopes: []string{"openid", "offline_access"}, }, @@ -754,7 +755,7 @@ func TestCreateRunConfigFromMCPServer_WithExternalAuth(t *testing.T) { }, OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ Name: "embedded-oidc", - Audience: "toolhive", + Audience: "http://embedded-auth-server.default.svc.cluster.local:8080", Scopes: []string{"openid", "offline_access", "mcp:tools"}, }, }, diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go index cf8fbf4207..d1f204d5a0 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver.go @@ -395,12 +395,8 @@ func AddEmbeddedAuthServerConfigOptions( return fmt.Errorf("embedded auth server configuration is nil for type embeddedAuthServer") } - // Validate OIDC config is provided with ResourceURL (required for embedded auth server) - if oidcConfig == nil { - return fmt.Errorf("OIDC config is required for embedded auth server: OIDCConfigRef must be set on the MCPServer") - } - if oidcConfig.ResourceURL == "" { - return fmt.Errorf("OIDC config resourceUrl is required for embedded auth server: set resourceUrl in OIDCConfigRef") + if err := validateOIDCConfigForEmbeddedAuthServer(oidcConfig); err != nil { + return err } // Build the embedded auth server config for runner @@ -418,6 +414,42 @@ func AddEmbeddedAuthServerConfigOptions( return nil } +// validateOIDCConfigForEmbeddedAuthServer validates OIDC configuration +// requirements when an embedded auth server is active. +// +// The embedded auth server mints tokens with aud = ResourceURL (the value +// clients send as the RFC 8707 resource parameter via discovery). The token +// validator checks aud against Audience. If these differ, every authenticated +// request fails with an audience mismatch. +// +// We validate consistency at reconciliation time (rather than silently +// overriding Audience with ResourceURL) so that operators see exactly what +// values are in play and control both sides explicitly. This mirrors the +// existing vMCP inline config validation (ValidateAuthServerIntegration). +func validateOIDCConfigForEmbeddedAuthServer(oidcConfig *oidc.OIDCConfig) error { + if oidcConfig == nil { + return fmt.Errorf("OIDC config is required for embedded auth server: OIDCConfigRef must be set on the MCPServer") + } + if oidcConfig.ResourceURL == "" { + return fmt.Errorf("OIDC config resourceUrl is required for embedded auth server: set resourceUrl in OIDCConfigRef") + } + if oidcConfig.Audience == "" { + return fmt.Errorf( + "oidcConfigRef.audience is required when an embedded auth server is active; "+ + "set audience to %q to match resourceUrl", + oidcConfig.ResourceURL, + ) + } + if oidcConfig.Audience != oidcConfig.ResourceURL { + return fmt.Errorf( + "oidcConfigRef.audience %q must match resourceUrl %q when an embedded auth server is active; "+ + "set audience to %q or set resourceUrl to match audience", + oidcConfig.Audience, oidcConfig.ResourceURL, oidcConfig.ResourceURL, + ) + } + return nil +} + // BuildAuthServerRunConfig converts CRD EmbeddedAuthServerConfig to authserver.RunConfig. // The RunConfig is serializable and contains file paths for secrets (not the secrets themselves). // @@ -759,12 +791,8 @@ func AddAuthServerRefOptions( return fmt.Errorf("embedded auth server configuration is nil for type embeddedAuthServer") } - // Validate OIDC config is provided with ResourceURL (required for embedded auth server) - if oidcConfig == nil { - return fmt.Errorf("OIDC config is required for embedded auth server: OIDCConfigRef must be set on the MCPServer") - } - if oidcConfig.ResourceURL == "" { - return fmt.Errorf("OIDC config resourceUrl is required for embedded auth server: set resourceUrl in OIDCConfigRef") + if err := validateOIDCConfigForEmbeddedAuthServer(oidcConfig); err != nil { + return err } // Build the embedded auth server config for runner diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_test.go index 1c59a7741d..f56eb1bd03 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go @@ -1110,6 +1110,7 @@ func TestAddEmbeddedAuthServerConfigOptions_Validation(t *testing.T) { { name: "valid OIDC config succeeds", oidcConfig: &oidc.OIDCConfig{ + Audience: "http://test-server.default.svc.cluster.local:8080", ResourceURL: "http://test-server.default.svc.cluster.local:8080", Scopes: []string{"openid", "offline_access"}, }, @@ -1118,11 +1119,32 @@ func TestAddEmbeddedAuthServerConfigOptions_Validation(t *testing.T) { { name: "valid OIDC config with nil scopes succeeds", oidcConfig: &oidc.OIDCConfig{ + Audience: "http://test-server.default.svc.cluster.local:8080", ResourceURL: "http://test-server.default.svc.cluster.local:8080", Scopes: nil, }, expectError: false, }, + { + name: "audience mismatch with resourceUrl returns error", + oidcConfig: &oidc.OIDCConfig{ + Audience: "https://different-audience.example.com", + ResourceURL: "http://test-server.default.svc.cluster.local:8080", + Scopes: []string{"openid"}, + }, + expectError: true, + errContains: "must match resourceUrl", + }, + { + name: "empty audience returns specific error", + oidcConfig: &oidc.OIDCConfig{ + Audience: "", + ResourceURL: "http://test-server.default.svc.cluster.local:8080", + Scopes: []string{"openid"}, + }, + expectError: true, + errContains: "audience is required when an embedded auth server is active", + }, } for _, tt := range tests { @@ -1665,6 +1687,7 @@ func TestAddAuthServerRefOptions(t *testing.T) { } validOIDCConfig := &oidc.OIDCConfig{ + Audience: "https://mcp.example.com", ResourceURL: "https://mcp.example.com", Scopes: []string{"openid"}, } @@ -1738,6 +1761,36 @@ func TestAddAuthServerRefOptions(t *testing.T) { wantErr: true, errContains: "OIDC config is required", }, + { + name: "audience mismatch with resourceUrl returns error", + authServerRef: &mcpv1alpha1.AuthServerRef{ + Kind: "MCPExternalAuthConfig", + Name: "auth-server-config", + }, + oidcConfig: &oidc.OIDCConfig{ + Audience: "https://wrong-audience.example.com", + ResourceURL: "https://mcp.example.com", + Scopes: []string{"openid"}, + }, + objects: func() []runtime.Object { return []runtime.Object{newValidEmbeddedAuthConfig()} }, + wantErr: true, + errContains: "must match resourceUrl", + }, + { + name: "audience matching resourceUrl succeeds", + authServerRef: &mcpv1alpha1.AuthServerRef{ + Kind: "MCPExternalAuthConfig", + Name: "auth-server-config", + }, + oidcConfig: &oidc.OIDCConfig{ + Audience: "https://mcp.example.com", + ResourceURL: "https://mcp.example.com", + Scopes: []string{"openid"}, + }, + objects: func() []runtime.Object { return []runtime.Object{newValidEmbeddedAuthConfig()} }, + wantErr: false, + wantOptions: 1, + }, } for _, tt := range tests { @@ -1813,6 +1866,7 @@ func TestValidateAndAddAuthServerRefOptions(t *testing.T) { } validOIDC := &oidc.OIDCConfig{ + Audience: "https://mcp.example.com", ResourceURL: "https://mcp.example.com", Scopes: []string{"openid"}, } diff --git a/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go b/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go index 8badc0a56a..f6f364d435 100644 --- a/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go +++ b/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go @@ -116,10 +116,13 @@ func (rb *RemoteProxyBuilder) WithAuthServerRef(name string) *RemoteProxyBuilder } // WithOIDCConfigRef sets the OIDCConfigRef for the proxy. -func (rb *RemoteProxyBuilder) WithOIDCConfigRef(name, audience string) *RemoteProxyBuilder { +// resourceURL sets both Audience and ResourceURL to the same value, which is +// required when an embedded auth server is active (#4860). +func (rb *RemoteProxyBuilder) WithOIDCConfigRef(name, resourceURL string) *RemoteProxyBuilder { rb.proxy.Spec.OIDCConfigRef = &mcpv1alpha1.MCPOIDCConfigReference{ - Name: name, - Audience: audience, + Name: name, + Audience: resourceURL, + ResourceURL: resourceURL, } return rb } diff --git a/cmd/thv-operator/test-integration/mcp-server/mcpserver_authserverref_integration_test.go b/cmd/thv-operator/test-integration/mcp-server/mcpserver_authserverref_integration_test.go index c6534c2ce5..2d2fd510ed 100644 --- a/cmd/thv-operator/test-integration/mcp-server/mcpserver_authserverref_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/mcpserver_authserverref_integration_test.go @@ -81,8 +81,9 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { Name: authConfigName, }, OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ - Name: oidcConfigName, - Audience: "https://test-resource.example.com", + Name: oidcConfigName, + Audience: "https://test-resource.example.com", + ResourceURL: "https://test-resource.example.com", }, }, } @@ -197,8 +198,9 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { Name: authConfigConflict, }, OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ - Name: oidcConfigName, - Audience: "https://test-resource.example.com", + Name: oidcConfigName, + Audience: "https://test-resource.example.com", + ResourceURL: "https://test-resource.example.com", }, }, } @@ -376,8 +378,9 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { Name: authConfigName, }, OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ - Name: oidcConfigName, - Audience: "https://test-resource.example.com", + Name: oidcConfigName, + Audience: "https://test-resource.example.com", + ResourceURL: "https://test-resource.example.com", }, }, } From 5342824025e06a54e156a5c34b1899cc2b78bcee Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota <yolanda@stacklok.com> Date: Fri, 17 Apr 2026 09:10:03 +0200 Subject: [PATCH 070/117] Extract vMCP serve/validate logic into pkg/vmcp/cli/ (#4891) Create pkg/vmcp/cli/ with serve.go and validate.go, extracting all server-initialization and config-validation business logic from cmd/vmcp/app/commands.go into importable exported APIs (Serve, Validate). Thin-wrap cmd/vmcp/app/commands.go to delegate to the new package, and move the existing loadAuthServerConfig test to pkg/vmcp/cli/ where the function now lives. Closes #4879 Co-authored-by: taskbot <taskbot@users.noreply.github.com> --- cmd/vmcp/app/commands.go | 574 +----------------- .../vmcp/cli/auth_server_config_test.go | 3 +- pkg/vmcp/cli/serve.go | 491 +++++++++++++++ pkg/vmcp/cli/validate.go | 66 ++ 4 files changed, 582 insertions(+), 552 deletions(-) rename cmd/vmcp/app/commands_test.go => pkg/vmcp/cli/auth_server_config_test.go (99%) create mode 100644 pkg/vmcp/cli/serve.go create mode 100644 pkg/vmcp/cli/validate.go diff --git a/cmd/vmcp/app/commands.go b/cmd/vmcp/app/commands.go index 31dc6cebbc..219f2deecb 100644 --- a/cmd/vmcp/app/commands.go +++ b/cmd/vmcp/app/commands.go @@ -5,44 +5,14 @@ package app import ( - "context" - "errors" "fmt" "log/slog" - "os" - "path/filepath" - "time" "github.com/spf13/cobra" "github.com/spf13/viper" - "go.opentelemetry.io/otel/trace" - "gopkg.in/yaml.v3" - "k8s.io/client-go/rest" - "github.com/stacklok/toolhive-core/env" - "github.com/stacklok/toolhive/pkg/audit" - "github.com/stacklok/toolhive/pkg/auth/upstreamtoken" - authserverconfig "github.com/stacklok/toolhive/pkg/authserver" - authserverrunner "github.com/stacklok/toolhive/pkg/authserver/runner" - "github.com/stacklok/toolhive/pkg/authserver/server/keys" - "github.com/stacklok/toolhive/pkg/container/runtime" - "github.com/stacklok/toolhive/pkg/groups" - "github.com/stacklok/toolhive/pkg/telemetry" - "github.com/stacklok/toolhive/pkg/vmcp" - "github.com/stacklok/toolhive/pkg/vmcp/aggregator" - vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth" - "github.com/stacklok/toolhive/pkg/vmcp/auth/factory" - vmcpclient "github.com/stacklok/toolhive/pkg/vmcp/client" - "github.com/stacklok/toolhive/pkg/vmcp/config" - "github.com/stacklok/toolhive/pkg/vmcp/discovery" - "github.com/stacklok/toolhive/pkg/vmcp/health" - "github.com/stacklok/toolhive/pkg/vmcp/k8s" - "github.com/stacklok/toolhive/pkg/vmcp/optimizer" - vmcprouter "github.com/stacklok/toolhive/pkg/vmcp/router" - vmcpserver "github.com/stacklok/toolhive/pkg/vmcp/server" - vmcpsession "github.com/stacklok/toolhive/pkg/vmcp/session" - "github.com/stacklok/toolhive/pkg/vmcp/session/optimizerdec" - vmcpstatus "github.com/stacklok/toolhive/pkg/vmcp/status" + "github.com/stacklok/toolhive/pkg/versions" + vmcpcli "github.com/stacklok/toolhive/pkg/vmcp/cli" ) var rootCmd = &cobra.Command{ @@ -105,7 +75,23 @@ func newServeCmd() *cobra.Command { The server will read the configuration file specified by --config flag and start listening for MCP client connections. It will aggregate tools, resources, and prompts from all configured backend MCP servers.`, - RunE: runServe, + RunE: func(cmd *cobra.Command, _ []string) error { + configPath := viper.GetString("config") + if configPath == "" { + return fmt.Errorf("no configuration file specified, use --config flag") + } + + host, _ := cmd.Flags().GetString("host") + port, _ := cmd.Flags().GetInt("port") + enableAudit, _ := cmd.Flags().GetBool("enable-audit") + + return vmcpcli.Serve(cmd.Context(), vmcpcli.ServeConfig{ + ConfigPath: configPath, + Host: host, + Port: port, + EnableAudit: enableAudit, + }) + }, } // Add serve-specific flags @@ -123,8 +109,7 @@ func newVersionCmd() *cobra.Command { Short: "Print version information", Long: "Display version information for vmcp", Run: func(_ *cobra.Command, _ []string) { - // Version information will be injected at build time - slog.Info(fmt.Sprintf("vmcp version: %s", getVersion())) + slog.Info(fmt.Sprintf("vmcp version: %s", versions.Version)) }, } } @@ -141,525 +126,14 @@ This command checks: - Required fields presence - Middleware configuration correctness - Backend configuration validity`, - RunE: func(_ *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { configPath := viper.GetString("config") if configPath == "" { return fmt.Errorf("no configuration file specified, use --config flag") } - - slog.Info(fmt.Sprintf("Validating configuration: %s", configPath)) - - // Load configuration from YAML - envReader := &env.OSReader{} - loader := config.NewYAMLLoader(configPath, envReader) - cfg, err := loader.Load() - if err != nil { - slog.Error(fmt.Sprintf("Failed to load configuration: %v", err)) - return fmt.Errorf("configuration loading failed: %w", err) - } - - slog.Debug("configuration loaded successfully, performing validation") - - // Validate configuration - validator := config.NewValidator() - if err := validator.Validate(cfg); err != nil { - slog.Error(fmt.Sprintf("Configuration validation failed: %v", err)) - return fmt.Errorf("validation failed: %w", err) - } - - slog.Info("✓ Configuration is valid") - slog.Info(fmt.Sprintf(" Name: %s", cfg.Name)) - slog.Info(fmt.Sprintf(" Group: %s", cfg.Group)) - slog.Info(fmt.Sprintf(" Incoming Auth: %s", cfg.IncomingAuth.Type)) - slog.Info(fmt.Sprintf(" Outgoing Auth: %s (source: %s)", - func() string { - if len(cfg.OutgoingAuth.Backends) > 0 { - return fmt.Sprintf("%d backends configured", len(cfg.OutgoingAuth.Backends)) - } - return "default only" - }(), - cfg.OutgoingAuth.Source)) - slog.Info(fmt.Sprintf(" Conflict Resolution: %s", cfg.Aggregation.ConflictResolution)) - - if len(cfg.CompositeTools) > 0 { - slog.Info(fmt.Sprintf(" Composite Tools: %d defined", len(cfg.CompositeTools))) - } - - return nil + return vmcpcli.Validate(cmd.Context(), vmcpcli.ValidateConfig{ + ConfigPath: configPath, + }) }, } } - -// getVersion returns the version string (will be set at build time) -func getVersion() string { - // This will be replaced with actual version info using ldflags - return "dev" -} - -// getStatusReportingInterval extracts the status reporting interval from config. -// Returns 0 if not configured, which will use the default interval. -func getStatusReportingInterval(cfg *config.Config) time.Duration { - if cfg.Operational != nil && - cfg.Operational.FailureHandling != nil && - cfg.Operational.FailureHandling.StatusReportingInterval > 0 { - return time.Duration(cfg.Operational.FailureHandling.StatusReportingInterval) - } - return 0 -} - -// loadAndValidateConfig loads and validates the vMCP configuration file -func loadAndValidateConfig(configPath string) (*config.Config, error) { - slog.Info(fmt.Sprintf("Loading configuration from: %s", configPath)) - - envReader := &env.OSReader{} - loader := config.NewYAMLLoader(configPath, envReader) - cfg, err := loader.Load() - if err != nil { - slog.Error(fmt.Sprintf("Failed to load configuration: %v", err)) - return nil, fmt.Errorf("configuration loading failed: %w", err) - } - - validator := config.NewValidator() - if err := validator.Validate(cfg); err != nil { - slog.Error(fmt.Sprintf("Configuration validation failed: %v", err)) - return nil, fmt.Errorf("validation failed: %w", err) - } - - slog.Info("configuration loaded and validated successfully") - slog.Info(fmt.Sprintf(" Name: %s", cfg.Name)) - slog.Info(fmt.Sprintf(" Group: %s", cfg.Group)) - slog.Info(fmt.Sprintf(" Conflict Resolution: %s", cfg.Aggregation.ConflictResolution)) - if len(cfg.CompositeTools) > 0 { - slog.Info(fmt.Sprintf(" Composite Tools: %d defined", len(cfg.CompositeTools))) - } - - return cfg, nil -} - -// loadAuthServerConfig loads the auth server RunConfig from a sibling file -// alongside the main config. The operator serializes authserver.RunConfig -// as a separate ConfigMap key (authserver-config.yaml). -// Returns nil with no error if the file does not exist. -func loadAuthServerConfig(configPath string) (*authserverconfig.RunConfig, error) { - authServerPath := filepath.Join(filepath.Dir(configPath), "authserver-config.yaml") - //nolint:gosec // path derived from trusted --config flag - authServerData, readErr := os.ReadFile(authServerPath) - if readErr != nil { - if errors.Is(readErr, os.ErrNotExist) { - return nil, nil - } - return nil, fmt.Errorf("failed to read auth server config %s: %w", authServerPath, readErr) - } - var rc authserverconfig.RunConfig - if unmarshalErr := yaml.Unmarshal(authServerData, &rc); unmarshalErr != nil { - return nil, fmt.Errorf("failed to parse auth server config %s: %w", authServerPath, unmarshalErr) - } - slog.Info("auth server configuration loaded", "path", authServerPath) - return &rc, nil -} - -// discoverBackends initializes managers, discovers backends, and creates backend client -// Returns empty backends list with no error if running in Kubernetes where CLI discovery doesn't work -// Also returns the outgoingRegistry for use in creating SessionFactory -func discoverBackends( - ctx context.Context, - cfg *config.Config, -) ([]vmcp.Backend, vmcp.BackendClient, vmcpauth.OutgoingAuthRegistry, error) { - // Create outgoing authentication registry - slog.Info("initializing outgoing authentication") - envReader := &env.OSReader{} - outgoingRegistry, err := factory.NewOutgoingAuthRegistry(ctx, envReader) - if err != nil { - return nil, nil, nil, fmt.Errorf("failed to create outgoing authentication registry: %w", err) - } - - // Create backend client first (needed even with empty backends) - backendClient, err := vmcpclient.NewHTTPBackendClient(outgoingRegistry) - if err != nil { - return nil, nil, nil, fmt.Errorf("failed to create backend client: %w", err) - } - - // Create backend discoverer based on configuration mode - var discoverer aggregator.BackendDiscoverer - if len(cfg.Backends) > 0 { - // Static mode: Use pre-configured backends from config (no K8s API access needed) - slog.Info(fmt.Sprintf("Static mode: using %d pre-configured backends", len(cfg.Backends))) - discoverer = aggregator.NewUnifiedBackendDiscovererWithStaticBackends( - cfg.Backends, - cfg.OutgoingAuth, - cfg.Group, - ) - } else { - // Dynamic mode: Discover backends at runtime from K8s API - slog.Info("dynamic mode: initializing group manager for backend discovery") - groupsManager, err := groups.NewManager() - if err != nil { - return nil, nil, nil, fmt.Errorf("failed to create groups manager: %w", err) - } - - discoverer, err = aggregator.NewBackendDiscoverer(ctx, groupsManager, cfg.OutgoingAuth) - if err != nil { - return nil, nil, nil, fmt.Errorf("failed to create backend discoverer: %w", err) - } - } - - slog.Info(fmt.Sprintf("Discovering backends in group: %s", cfg.Group)) - backends, err := discoverer.Discover(ctx, cfg.Group) - if err != nil { - return nil, nil, nil, fmt.Errorf("failed to discover backends: %w", err) - } - - if len(backends) == 0 { - slog.Warn(fmt.Sprintf("No backends discovered in group %s - vmcp will start but have no backends to proxy", cfg.Group)) - return []vmcp.Backend{}, backendClient, outgoingRegistry, nil - } - - slog.Info(fmt.Sprintf("Discovered %d backends", len(backends))) - return backends, backendClient, outgoingRegistry, nil -} - -// createSessionFactory creates a MultiSessionFactory with HMAC-SHA256 token binding. -// The HMAC secret is read from the VMCP_SESSION_HMAC_SECRET environment variable. -// -// Behavior: -// - If VMCP_SESSION_HMAC_SECRET is set: validates length and creates factory with the secret -// - If running in Kubernetes without secret: returns error (production safety requirement) -// - Otherwise: logs warning and creates factory with default insecure secret -// -// Returns an error only when running in Kubernetes without a secret (fail-fast for production). -func createSessionFactory( - outgoingRegistry vmcpauth.OutgoingAuthRegistry, - agg aggregator.Aggregator, -) (vmcpsession.MultiSessionFactory, error) { - const ( - envKey = "VMCP_SESSION_HMAC_SECRET" - minRecommendedSecretLen = 32 - ) - - // Build base options, conditionally including aggregator only when provided. - opts := []vmcpsession.MultiSessionFactoryOption{} - if agg != nil { - opts = append(opts, vmcpsession.WithAggregator(agg)) - } - - hmacSecret := os.Getenv(envKey) - - if hmacSecret != "" { - // Validate secret length (warn if too short, but still use it) - if secretLen := len(hmacSecret); secretLen < minRecommendedSecretLen { - // G706: Safe - only logging integer length, not the secret itself - slog.Warn( //nolint:gosec - "HMAC secret is shorter than recommended length - consider using a longer secret", - "actual_length", secretLen, - "recommended_length", minRecommendedSecretLen, - ) - } - slog.Info("using HMAC secret from VMCP_SESSION_HMAC_SECRET environment variable for session token binding") - opts = append(opts, vmcpsession.WithHMACSecret([]byte(hmacSecret))) - return vmcpsession.NewSessionFactory(outgoingRegistry, opts...), nil - } - - // No secret provided - check if we're in Kubernetes (production environment) - if runtime.IsKubernetesRuntime() { - return nil, fmt.Errorf( - "VMCP_SESSION_HMAC_SECRET environment variable is required when running in Kubernetes. " + - "Generate a secure secret with: openssl rand -base64 32", - ) - } - - // Development mode: use default insecure secret with warning - slog.Warn("VMCP_SESSION_HMAC_SECRET not set - using default insecure secret (NOT recommended for production)") - return vmcpsession.NewSessionFactory(outgoingRegistry, opts...), nil -} - -// runServe implements the serve command logic -// -//nolint:gocyclo // Complexity from server initialization and configuration is acceptable -func runServe(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - configPath := viper.GetString("config") - - if configPath == "" { - return fmt.Errorf("no configuration file specified, use --config flag") - } - - // Load and validate configuration - cfg, err := loadAndValidateConfig(configPath) - if err != nil { - return err - } - - // Check if --enable-audit flag is set - enableAudit, _ := cmd.Flags().GetBool("enable-audit") - if enableAudit && cfg.Audit == nil { - // Create default audit config with reasonable defaults - cfg.Audit = audit.DefaultConfig() - cfg.Audit.Component = "vmcp-server" - slog.Info("audit logging enabled with default configuration") - } - - // Load auth server config from sibling file if present. - authServerRC, err := loadAuthServerConfig(configPath) - if err != nil { - return err - } - - // Auto-populate SubjectProviderName on any token_exchange strategy that - // omitted it when an embedded auth server is active. Without this, the - // strategy silently falls back to identity.Token (the ToolHive JWT) as the - // RFC 8693 subject token, which the exchange endpoint rejects. - config.InjectSubjectProviderNames(cfg, authServerRC) - - // Construct embedded authorization server if configured. - // Hard failure — if the auth server is configured but cannot be - // created, we must not silently fall back to unauthenticated mode. - var embeddedAuthServer *authserverrunner.EmbeddedAuthServer - if authServerRC != nil { - embeddedAuthServer, err = authserverrunner.NewEmbeddedAuthServer(ctx, authServerRC) - if err != nil { - return fmt.Errorf("failed to create embedded auth server: %w", err) - } - defer func() { - if closeErr := embeddedAuthServer.Close(); closeErr != nil { - slog.Error(fmt.Sprintf("failed to close embedded auth server: %v", closeErr)) - } - }() - slog.Info("embedded authorization server initialized") - } - - // Discover backends and create client - backends, backendClient, outgoingRegistry, err := discoverBackends(ctx, cfg) - if err != nil { - return err - } - - // Create conflict resolver based on configuration - conflictResolver, err := aggregator.NewConflictResolver(cfg.Aggregation) - if err != nil { - return fmt.Errorf("failed to create conflict resolver: %w", err) - } - - // If telemetry is configured, create the provider early so aggregator can use it - var telemetryProvider *telemetry.Provider - if cfg.Telemetry != nil { - telemetryProvider, err = telemetry.NewProvider(ctx, *cfg.Telemetry) - if err != nil { - return fmt.Errorf("failed to create telemetry provider: %w", err) - } - defer func() { - err := telemetryProvider.Shutdown(ctx) - if err != nil { - slog.Error(fmt.Sprintf("failed to shutdown telemetry provider: %v", err)) - } - }() - } - - // Create aggregator with tracer provider (nil if telemetry not configured) - var tracerProvider trace.TracerProvider - if telemetryProvider != nil { - tracerProvider = telemetryProvider.TracerProvider() - } - agg := aggregator.NewDefaultAggregator(backendClient, conflictResolver, cfg.Aggregation, tracerProvider) - - // DynamicRegistry tracks backends for dynamic discovery in Kubernetes mode. - // In standalone mode: backends from config file, no dynamic updates. - // In K8s mode with discovered auth: backends watched dynamically via BackendWatcher. - dynamicRegistry := vmcp.NewDynamicRegistry(backends) - backendRegistry := vmcp.BackendRegistry(dynamicRegistry) - - discoveryMgr, err := discovery.NewManager(agg) - if err != nil { - return fmt.Errorf("failed to create discovery manager: %w", err) - } - slog.Info("dynamic backend registry enabled for Kubernetes environment") - - // Backend watcher for dynamic backend discovery - var backendWatcher *k8s.BackendWatcher - - // If outgoingAuth.source is "discovered", start K8s backend watcher to watch backend changes - if cfg.OutgoingAuth != nil && cfg.OutgoingAuth.Source == "discovered" { - slog.Info("detected dynamic backend discovery mode (outgoingAuth.source: discovered)") - - // Get in-cluster REST config - restConfig, err := rest.InClusterConfig() - if err != nil { - return fmt.Errorf("failed to get in-cluster config: %w", err) - } - - // Get namespace from environment variable set by operator - // The operator sets VMCP_NAMESPACE to the VirtualMCPServer's namespace - namespace := os.Getenv("VMCP_NAMESPACE") - if namespace == "" { - return fmt.Errorf("VMCP_NAMESPACE environment variable not set") - } - - // Create K8s backend watcher to watch backend changes - backendWatcher, err = k8s.NewBackendWatcher(restConfig, namespace, cfg.Group, dynamicRegistry) - if err != nil { - return fmt.Errorf("failed to create backend watcher: %w", err) - } - - // Start K8s backend watcher in background goroutine - go func() { - slog.Info("starting Kubernetes backend watcher in background") - if err := backendWatcher.Start(ctx); err != nil { - slog.Error(fmt.Sprintf("Backend watcher stopped with error: %v", err)) - } - }() - - slog.Info("kubernetes backend watcher started for dynamic backend discovery") - } - - // Create router - rtr := vmcprouter.NewDefaultRouter() - - slog.Info(fmt.Sprintf("Setting up incoming authentication (type: %s)", cfg.IncomingAuth.Type)) - - // Create server configuration with flags - // Cobra validates flag types at parse time, so these values are safe to use directly - host, _ := cmd.Flags().GetString("host") - port, _ := cmd.Flags().GetInt("port") - - // Note: telemetryProvider was already created earlier (before aggregator creation) - // to enable tracing in the aggregator - - // Configure health monitoring if enabled - var healthMonitorConfig *health.MonitorConfig - if cfg.Operational != nil && cfg.Operational.FailureHandling != nil && cfg.Operational.FailureHandling.HealthCheckInterval > 0 { - // Note: HealthCheckInterval is config.Duration (alias for time.Duration), already in nanoseconds - // from YAML/JSON parsing via time.ParseDuration. This is a simple type cast, not unit conversion. - checkInterval := time.Duration(cfg.Operational.FailureHandling.HealthCheckInterval) - if cfg.Operational.FailureHandling.UnhealthyThreshold < 1 { - return fmt.Errorf("invalid health check configuration: unhealthy threshold must be >= 1, got %d", - cfg.Operational.FailureHandling.UnhealthyThreshold) - } - - defaults := health.DefaultConfig() - - // Use configured timeout if provided, otherwise use default - healthCheckTimeout := defaults.Timeout - if cfg.Operational.FailureHandling.HealthCheckTimeout > 0 { - healthCheckTimeout = time.Duration(cfg.Operational.FailureHandling.HealthCheckTimeout) - } - - healthMonitorConfig = &health.MonitorConfig{ - CheckInterval: checkInterval, - UnhealthyThreshold: cfg.Operational.FailureHandling.UnhealthyThreshold, - Timeout: healthCheckTimeout, - DegradedThreshold: defaults.DegradedThreshold, - } - - // Wire circuit breaker configuration if present - if cfg.Operational.FailureHandling.CircuitBreaker != nil { - cbConfig := cfg.Operational.FailureHandling.CircuitBreaker - - healthMonitorConfig.CircuitBreaker = &health.CircuitBreakerConfig{ - Enabled: cbConfig.Enabled, - FailureThreshold: cbConfig.FailureThreshold, - Timeout: time.Duration(cbConfig.Timeout), - } - - if cbConfig.Enabled { - slog.Info(fmt.Sprintf("Circuit breaker enabled (threshold: %d failures, timeout: %v)", - cbConfig.FailureThreshold, time.Duration(cbConfig.Timeout))) - } - } - - slog.Info("health monitoring configured from operational settings") - } - - // Create status reporter using factory (auto-detects K8s vs CLI mode) - statusReporter, err := vmcpstatus.NewReporter() - if err != nil { - return fmt.Errorf("failed to create status reporter: %w", err) - } - - optCfg, err := optimizer.GetAndValidateConfig(cfg.Optimizer) - if err != nil { - return fmt.Errorf("failed to validate optimizer config: %w", err) - } - - sessionFactory, err := createSessionFactory(outgoingRegistry, agg) - if err != nil { - return err - } - - // When the optimizer is enabled, its meta-tools (find_tool, call_tool) must pass - // through the authz response filter so they appear in tools/list. Authorization - // for the underlying backend tools is enforced by the middleware's call_tool - // interception. See: https://github.com/stacklok/toolhive/issues/4373 - var passThroughTools map[string]struct{} - if optCfg != nil { - passThroughTools = map[string]struct{}{ - optimizerdec.FindToolName: {}, - optimizerdec.CallToolName: {}, - } - } - - // Extract dependencies from the embedded auth server so the OIDC middleware - // can (a) resolve JWKS keys in-process instead of self-referential HTTP - // calls, and (b) enrich Identity with upstream provider tokens. - var upstreamReader upstreamtoken.TokenReader - var keyProvider keys.PublicKeyProvider - if embeddedAuthServer != nil { - stor := embeddedAuthServer.IDPTokenStorage() - refresher := embeddedAuthServer.UpstreamTokenRefresher() - upstreamReader = upstreamtoken.NewInProcessService(stor, refresher) - keyProvider = embeddedAuthServer.KeyProvider() - } - - authMiddleware, authzMiddleware, authInfoHandler, err := - factory.NewIncomingAuthMiddleware(ctx, cfg.IncomingAuth, passThroughTools, upstreamReader, keyProvider) - if err != nil { - return fmt.Errorf("failed to create authentication middleware: %w", err) - } - - slog.Info(fmt.Sprintf("Incoming authentication configured: %s", cfg.IncomingAuth.Type)) - - serverCfg := &vmcpserver.Config{ - Name: cfg.Name, - Version: getVersion(), - GroupRef: cfg.Group, - Host: host, - Port: port, - AuthMiddleware: authMiddleware, - AuthzMiddleware: authzMiddleware, - AuthInfoHandler: authInfoHandler, - AuthServer: embeddedAuthServer, - TelemetryProvider: telemetryProvider, - AuditConfig: cfg.Audit, - HealthMonitorConfig: healthMonitorConfig, - StatusReportingInterval: getStatusReportingInterval(cfg), - Watcher: nil, // set below if backendWatcher is non-nil - StatusReporter: statusReporter, - OptimizerConfig: optCfg, - SessionFactory: sessionFactory, - SessionStorage: cfg.SessionStorage, - } - - // Assign Watcher only when backendWatcher is non-nil. A typed nil *k8s.BackendWatcher - // assigned directly to the Watcher interface field produces a non-nil interface value, - // which bypasses the nil check in handleReadiness and panics on the first /readyz probe. - if backendWatcher != nil { - serverCfg.Watcher = backendWatcher - } - - // Convert composite tool configurations to workflow definitions - workflowDefs, err := vmcpserver.ConvertConfigToWorkflowDefinitions(cfg.CompositeTools) - if err != nil { - return fmt.Errorf("failed to convert composite tool definitions: %w", err) - } - if len(workflowDefs) > 0 { - slog.Info(fmt.Sprintf("Loaded %d composite tool workflow definitions", len(workflowDefs))) - } - - // Create server with discovery manager, backend registry, and workflow definitions - srv, err := vmcpserver.New(ctx, serverCfg, rtr, backendClient, discoveryMgr, backendRegistry, workflowDefs) - if err != nil { - return fmt.Errorf("failed to create Virtual MCP Server: %w", err) - } - - // Start server (blocks until shutdown signal) - slog.Info(fmt.Sprintf("Starting Virtual MCP Server at %s", srv.Address())) - return srv.Start(ctx) -} diff --git a/cmd/vmcp/app/commands_test.go b/pkg/vmcp/cli/auth_server_config_test.go similarity index 99% rename from cmd/vmcp/app/commands_test.go rename to pkg/vmcp/cli/auth_server_config_test.go index fd519204d1..885635a1b0 100644 --- a/cmd/vmcp/app/commands_test.go +++ b/pkg/vmcp/cli/auth_server_config_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package app +package cli import ( "os" @@ -75,5 +75,4 @@ func TestLoadAuthServerConfig(t *testing.T) { assert.Nil(t, rc) assert.Contains(t, err.Error(), "failed to parse") }) - } diff --git a/pkg/vmcp/cli/serve.go b/pkg/vmcp/cli/serve.go new file mode 100644 index 0000000000..980d3ee68f --- /dev/null +++ b/pkg/vmcp/cli/serve.go @@ -0,0 +1,491 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package cli provides the business logic for the vMCP serve and validate +// commands. It is designed to be imported by both the standalone vmcp binary +// (cmd/vmcp/app) and the thv vmcp subcommand (cmd/thv/app), keeping all +// server-initialization logic in one importable place. +package cli + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "go.opentelemetry.io/otel/trace" + "gopkg.in/yaml.v3" + "k8s.io/client-go/rest" + + "github.com/stacklok/toolhive-core/env" + "github.com/stacklok/toolhive/pkg/audit" + "github.com/stacklok/toolhive/pkg/auth/upstreamtoken" + authserverconfig "github.com/stacklok/toolhive/pkg/authserver" + authserverrunner "github.com/stacklok/toolhive/pkg/authserver/runner" + "github.com/stacklok/toolhive/pkg/authserver/server/keys" + "github.com/stacklok/toolhive/pkg/container/runtime" + "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/telemetry" + "github.com/stacklok/toolhive/pkg/versions" + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/aggregator" + vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth" + "github.com/stacklok/toolhive/pkg/vmcp/auth/factory" + vmcpclient "github.com/stacklok/toolhive/pkg/vmcp/client" + "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/pkg/vmcp/discovery" + "github.com/stacklok/toolhive/pkg/vmcp/health" + "github.com/stacklok/toolhive/pkg/vmcp/k8s" + "github.com/stacklok/toolhive/pkg/vmcp/optimizer" + vmcprouter "github.com/stacklok/toolhive/pkg/vmcp/router" + vmcpserver "github.com/stacklok/toolhive/pkg/vmcp/server" + vmcpsession "github.com/stacklok/toolhive/pkg/vmcp/session" + "github.com/stacklok/toolhive/pkg/vmcp/session/optimizerdec" + vmcpstatus "github.com/stacklok/toolhive/pkg/vmcp/status" +) + +// ServeConfig holds all parameters needed to start the vMCP server. +// Populated by the caller from Cobra flag values or equivalent. +type ServeConfig struct { + // ConfigPath is the path to the vMCP YAML configuration file. + ConfigPath string + // Host is the address the server binds to (e.g. "127.0.0.1"). + Host string + // Port is the TCP port the server listens on. + Port int + // EnableAudit enables audit logging with default configuration when + // the loaded config does not already define an audit section. + EnableAudit bool +} + +// Serve loads configuration, initializes all subsystems, and starts the vMCP +// server. It blocks until the context is cancelled or the server stops. +// +//nolint:gocyclo // Complexity from server initialization sequence is acceptable here. +func Serve(ctx context.Context, cfg ServeConfig) error { + if cfg.ConfigPath == "" { + return fmt.Errorf("no configuration file specified, use --config flag") + } + + // Load and validate configuration + vmcpCfg, err := loadAndValidateConfig(cfg.ConfigPath) + if err != nil { + return err + } + + // Apply --enable-audit flag when the config has no audit section. + if cfg.EnableAudit && vmcpCfg.Audit == nil { + vmcpCfg.Audit = audit.DefaultConfig() + vmcpCfg.Audit.Component = "vmcp-server" + slog.Info("audit logging enabled with default configuration") + } + + // Load auth server config from sibling file if present. + authServerRC, err := loadAuthServerConfig(cfg.ConfigPath) + if err != nil { + return err + } + + // Auto-populate SubjectProviderName on any token_exchange strategy that + // omitted it when an embedded auth server is active. + config.InjectSubjectProviderNames(vmcpCfg, authServerRC) + + // Construct embedded authorization server if configured. + var embeddedAuthServer *authserverrunner.EmbeddedAuthServer + if authServerRC != nil { + embeddedAuthServer, err = authserverrunner.NewEmbeddedAuthServer(ctx, authServerRC) + if err != nil { + return fmt.Errorf("failed to create embedded auth server: %w", err) + } + defer func() { + if closeErr := embeddedAuthServer.Close(); closeErr != nil { + slog.Error(fmt.Sprintf("failed to close embedded auth server: %v", closeErr)) + } + }() + slog.Info("embedded authorization server initialized") + } + + // Discover backends and create client. + backends, backendClient, outgoingRegistry, err := discoverBackends(ctx, vmcpCfg) + if err != nil { + return err + } + + // Create conflict resolver based on configuration. + conflictResolver, err := aggregator.NewConflictResolver(vmcpCfg.Aggregation) + if err != nil { + return fmt.Errorf("failed to create conflict resolver: %w", err) + } + + // If telemetry is configured, create the provider early so aggregator can use it. + var telemetryProvider *telemetry.Provider + if vmcpCfg.Telemetry != nil { + telemetryProvider, err = telemetry.NewProvider(ctx, *vmcpCfg.Telemetry) + if err != nil { + return fmt.Errorf("failed to create telemetry provider: %w", err) + } + defer func() { + if shutdownErr := telemetryProvider.Shutdown(ctx); shutdownErr != nil { + slog.Error(fmt.Sprintf("failed to shutdown telemetry provider: %v", shutdownErr)) + } + }() + } + + // Create aggregator with tracer provider (nil if telemetry not configured). + var tracerProvider trace.TracerProvider + if telemetryProvider != nil { + tracerProvider = telemetryProvider.TracerProvider() + } + agg := aggregator.NewDefaultAggregator(backendClient, conflictResolver, vmcpCfg.Aggregation, tracerProvider) + + // DynamicRegistry tracks backends for dynamic discovery in Kubernetes mode. + dynamicRegistry := vmcp.NewDynamicRegistry(backends) + backendRegistry := vmcp.BackendRegistry(dynamicRegistry) + + discoveryMgr, err := discovery.NewManager(agg) + if err != nil { + return fmt.Errorf("failed to create discovery manager: %w", err) + } + slog.Info("dynamic backend registry enabled for Kubernetes environment") + + // Backend watcher for dynamic backend discovery. + var backendWatcher *k8s.BackendWatcher + + // If outgoingAuth.source is "discovered", start K8s backend watcher. + if vmcpCfg.OutgoingAuth != nil && vmcpCfg.OutgoingAuth.Source == "discovered" { + slog.Info("detected dynamic backend discovery mode (outgoingAuth.source: discovered)") + + restConfig, err := rest.InClusterConfig() + if err != nil { + return fmt.Errorf("failed to get in-cluster config: %w", err) + } + + namespace := os.Getenv("VMCP_NAMESPACE") + if namespace == "" { + return fmt.Errorf("VMCP_NAMESPACE environment variable not set") + } + + backendWatcher, err = k8s.NewBackendWatcher(restConfig, namespace, vmcpCfg.Group, dynamicRegistry) + if err != nil { + return fmt.Errorf("failed to create backend watcher: %w", err) + } + + go func() { + slog.Info("starting Kubernetes backend watcher in background") + if err := backendWatcher.Start(ctx); err != nil { + slog.Error(fmt.Sprintf("Backend watcher stopped with error: %v", err)) + } + }() + + slog.Info("kubernetes backend watcher started for dynamic backend discovery") + } + + // Create router. + rtr := vmcprouter.NewDefaultRouter() + + slog.Info(fmt.Sprintf("Setting up incoming authentication (type: %s)", vmcpCfg.IncomingAuth.Type)) + + // Configure health monitoring if enabled. + var healthMonitorConfig *health.MonitorConfig + if vmcpCfg.Operational != nil && + vmcpCfg.Operational.FailureHandling != nil && + vmcpCfg.Operational.FailureHandling.HealthCheckInterval > 0 { + + checkInterval := time.Duration(vmcpCfg.Operational.FailureHandling.HealthCheckInterval) + if vmcpCfg.Operational.FailureHandling.UnhealthyThreshold < 1 { + return fmt.Errorf("invalid health check configuration: unhealthy threshold must be >= 1, got %d", + vmcpCfg.Operational.FailureHandling.UnhealthyThreshold) + } + + defaults := health.DefaultConfig() + + healthCheckTimeout := defaults.Timeout + if vmcpCfg.Operational.FailureHandling.HealthCheckTimeout > 0 { + healthCheckTimeout = time.Duration(vmcpCfg.Operational.FailureHandling.HealthCheckTimeout) + } + + healthMonitorConfig = &health.MonitorConfig{ + CheckInterval: checkInterval, + UnhealthyThreshold: vmcpCfg.Operational.FailureHandling.UnhealthyThreshold, + Timeout: healthCheckTimeout, + DegradedThreshold: defaults.DegradedThreshold, + } + + if vmcpCfg.Operational.FailureHandling.CircuitBreaker != nil { + cbConfig := vmcpCfg.Operational.FailureHandling.CircuitBreaker + healthMonitorConfig.CircuitBreaker = &health.CircuitBreakerConfig{ + Enabled: cbConfig.Enabled, + FailureThreshold: cbConfig.FailureThreshold, + Timeout: time.Duration(cbConfig.Timeout), + } + if cbConfig.Enabled { + slog.Info(fmt.Sprintf("Circuit breaker enabled (threshold: %d failures, timeout: %v)", + cbConfig.FailureThreshold, time.Duration(cbConfig.Timeout))) + } + } + + slog.Info("health monitoring configured from operational settings") + } + + // Create status reporter. + statusReporter, err := vmcpstatus.NewReporter() + if err != nil { + return fmt.Errorf("failed to create status reporter: %w", err) + } + + optCfg, err := optimizer.GetAndValidateConfig(vmcpCfg.Optimizer) + if err != nil { + return fmt.Errorf("failed to validate optimizer config: %w", err) + } + + sessionFactory, err := createSessionFactory(outgoingRegistry, agg) + if err != nil { + return err + } + + // When the optimizer is enabled, its meta-tools must pass through the authz + // response filter so they appear in tools/list. + var passThroughTools map[string]struct{} + if optCfg != nil { + passThroughTools = map[string]struct{}{ + optimizerdec.FindToolName: {}, + optimizerdec.CallToolName: {}, + } + } + + // Extract dependencies from the embedded auth server. + var upstreamReader upstreamtoken.TokenReader + var keyProvider keys.PublicKeyProvider + if embeddedAuthServer != nil { + stor := embeddedAuthServer.IDPTokenStorage() + refresher := embeddedAuthServer.UpstreamTokenRefresher() + upstreamReader = upstreamtoken.NewInProcessService(stor, refresher) + keyProvider = embeddedAuthServer.KeyProvider() + } + + authMiddleware, authzMiddleware, authInfoHandler, err := + factory.NewIncomingAuthMiddleware(ctx, vmcpCfg.IncomingAuth, passThroughTools, upstreamReader, keyProvider) + if err != nil { + return fmt.Errorf("failed to create authentication middleware: %w", err) + } + + slog.Info(fmt.Sprintf("Incoming authentication configured: %s", vmcpCfg.IncomingAuth.Type)) + + serverCfg := &vmcpserver.Config{ + Name: vmcpCfg.Name, + Version: versions.Version, + GroupRef: vmcpCfg.Group, + Host: cfg.Host, + Port: cfg.Port, + AuthMiddleware: authMiddleware, + AuthzMiddleware: authzMiddleware, + AuthInfoHandler: authInfoHandler, + AuthServer: embeddedAuthServer, + TelemetryProvider: telemetryProvider, + AuditConfig: vmcpCfg.Audit, + HealthMonitorConfig: healthMonitorConfig, + StatusReportingInterval: getStatusReportingInterval(vmcpCfg), + Watcher: nil, // set below if backendWatcher is non-nil + StatusReporter: statusReporter, + OptimizerConfig: optCfg, + SessionFactory: sessionFactory, + SessionStorage: vmcpCfg.SessionStorage, + } + + // Assign Watcher only when backendWatcher is non-nil. A typed nil + // *k8s.BackendWatcher assigned to the Watcher interface produces a + // non-nil interface value, which panics on the first /readyz probe. + if backendWatcher != nil { + serverCfg.Watcher = backendWatcher + } + + // Convert composite tool configurations to workflow definitions. + workflowDefs, err := vmcpserver.ConvertConfigToWorkflowDefinitions(vmcpCfg.CompositeTools) + if err != nil { + return fmt.Errorf("failed to convert composite tool definitions: %w", err) + } + if len(workflowDefs) > 0 { + slog.Info(fmt.Sprintf("Loaded %d composite tool workflow definitions", len(workflowDefs))) + } + + // Create server with discovery manager, backend registry, and workflow definitions. + srv, err := vmcpserver.New(ctx, serverCfg, rtr, backendClient, discoveryMgr, backendRegistry, workflowDefs) + if err != nil { + return fmt.Errorf("failed to create Virtual MCP Server: %w", err) + } + + slog.Info(fmt.Sprintf("Starting Virtual MCP Server at %s", srv.Address())) + return srv.Start(ctx) +} + +// getStatusReportingInterval extracts the status reporting interval from config. +// Returns 0 if not configured, which uses the default interval. +func getStatusReportingInterval(cfg *config.Config) time.Duration { + if cfg.Operational != nil && + cfg.Operational.FailureHandling != nil && + cfg.Operational.FailureHandling.StatusReportingInterval > 0 { + return time.Duration(cfg.Operational.FailureHandling.StatusReportingInterval) + } + return 0 +} + +// loadAndValidateConfig loads and validates the vMCP configuration file. +func loadAndValidateConfig(configPath string) (*config.Config, error) { + slog.Info(fmt.Sprintf("Loading configuration from: %s", configPath)) + + envReader := &env.OSReader{} + loader := config.NewYAMLLoader(configPath, envReader) + cfg, err := loader.Load() + if err != nil { + slog.Error(fmt.Sprintf("Failed to load configuration: %v", err)) + return nil, fmt.Errorf("configuration loading failed: %w", err) + } + + validator := config.NewValidator() + if err := validator.Validate(cfg); err != nil { + slog.Error(fmt.Sprintf("Configuration validation failed: %v", err)) + return nil, fmt.Errorf("validation failed: %w", err) + } + + slog.Info("configuration loaded and validated successfully") + slog.Info(fmt.Sprintf(" Name: %s", cfg.Name)) + slog.Info(fmt.Sprintf(" Group: %s", cfg.Group)) + slog.Info(fmt.Sprintf(" Conflict Resolution: %s", cfg.Aggregation.ConflictResolution)) + if len(cfg.CompositeTools) > 0 { + slog.Info(fmt.Sprintf(" Composite Tools: %d defined", len(cfg.CompositeTools))) + } + + return cfg, nil +} + +// loadAuthServerConfig loads the auth server RunConfig from a sibling file +// alongside the main config. The operator serializes authserver.RunConfig as a +// separate ConfigMap key (authserver-config.yaml). +// Returns nil with no error if the file does not exist. +func loadAuthServerConfig(configPath string) (*authserverconfig.RunConfig, error) { + authServerPath := filepath.Join(filepath.Dir(configPath), "authserver-config.yaml") + //nolint:gosec // path is user-supplied and intentionally read from the local filesystem + authServerData, readErr := os.ReadFile(authServerPath) + if readErr != nil { + if errors.Is(readErr, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("failed to read auth server config %s: %w", authServerPath, readErr) + } + var rc authserverconfig.RunConfig + if unmarshalErr := yaml.Unmarshal(authServerData, &rc); unmarshalErr != nil { + return nil, fmt.Errorf("failed to parse auth server config %s: %w", authServerPath, unmarshalErr) + } + slog.Info("auth server configuration loaded", "path", authServerPath) + return &rc, nil +} + +// discoverBackends initializes managers, discovers backends, and creates the +// backend client. Returns an empty backends list (with no error) when +// discovery succeeds but finds no backends (static or dynamic mode). +func discoverBackends( + ctx context.Context, + cfg *config.Config, +) ([]vmcp.Backend, vmcp.BackendClient, vmcpauth.OutgoingAuthRegistry, error) { + slog.Info("initializing outgoing authentication") + envReader := &env.OSReader{} + outgoingRegistry, err := factory.NewOutgoingAuthRegistry(ctx, envReader) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create outgoing authentication registry: %w", err) + } + + backendClient, err := vmcpclient.NewHTTPBackendClient(outgoingRegistry) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create backend client: %w", err) + } + + var discoverer aggregator.BackendDiscoverer + if len(cfg.Backends) > 0 { + // Static mode: use pre-configured backends from config. + slog.Info(fmt.Sprintf("Static mode: using %d pre-configured backends", len(cfg.Backends))) + discoverer = aggregator.NewUnifiedBackendDiscovererWithStaticBackends( + cfg.Backends, + cfg.OutgoingAuth, + cfg.Group, + ) + } else { + // Dynamic mode: discover backends at runtime from K8s API. + slog.Info("dynamic mode: initializing group manager for backend discovery") + groupsManager, err := groups.NewManager() + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create groups manager: %w", err) + } + + discoverer, err = aggregator.NewBackendDiscoverer(ctx, groupsManager, cfg.OutgoingAuth) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create backend discoverer: %w", err) + } + } + + slog.Info(fmt.Sprintf("Discovering backends in group: %s", cfg.Group)) + backends, err := discoverer.Discover(ctx, cfg.Group) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to discover backends: %w", err) + } + + if len(backends) == 0 { + slog.Warn(fmt.Sprintf("No backends discovered in group %s - vmcp will start but have no backends to proxy", cfg.Group)) + return []vmcp.Backend{}, backendClient, outgoingRegistry, nil + } + + slog.Info(fmt.Sprintf("Discovered %d backends", len(backends))) + return backends, backendClient, outgoingRegistry, nil +} + +// createSessionFactory creates a MultiSessionFactory with HMAC-SHA256 token binding. +// The HMAC secret is read from the VMCP_SESSION_HMAC_SECRET environment variable. +// +// Behavior: +// - If VMCP_SESSION_HMAC_SECRET is set: validates length and creates factory with the secret. +// - If running in Kubernetes without secret: returns error (production safety requirement). +// - Otherwise: logs warning and creates factory with default insecure secret. +func createSessionFactory( + outgoingRegistry vmcpauth.OutgoingAuthRegistry, + agg aggregator.Aggregator, +) (vmcpsession.MultiSessionFactory, error) { + const ( + envKey = "VMCP_SESSION_HMAC_SECRET" + minRecommendedSecretLen = 32 + ) + + opts := []vmcpsession.MultiSessionFactoryOption{} + if agg != nil { + opts = append(opts, vmcpsession.WithAggregator(agg)) + } + + hmacSecret := os.Getenv(envKey) + + if hmacSecret != "" { + if secretLen := len(hmacSecret); secretLen < minRecommendedSecretLen { + // G706: Safe - only logging integer length, not the secret itself. + slog.Warn( //nolint:gosec + "HMAC secret is shorter than recommended length - consider using a longer secret", + "actual_length", secretLen, + "recommended_length", minRecommendedSecretLen, + ) + } + slog.Info("using HMAC secret from VMCP_SESSION_HMAC_SECRET environment variable for session token binding") + opts = append(opts, vmcpsession.WithHMACSecret([]byte(hmacSecret))) + return vmcpsession.NewSessionFactory(outgoingRegistry, opts...), nil + } + + // No secret provided — fail fast in Kubernetes (production environment). + if runtime.IsKubernetesRuntime() { + return nil, fmt.Errorf( + "VMCP_SESSION_HMAC_SECRET environment variable is required when running in Kubernetes. " + + "Generate a secure secret with: openssl rand -base64 32", + ) + } + + // Development mode: use default insecure secret with warning. + slog.Warn("VMCP_SESSION_HMAC_SECRET not set - using default insecure secret (NOT recommended for production)") + return vmcpsession.NewSessionFactory(outgoingRegistry, opts...), nil +} diff --git a/pkg/vmcp/cli/validate.go b/pkg/vmcp/cli/validate.go new file mode 100644 index 0000000000..594c041670 --- /dev/null +++ b/pkg/vmcp/cli/validate.go @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "fmt" + "log/slog" + + "github.com/stacklok/toolhive-core/env" + "github.com/stacklok/toolhive/pkg/vmcp/config" +) + +// ValidateConfig holds parameters for the validate command. +type ValidateConfig struct { + // ConfigPath is the path to the vMCP YAML configuration file to validate. + ConfigPath string +} + +// Validate loads and validates a vMCP configuration file, printing a summary +// on success. Returns a descriptive error if the file is missing, malformed, +// or fails semantic validation. +func Validate(_ context.Context, cfg ValidateConfig) error { + if cfg.ConfigPath == "" { + return fmt.Errorf("no configuration file specified, use --config flag") + } + + slog.Info(fmt.Sprintf("Validating configuration: %s", cfg.ConfigPath)) + + envReader := &env.OSReader{} + loader := config.NewYAMLLoader(cfg.ConfigPath, envReader) + vmcpCfg, err := loader.Load() + if err != nil { + slog.Error(fmt.Sprintf("Failed to load configuration: %v", err)) + return fmt.Errorf("configuration loading failed: %w", err) + } + + slog.Debug("configuration loaded successfully, performing validation") + + validator := config.NewValidator() + if err := validator.Validate(vmcpCfg); err != nil { + slog.Error(fmt.Sprintf("Configuration validation failed: %v", err)) + return fmt.Errorf("validation failed: %w", err) + } + + slog.Info("✓ Configuration is valid") + slog.Info(fmt.Sprintf(" Name: %s", vmcpCfg.Name)) + slog.Info(fmt.Sprintf(" Group: %s", vmcpCfg.Group)) + slog.Info(fmt.Sprintf(" Incoming Auth: %s", vmcpCfg.IncomingAuth.Type)) + slog.Info(fmt.Sprintf(" Outgoing Auth: %s (source: %s)", + func() string { + if len(vmcpCfg.OutgoingAuth.Backends) > 0 { + return fmt.Sprintf("%d backends configured", len(vmcpCfg.OutgoingAuth.Backends)) + } + return "default only" + }(), + vmcpCfg.OutgoingAuth.Source)) + slog.Info(fmt.Sprintf(" Conflict Resolution: %s", vmcpCfg.Aggregation.ConflictResolution)) + + if len(vmcpCfg.CompositeTools) > 0 { + slog.Info(fmt.Sprintf(" Composite Tools: %d defined", len(vmcpCfg.CompositeTools))) + } + + return nil +} From cfd835cd0e89f562ae969c2a4fd41d375f3a8e02 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek <jakub.hrozek@posteo.se> Date: Fri, 17 Apr 2026 11:26:45 +0100 Subject: [PATCH 071/117] Add name attribute alias on Resource entities (#4907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enterprise Cedar schema (platform-authz branch, 02-cedar-compilation.md § entity types) declares "name" as a required String attribute on all three entity kinds — Tool, Prompt, and Resource. Tool and Prompt entities already had "name" populated by CreateResourceEntity, but authorizeResourceRead built its own attributes map with only "uri", so Resource entities lacked the attribute and would fail schema validation when the enterprise controller compiles policies. Add "name": resourceURI alongside the existing "uri": resourceURI in authorizeResourceRead so Resource entities satisfy the schema. In CreateResourceEntity, default "name" to resourceID only when the caller has not already provided one — authorizeResourceRead sets name to the original unsanitized URI before passing the sanitized form as resourceID, and the caller's value must survive. Preserving the caller-provided name also exposes the original URI to policies, so authors can match on the form they actually know (e.g. resource.name == "file:///...") rather than the Cedar-sanitized entity ID. E2E tested in a Kind cluster with real Entra ID tokens: permit(principal in THVGroup::"mcp-admin", action == Action::"call_tool", resource in MCP::"entra-role-test") when { resource.name == "echo" }; Entra JWT: { "roles": ["mcp-admin", "developer"] } call_tool "echo" -> 200 (resource.name matches) call_tool "nonexistent_tool" -> 403 (name mismatch) Fixes #4766 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> --- pkg/authz/authorizers/cedar/core.go | 1 + pkg/authz/authorizers/cedar/core_test.go | 63 ++++++++++++++++++++++ pkg/authz/authorizers/cedar/entity.go | 8 ++- pkg/authz/authorizers/cedar/entity_test.go | 63 ++++++++++++++++++++++ 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/pkg/authz/authorizers/cedar/core.go b/pkg/authz/authorizers/cedar/core.go index a42cdd6ed3..7c3504d73b 100644 --- a/pkg/authz/authorizers/cedar/core.go +++ b/pkg/authz/authorizers/cedar/core.go @@ -637,6 +637,7 @@ func (a *Authorizer) authorizeResourceRead( // Create attributes for the entities attributes := mergeContexts(map[string]interface{}{ + "name": resourceURI, "uri": resourceURI, "operation": "read", "feature": "resource", diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index e39e16cd1a..a0c7aa0a5c 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -269,6 +269,69 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { arguments: nil, expectAuthorized: true, }, + { + name: "Resource entity exposes name attribute for Cedar schema", + policy: ` + permit( + principal, + action == Action::"read_resource", + resource + ) + when { + resource.name == "sensitive_data" + }; + `, + claims: jwt.MapClaims{ + "sub": "user123", + }, + feature: authorizers.MCPFeatureResource, + operation: authorizers.MCPOperationRead, + resourceID: "sensitive_data", + arguments: nil, + expectAuthorized: true, + }, + { + name: "Resource entity retains uri attribute for backward compat", + policy: ` + permit( + principal, + action == Action::"read_resource", + resource + ) + when { + resource.uri == "sensitive_data" + }; + `, + claims: jwt.MapClaims{ + "sub": "user123", + }, + feature: authorizers.MCPFeatureResource, + operation: authorizers.MCPOperationRead, + resourceID: "sensitive_data", + arguments: nil, + expectAuthorized: true, + }, + { + name: "Resource name and uri attributes carry the same value", + policy: ` + permit( + principal, + action == Action::"read_resource", + resource + ) + when { + resource.name == resource.uri + }; + `, + claims: jwt.MapClaims{ + "sub": "user123", + }, + feature: authorizers.MCPFeatureResource, + operation: authorizers.MCPOperationRead, + resourceID: "sensitive_data", + arguments: nil, + expectAuthorized: true, + }, { name: "User can get prompt", policy: ` diff --git a/pkg/authz/authorizers/cedar/entity.go b/pkg/authz/authorizers/cedar/entity.go index a495f154f3..ee7307f1ee 100644 --- a/pkg/authz/authorizers/cedar/entity.go +++ b/pkg/authz/authorizers/cedar/entity.go @@ -79,11 +79,15 @@ func (*EntityFactory) CreateResourceEntity( ) (cedar.EntityUID, cedar.Entity) { uid := cedar.NewEntityUID(cedar.EntityType(resourceType), cedar.String(resourceID)) - // Ensure name attribute is set + // Ensure name attribute is set — but don't overwrite if the caller + // already provided one (e.g. authorizeResourceRead sets name to the + // original URI before sanitization). if attributes == nil { attributes = make(map[string]interface{}) } - attributes["name"] = resourceID + if _, exists := attributes["name"]; !exists { + attributes["name"] = resourceID + } attrs := convertMapToCedarRecord(attributes) diff --git a/pkg/authz/authorizers/cedar/entity_test.go b/pkg/authz/authorizers/cedar/entity_test.go index 8569c3d125..c971627aa6 100644 --- a/pkg/authz/authorizers/cedar/entity_test.go +++ b/pkg/authz/authorizers/cedar/entity_test.go @@ -161,6 +161,69 @@ func TestCreateResourceEntity_Parents(t *testing.T) { } } +// TestCreateResourceEntity_NamePreservation is a regression test for the bug +// where CreateResourceEntity unconditionally overwrote attributes["name"] with +// resourceID (the sanitized entity UID). authorizeResourceRead sets name to the +// original, unsanitized URI before calling CreateResourceEntity — the caller's +// value must survive. When no name is provided, resourceID is used as fallback. +func TestCreateResourceEntity_NamePreservation(t *testing.T) { + t.Parallel() + + factory := NewEntityFactory() + + tests := []struct { + name string + resourceID string + attributes map[string]interface{} + wantName string + }{ + { + name: "caller_name_preserved", + resourceID: "sanitized-resource-id", + attributes: map[string]interface{}{ + "name": "original/unsanitized:uri", + }, + wantName: "original/unsanitized:uri", + }, + { + name: "uri_with_special_characters_preserved", + resourceID: "https___example_com_api_v1_resource_id=42", + attributes: map[string]interface{}{ + "name": "https://example.com/api/v1/resource?id=42", + }, + wantName: "https://example.com/api/v1/resource?id=42", + }, + { + name: "fallback_to_resourceID_when_name_absent", + resourceID: "weather", + attributes: map[string]interface{}{ + "description": "Weather tool", + }, + wantName: "weather", + }, + { + name: "fallback_to_resourceID_when_attributes_nil", + resourceID: "weather", + attributes: nil, + wantName: "weather", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, entity := factory.CreateResourceEntity( + "Resource", tt.resourceID, tt.attributes, + ) + + nameVal, found := entity.Attributes.Get(cedar.String("name")) + require.True(t, found, "name attribute must always be set") + assert.Equal(t, tt.wantName, string(nameVal.(cedar.String))) + }) + } +} + // TestCreateEntitiesForRequest_GroupsAsParents verifies that // CreateEntitiesForRequest sets THVGroup parent UIDs on the principal but // does NOT insert separate THVGroup entities into the entity map (fixing From dce0ac7bf778af1db61ac5c91dd00cab5a0edd1a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:40:15 -0400 Subject: [PATCH 072/117] Update module github.com/stacklok/toolhive-catalog to v0.20260417.0 (#4909) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c62444beb9..3c0de8462e 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/redis/go-redis/v9 v9.18.0 github.com/shirou/gopsutil/v4 v4.26.2 github.com/spf13/viper v1.21.0 - github.com/stacklok/toolhive-catalog v0.20260416.0 + github.com/stacklok/toolhive-catalog v0.20260417.0 github.com/stacklok/toolhive-core v0.0.16 github.com/stretchr/testify v1.11.1 github.com/swaggo/swag/v2 v2.0.0-rc5 diff --git a/go.sum b/go.sum index 7edd692292..175a969dbe 100644 --- a/go.sum +++ b/go.sum @@ -796,8 +796,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stacklok/toolhive-catalog v0.20260416.0 h1:e2b8/90d3/VoA7JVKIk6eceEsbaBtY6uET9WB/u32vs= -github.com/stacklok/toolhive-catalog v0.20260416.0/go.mod h1:KveauhiKSukvUr2SYxDnlSeW3JmfdbXefuZxpiOwQ6A= +github.com/stacklok/toolhive-catalog v0.20260417.0 h1:LUNlpmMNORtcaYKmS7KNKo4mEq5JMXfyqlwOBGH6kd4= +github.com/stacklok/toolhive-catalog v0.20260417.0/go.mod h1:KveauhiKSukvUr2SYxDnlSeW3JmfdbXefuZxpiOwQ6A= github.com/stacklok/toolhive-core v0.0.16 h1:Td/o/zrO3Pbr6tkx4r0k8Bqn9DySlwgah2vknE8tj5A= github.com/stacklok/toolhive-core v0.0.16/go.mod h1:iHp39rCHZxXzU5FmXKK9aoH48NE+K0ob+MtdTS9CvAk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= From b8b76ccec4ddef04c480f50cd5b1ec6fa3d197ef Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota <yolanda@stacklok.com> Date: Fri, 17 Apr 2026 15:04:08 +0200 Subject: [PATCH 073/117] Add unit tests for pkg/vmcp/cli serve and validate logic (#4898) * Add unit tests for pkg/vmcp/cli serve and validate logic Cover loadAndValidateConfig, loadAuthServerConfig, discoverBackends, createSessionFactory, and Validate with table-driven unit tests in pkg/vmcp/cli. Also moves the existing loadAuthServerConfig test from cmd/vmcp/app (where it lived before the thin-wrap refactor) to pkg/vmcp/cli where the function now lives. env var tests use os.Setenv + t.Cleanup via a setEnvForTest helper to remain compatible with t.Parallel(). Part of #4808. Closes #4881. * fixes from review --------- Co-authored-by: taskbot <taskbot@users.noreply.github.com> --- pkg/vmcp/cli/serve.go | 50 +++++--- pkg/vmcp/cli/serve_test.go | 230 ++++++++++++++++++++++++++++++++++ pkg/vmcp/cli/validate_test.go | 117 +++++++++++++++++ 3 files changed, 381 insertions(+), 16 deletions(-) create mode 100644 pkg/vmcp/cli/serve_test.go create mode 100644 pkg/vmcp/cli/validate_test.go diff --git a/pkg/vmcp/cli/serve.go b/pkg/vmcp/cli/serve.go index 980d3ee68f..abe1e40a8a 100644 --- a/pkg/vmcp/cli/serve.go +++ b/pkg/vmcp/cli/serve.go @@ -241,7 +241,13 @@ func Serve(ctx context.Context, cfg ServeConfig) error { return fmt.Errorf("failed to validate optimizer config: %w", err) } - sessionFactory, err := createSessionFactory(outgoingRegistry, agg) + envReader := &env.OSReader{} + sessionFactory, err := createSessionFactory( + envReader.Getenv("VMCP_SESSION_HMAC_SECRET"), + runtime.IsKubernetesRuntimeWithEnv(envReader), + outgoingRegistry, + agg, + ) if err != nil { return err } @@ -425,14 +431,27 @@ func discoverBackends( } } - slog.Info(fmt.Sprintf("Discovering backends in group: %s", cfg.Group)) - backends, err := discoverer.Discover(ctx, cfg.Group) + return runDiscovery(ctx, cfg.Group, discoverer, backendClient, outgoingRegistry) +} + +// runDiscovery calls Discover on the provided discoverer and handles the zero-backends +// case. Extracted so tests can inject a stub discoverer without needing a real +// Kubernetes cluster or Docker daemon. +func runDiscovery( + ctx context.Context, + groupRef string, + discoverer aggregator.BackendDiscoverer, + backendClient vmcp.BackendClient, + outgoingRegistry vmcpauth.OutgoingAuthRegistry, +) ([]vmcp.Backend, vmcp.BackendClient, vmcpauth.OutgoingAuthRegistry, error) { + slog.Info(fmt.Sprintf("Discovering backends in group: %s", groupRef)) + backends, err := discoverer.Discover(ctx, groupRef) if err != nil { return nil, nil, nil, fmt.Errorf("failed to discover backends: %w", err) } if len(backends) == 0 { - slog.Warn(fmt.Sprintf("No backends discovered in group %s - vmcp will start but have no backends to proxy", cfg.Group)) + slog.Warn(fmt.Sprintf("No backends discovered in group %s - vmcp will start but have no backends to proxy", groupRef)) return []vmcp.Backend{}, backendClient, outgoingRegistry, nil } @@ -441,28 +460,27 @@ func discoverBackends( } // createSessionFactory creates a MultiSessionFactory with HMAC-SHA256 token binding. -// The HMAC secret is read from the VMCP_SESSION_HMAC_SECRET environment variable. +// The HMAC secret and Kubernetes detection are passed in as parameters (typically sourced +// from the VMCP_SESSION_HMAC_SECRET environment variable and runtime environment detection +// by the caller). // // Behavior: -// - If VMCP_SESSION_HMAC_SECRET is set: validates length and creates factory with the secret. +// - If hmacSecret is non-empty: validates length and creates factory with the secret. // - If running in Kubernetes without secret: returns error (production safety requirement). // - Otherwise: logs warning and creates factory with default insecure secret. func createSessionFactory( + hmacSecret string, + isKubernetes bool, outgoingRegistry vmcpauth.OutgoingAuthRegistry, agg aggregator.Aggregator, ) (vmcpsession.MultiSessionFactory, error) { - const ( - envKey = "VMCP_SESSION_HMAC_SECRET" - minRecommendedSecretLen = 32 - ) + const minRecommendedSecretLen = 32 opts := []vmcpsession.MultiSessionFactoryOption{} if agg != nil { opts = append(opts, vmcpsession.WithAggregator(agg)) } - hmacSecret := os.Getenv(envKey) - if hmacSecret != "" { if secretLen := len(hmacSecret); secretLen < minRecommendedSecretLen { // G706: Safe - only logging integer length, not the secret itself. @@ -472,20 +490,20 @@ func createSessionFactory( "recommended_length", minRecommendedSecretLen, ) } - slog.Info("using HMAC secret from VMCP_SESSION_HMAC_SECRET environment variable for session token binding") + slog.Info("using provided HMAC secret for session token binding") opts = append(opts, vmcpsession.WithHMACSecret([]byte(hmacSecret))) return vmcpsession.NewSessionFactory(outgoingRegistry, opts...), nil } // No secret provided — fail fast in Kubernetes (production environment). - if runtime.IsKubernetesRuntime() { + if isKubernetes { return nil, fmt.Errorf( - "VMCP_SESSION_HMAC_SECRET environment variable is required when running in Kubernetes. " + + "an HMAC secret is required when running in Kubernetes (set VMCP_SESSION_HMAC_SECRET). " + "Generate a secure secret with: openssl rand -base64 32", ) } // Development mode: use default insecure secret with warning. - slog.Warn("VMCP_SESSION_HMAC_SECRET not set - using default insecure secret (NOT recommended for production)") + slog.Warn("no HMAC secret provided - using default insecure secret (NOT recommended for production)") return vmcpsession.NewSessionFactory(outgoingRegistry, opts...), nil } diff --git a/pkg/vmcp/cli/serve_test.go b/pkg/vmcp/cli/serve_test.go new file mode 100644 index 0000000000..76e5394033 --- /dev/null +++ b/pkg/vmcp/cli/serve_test.go @@ -0,0 +1,230 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "gopkg.in/yaml.v3" + + authserverconfig "github.com/stacklok/toolhive/pkg/authserver" + "github.com/stacklok/toolhive/pkg/vmcp" + aggregatormocks "github.com/stacklok/toolhive/pkg/vmcp/aggregator/mocks" + clientmocks "github.com/stacklok/toolhive/pkg/vmcp/client/mocks" + vmcpmocks "github.com/stacklok/toolhive/pkg/vmcp/mocks" +) + +// TestLoadAndValidateConfig covers all config-loading paths. +func TestLoadAndValidateConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + wantErr bool + errContains string + }{ + { + name: "valid config", + content: validConfigYAML, + wantErr: false, + }, + { + name: "non-existent file", + content: "", // file will not be created + wantErr: true, + errContains: "configuration loading failed", + }, + { + name: "malformed YAML", + content: ":::invalid yaml:::", + wantErr: true, + errContains: "configuration loading failed", + }, + { + name: "fails semantic validation — missing groupRef", + content: ` +name: test-vmcp +incomingAuth: + type: anonymous +outgoingAuth: + source: inline +aggregation: + conflictResolution: prefix + conflictResolutionConfig: + prefixFormat: "{workload}_" +`, + wantErr: true, + errContains: "group reference is required", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "vmcp.yaml") + if tc.content != "" { + require.NoError(t, os.WriteFile(path, []byte(tc.content), 0o600)) + } + + cfg, err := loadAndValidateConfig(path) + if tc.wantErr { + require.Error(t, err) + require.ErrorContains(t, err, tc.errContains) + require.Nil(t, cfg) + } else { + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, "test-group", cfg.Group) + } + }) + } +} + +// TestLoadAuthServerConfig covers all auth-server-config side-loading paths. +// (Additional cases live in auth_server_config_test.go, moved from cmd/vmcp/app.) +func TestLoadAuthServerConfig_NestedDir(t *testing.T) { + t.Parallel() + + // Config lives in a subdirectory; sibling authserver-config.yaml must be found correctly. + dir := t.TempDir() + subdir := filepath.Join(dir, "sub", "dir") + require.NoError(t, os.MkdirAll(subdir, 0o750)) + configPath := filepath.Join(subdir, "vmcp-config.yaml") + + want := &authserverconfig.RunConfig{ + Issuer: "https://nested.example.com", + SchemaVersion: "1", + } + data, err := yaml.Marshal(want) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(subdir, "authserver-config.yaml"), data, 0o600)) + + rc, err := loadAuthServerConfig(configPath) + require.NoError(t, err) + require.NotNil(t, rc) + assert.Equal(t, "https://nested.example.com", rc.Issuer) +} + +// TestDiscoverBackends_StaticMode exercises the static-backend path without +// needing a live Kubernetes API. +func TestDiscoverBackends_StaticMode(t *testing.T) { + t.Parallel() + + // Build a minimal config with one static backend. + dir := t.TempDir() + path := filepath.Join(dir, "vmcp.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +name: test-vmcp +groupRef: test-group + +incomingAuth: + type: anonymous + +outgoingAuth: + source: inline + default: + type: unauthenticated + +aggregation: + conflictResolution: prefix + conflictResolutionConfig: + prefixFormat: "{workload}_" + +backends: + - name: backend-one + url: http://127.0.0.1:9001/sse + transport: sse +`), 0o600)) + + cfg, err := loadAndValidateConfig(path) + require.NoError(t, err) + require.Len(t, cfg.Backends, 1) + + backends, client, registry, err := discoverBackends(t.Context(), cfg) + require.NoError(t, err) + assert.NotNil(t, client) + assert.NotNil(t, registry) + // Static mode: one backend discovered. + assert.Len(t, backends, 1) +} + +func newSessionFactoryMocks(t *testing.T) (*clientmocks.MockOutgoingAuthRegistry, *aggregatormocks.MockAggregator) { + t.Helper() + ctrl := gomock.NewController(t) + return clientmocks.NewMockOutgoingAuthRegistry(ctrl), aggregatormocks.NewMockAggregator(ctrl) +} + +func TestCreateSessionFactory_WithHMACSecret(t *testing.T) { + t.Parallel() + registry, agg := newSessionFactoryMocks(t) + factory, err := createSessionFactory("a-sufficiently-long-hmac-secret-value-32b", false, registry, agg) + require.NoError(t, err) + require.NotNil(t, factory) +} + +func TestCreateSessionFactory_HMACSecretExactly32Bytes(t *testing.T) { + t.Parallel() + registry, agg := newSessionFactoryMocks(t) + factory, err := createSessionFactory("12345678901234567890123456789012", false, registry, agg) + require.NoError(t, err) + require.NotNil(t, factory) +} + +func TestCreateSessionFactory_ShortHMACSecret(t *testing.T) { + t.Parallel() + registry, agg := newSessionFactoryMocks(t) + factory, err := createSessionFactory("short", false, registry, agg) + require.NoError(t, err) + require.NotNil(t, factory) +} + +func TestCreateSessionFactory_NoSecretNonKubernetes(t *testing.T) { + t.Parallel() + registry, agg := newSessionFactoryMocks(t) + factory, err := createSessionFactory("", false, registry, agg) + require.NoError(t, err) + require.NotNil(t, factory) +} + +func TestCreateSessionFactory_NoSecretKubernetes(t *testing.T) { + t.Parallel() + registry, agg := newSessionFactoryMocks(t) + factory, err := createSessionFactory("", true, registry, agg) + require.Error(t, err) + require.ErrorContains(t, err, "an HMAC secret is required when running in Kubernetes") + require.Nil(t, factory) +} + +// TestRunDiscovery_ZeroBackends exercises the branch in runDiscovery where the +// discoverer succeeds but returns no backends. The function must return a +// non-error, an empty (non-nil) backend slice, and pass through the client and +// registry it received. +func TestRunDiscovery_ZeroBackends(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + discoverer := aggregatormocks.NewMockBackendDiscoverer(ctrl) + backendClient := vmcpmocks.NewMockBackendClient(ctrl) + registry := clientmocks.NewMockOutgoingAuthRegistry(ctrl) + + const groupRef = "test-group" + discoverer.EXPECT(). + Discover(gomock.Any(), groupRef). + Return([]vmcp.Backend{}, nil) + + backends, gotClient, gotRegistry, err := runDiscovery(t.Context(), groupRef, discoverer, backendClient, registry) + + require.NoError(t, err) + assert.NotNil(t, backends) + assert.Empty(t, backends) + assert.Same(t, backendClient, gotClient) + assert.Same(t, registry, gotRegistry) +} diff --git a/pkg/vmcp/cli/validate_test.go b/pkg/vmcp/cli/validate_test.go new file mode 100644 index 0000000000..63e0e309b7 --- /dev/null +++ b/pkg/vmcp/cli/validate_test.go @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +const validConfigYAML = ` +name: test-vmcp +groupRef: test-group + +incomingAuth: + type: anonymous + +outgoingAuth: + source: inline + default: + type: unauthenticated + +aggregation: + conflictResolution: prefix + conflictResolutionConfig: + prefixFormat: "{workload}_" +` + +func TestValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(t *testing.T) ValidateConfig + wantErr bool + errContains string + }{ + { + name: "missing config path", + setup: func(_ *testing.T) ValidateConfig { + return ValidateConfig{} + }, + wantErr: true, + errContains: "no configuration file specified", + }, + { + name: "valid config file", + setup: func(t *testing.T) ValidateConfig { + t.Helper() + path := filepath.Join(t.TempDir(), "vmcp.yaml") + require.NoError(t, os.WriteFile(path, []byte(validConfigYAML), 0o600)) + return ValidateConfig{ConfigPath: path} + }, + wantErr: false, + }, + { + name: "non-existent file", + setup: func(t *testing.T) ValidateConfig { + t.Helper() + return ValidateConfig{ConfigPath: filepath.Join(t.TempDir(), "nonexistent.yaml")} + }, + wantErr: true, + errContains: "configuration loading failed", + }, + { + name: "malformed YAML", + setup: func(t *testing.T) ValidateConfig { + t.Helper() + path := filepath.Join(t.TempDir(), "bad.yaml") + require.NoError(t, os.WriteFile(path, []byte(":::not valid yaml:::"), 0o600)) + return ValidateConfig{ConfigPath: path} + }, + wantErr: true, + errContains: "configuration loading failed", + }, + { + name: "config missing required groupRef", + setup: func(t *testing.T) ValidateConfig { + t.Helper() + path := filepath.Join(t.TempDir(), "invalid.yaml") + // groupRef is required; omitting it must fail validation. + require.NoError(t, os.WriteFile(path, []byte(` +name: test-vmcp +incomingAuth: + type: anonymous +outgoingAuth: + source: inline +aggregation: + conflictResolution: prefix + conflictResolutionConfig: + prefixFormat: "{workload}_" +`), 0o600)) + return ValidateConfig{ConfigPath: path} + }, + wantErr: true, + errContains: "group reference is required", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + cfg := tc.setup(t) + err := Validate(context.Background(), cfg) + if tc.wantErr { + require.Error(t, err) + require.ErrorContains(t, err, tc.errContains) + } else { + require.NoError(t, err) + } + }) + } +} From ee988f35b6ff04bf2fb08f11b235b7ec6072d70b Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota <yolanda@stacklok.com> Date: Fri, 17 Apr 2026 15:04:48 +0200 Subject: [PATCH 074/117] Add pkg/vmcp/cli/init.go for vMCP config scaffolding (#4903) Implement Init(ctx, InitConfig) which discovers running workloads in a named ToolHive group via an injected Discoverer, renders a commented starter YAML config pre-populated with one backend entry per accessible workload, and writes the result to a file (0o600) or an io.Writer. Workloads returning nil from GetWorkloadAsVMCPBackend are skipped with a debug log. Empty groups produce a valid YAML skeleton with backends: []. Includes unit tests covering writer output, file output, nil-backend skipping, empty groups, discovery errors, YAML validity (loader + validator), file permissions, and input validation edge cases. Closes #4882 Co-authored-by: taskbot <taskbot@users.noreply.github.com> --- pkg/vmcp/cli/init.go | 217 ++++++++++++++++++++++++++++++++++ pkg/vmcp/cli/init_test.go | 240 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 pkg/vmcp/cli/init.go create mode 100644 pkg/vmcp/cli/init_test.go diff --git a/pkg/vmcp/cli/init.go b/pkg/vmcp/cli/init.go new file mode 100644 index 0000000000..8677900f64 --- /dev/null +++ b/pkg/vmcp/cli/init.go @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "os" + "slices" + "strings" + "text/template" + + "gopkg.in/yaml.v3" + + groupval "github.com/stacklok/toolhive-core/validation/group" + "github.com/stacklok/toolhive/pkg/fileutils" + vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/pkg/vmcp/workloads" +) + +// InitConfig holds all parameters for the Init command. +// Discoverer is injected rather than constructed internally to enable unit testing. +type InitConfig struct { + // GroupName is the ToolHive group whose workloads are enumerated. + GroupName string + + // OutputPath is the file path to write the generated config. + // If empty or "-", content is written to Writer. + OutputPath string + + // Writer is used when OutputPath is empty or "-". + // Defaults to os.Stdout when nil. + Writer io.Writer + + // Discoverer resolves running workloads in the group. + // In production, callers pass a *pkg/workloads.DiscovererAdapter (CLI) or + // the Kubernetes discoverer from pkg/vmcp/workloads/k8s.go. + Discoverer workloads.Discoverer +} + +// initTemplateData holds the data for the config template. +type initTemplateData struct { + ServerName string + GroupName string + Backends []vmcpconfig.StaticBackendConfig +} + +// configTemplate is the starter vMCP YAML template with inline comments. +// Text/template delimiters ({{...}}) do not conflict with YAML's {workload}_ +// placeholder because Go templates require double braces. +const configTemplate = "# Generated by `thv vmcp init`. Review and customize before use.\n" + + ` +# name: unique identifier for this vMCP server instance. +name: {{.ServerName}} + +# groupRef: the ToolHive group whose workloads are aggregated. +groupRef: {{.GroupName}} + +# incomingAuth: controls how clients authenticate to this vMCP server. +# type: anonymous disables client auth (suitable for local development). +# Change to "oidc" for production deployments. +incomingAuth: + type: anonymous + +# outgoingAuth: controls how this vMCP server authenticates to backends. +# source: inline means auth config is fully specified here. +outgoingAuth: + source: inline + +# aggregation: controls how tools from multiple backends are combined. +# conflictResolution: prefix prepends the backend name to each tool name. +aggregation: + conflictResolution: prefix + conflictResolutionConfig: + prefixFormat: "{workload}_" + +# backends: one entry per running workload discovered in the group. +backends:{{if .Backends}} +{{range .Backends}} - name: {{yamlStr .Name}} + url: {{yamlStr .URL}} + transport: {{yamlStr .Transport}} +{{end}}{{else}} [] +{{end}}` + +// parsedConfigTemplate is the configTemplate parsed once at package init with +// the yamlStr function registered, avoiding repeated parsing on every call. +var parsedConfigTemplate = template.Must( + template.New("vmcp-init").Funcs(template.FuncMap{ + "yamlStr": yamlScalar, + }).Parse(configTemplate), +) + +// Init discovers workloads in cfg.GroupName, renders a starter vMCP YAML +// config file with one backend entry per accessible workload, and writes +// the result to cfg.OutputPath or cfg.Writer. +func Init(ctx context.Context, cfg InitConfig) error { + if cfg.Discoverer == nil { + return fmt.Errorf("discoverer is required") + } + if err := groupval.ValidateName(cfg.GroupName); err != nil { + return fmt.Errorf("invalid group name: %w", err) + } + + workloadList, err := cfg.Discoverer.ListWorkloadsInGroup(ctx, cfg.GroupName) + if err != nil { + return fmt.Errorf("failed to list workloads in group %q: %w", cfg.GroupName, err) + } + + backends, err := resolveBackends(ctx, cfg.Discoverer, workloadList) + if err != nil { + return err + } + + rendered, err := renderConfig(initTemplateData{ + ServerName: cfg.GroupName + "-vmcp", + GroupName: cfg.GroupName, + Backends: backends, + }) + if err != nil { + return err + } + + return writeOutput(cfg, rendered) +} + +// yamlScalar marshals a string to a properly quoted/escaped YAML scalar value +// using gopkg.in/yaml.v3, ensuring characters like '#' or ':' in names and +// URLs do not corrupt the rendered YAML. +func yamlScalar(v string) (string, error) { + b, err := yaml.Marshal(v) + if err != nil { + return "", err + } + return strings.TrimRight(string(b), "\n"), nil +} + +// normalizeTransport maps known transport aliases to the canonical values accepted +// by the static backend config validator. Returns the canonical value and true, +// or ("", false) for unsupported transports. +func normalizeTransport(t string) (string, bool) { + switch t { + case vmcpconfig.TransportSSE: + return vmcpconfig.TransportSSE, true + case vmcpconfig.TransportStreamableHTTP, "streamable": + return vmcpconfig.TransportStreamableHTTP, true + default: + return "", false + } +} + +// resolveBackends calls GetWorkloadAsVMCPBackend for each workload and collects +// accessible backends, skipping those that return nil. +func resolveBackends( + ctx context.Context, + disc workloads.Discoverer, + workloadList []workloads.TypedWorkload, +) ([]vmcpconfig.StaticBackendConfig, error) { + var backends []vmcpconfig.StaticBackendConfig + for _, wl := range workloadList { + backend, err := disc.GetWorkloadAsVMCPBackend(ctx, wl) + if err != nil { + return nil, fmt.Errorf("failed to get backend for workload %q: %w", wl.Name, err) + } + if backend == nil { + slog.Debug("skipping workload: not yet accessible", "workload", wl.Name) + continue + } + transport, ok := normalizeTransport(backend.TransportType) + if !ok { + slog.Warn("skipping workload: unsupported transport type for static config", + "workload", wl.Name, "transport", backend.TransportType) + continue + } + backends = append(backends, vmcpconfig.StaticBackendConfig{ + Name: backend.Name, + URL: backend.BaseURL, + Transport: transport, + }) + } + slices.SortFunc(backends, func(a, b vmcpconfig.StaticBackendConfig) int { + return strings.Compare(a.Name, b.Name) + }) + return backends, nil +} + +// renderConfig executes the pre-parsed configTemplate with the given data. +func renderConfig(data initTemplateData) ([]byte, error) { + var buf bytes.Buffer + if err := parsedConfigTemplate.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("failed to render config template: %w", err) + } + return buf.Bytes(), nil +} + +// writeOutput writes the rendered config to the configured destination. +func writeOutput(cfg InitConfig, content []byte) error { + if cfg.OutputPath != "" && cfg.OutputPath != "-" { + if err := fileutils.AtomicWriteFile(cfg.OutputPath, content, 0o600); err != nil { + return fmt.Errorf("failed to write config to %q: %w", cfg.OutputPath, err) + } + slog.Info("vMCP configuration written", "path", cfg.OutputPath) + return nil + } + + w := cfg.Writer + if w == nil { + w = os.Stdout + } + if _, err := w.Write(content); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + return nil +} diff --git a/pkg/vmcp/cli/init_test.go b/pkg/vmcp/cli/init_test.go new file mode 100644 index 0000000000..cf12a93b0c --- /dev/null +++ b/pkg/vmcp/cli/init_test.go @@ -0,0 +1,240 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/env" + "github.com/stacklok/toolhive/pkg/vmcp" + vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/pkg/vmcp/workloads" + workloadmocks "github.com/stacklok/toolhive/pkg/vmcp/workloads/mocks" +) + +// newDiscovererMock creates a new MockDiscoverer for use in tests. +func newDiscovererMock(t *testing.T) *workloadmocks.MockDiscoverer { + t.Helper() + return workloadmocks.NewMockDiscoverer(gomock.NewController(t)) +} + +// testWorkload is a TypedWorkload fixture used across tests. +var testWorkload = workloads.TypedWorkload{ + Name: "my-server", + Type: workloads.WorkloadTypeMCPServer, +} + +// testBackend is the vmcp.Backend returned by the mock for testWorkload. +var testBackend = &vmcp.Backend{ + Name: "my-server", + BaseURL: "http://127.0.0.1:9001/mcp", + TransportType: "streamable-http", +} + +func TestInit_WritesToWriter(t *testing.T) { + t.Parallel() + + disc := newDiscovererMock(t) + disc.EXPECT().ListWorkloadsInGroup(gomock.Any(), "test-group").Return([]workloads.TypedWorkload{testWorkload}, nil) + disc.EXPECT().GetWorkloadAsVMCPBackend(gomock.Any(), testWorkload).Return(testBackend, nil) + + var buf bytes.Buffer + err := Init(context.Background(), InitConfig{ + GroupName: "test-group", + Writer: &buf, + Discoverer: disc, + }) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "groupRef: test-group") + assert.Contains(t, output, "my-server") +} + +func TestInit_WritesToFile(t *testing.T) { + t.Parallel() + + disc := newDiscovererMock(t) + disc.EXPECT().ListWorkloadsInGroup(gomock.Any(), "test-group").Return([]workloads.TypedWorkload{testWorkload}, nil) + disc.EXPECT().GetWorkloadAsVMCPBackend(gomock.Any(), testWorkload).Return(testBackend, nil) + + path := filepath.Join(t.TempDir(), "vmcp.yaml") + err := Init(context.Background(), InitConfig{ + GroupName: "test-group", + OutputPath: path, + Discoverer: disc, + }) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(data), "groupRef: test-group") +} + +func TestInit_SkipsUnsupportedTransport(t *testing.T) { + t.Parallel() + + unsupportedWorkload := workloads.TypedWorkload{Name: "bad-transport", Type: workloads.WorkloadTypeMCPServer} + + disc := newDiscovererMock(t) + disc.EXPECT().ListWorkloadsInGroup(gomock.Any(), "test-group").Return( + []workloads.TypedWorkload{unsupportedWorkload, testWorkload}, nil, + ) + disc.EXPECT().GetWorkloadAsVMCPBackend(gomock.Any(), unsupportedWorkload).Return(&vmcp.Backend{ + Name: "bad-transport", + BaseURL: "http://127.0.0.1:9999/mcp", + TransportType: "http", + }, nil) + disc.EXPECT().GetWorkloadAsVMCPBackend(gomock.Any(), testWorkload).Return(testBackend, nil) + + var buf bytes.Buffer + err := Init(context.Background(), InitConfig{ + GroupName: "test-group", + Writer: &buf, + Discoverer: disc, + }) + require.NoError(t, err) + + output := buf.String() + assert.NotContains(t, output, "bad-transport") + assert.Contains(t, output, "my-server") +} + +func TestInit_SkipsNilBackends(t *testing.T) { + t.Parallel() + + nilWorkload := workloads.TypedWorkload{Name: "not-ready", Type: workloads.WorkloadTypeMCPServer} + + disc := newDiscovererMock(t) + disc.EXPECT().ListWorkloadsInGroup(gomock.Any(), "test-group").Return( + []workloads.TypedWorkload{nilWorkload, testWorkload}, nil, + ) + disc.EXPECT().GetWorkloadAsVMCPBackend(gomock.Any(), nilWorkload).Return(nil, nil) + disc.EXPECT().GetWorkloadAsVMCPBackend(gomock.Any(), testWorkload).Return(testBackend, nil) + + var buf bytes.Buffer + err := Init(context.Background(), InitConfig{ + GroupName: "test-group", + Writer: &buf, + Discoverer: disc, + }) + require.NoError(t, err) + + output := buf.String() + assert.NotContains(t, output, "not-ready") + assert.Contains(t, output, "my-server") +} + +func TestInit_EmptyGroup(t *testing.T) { + t.Parallel() + + disc := newDiscovererMock(t) + disc.EXPECT().ListWorkloadsInGroup(gomock.Any(), "empty-group").Return([]workloads.TypedWorkload{}, nil) + + var buf bytes.Buffer + err := Init(context.Background(), InitConfig{ + GroupName: "empty-group", + Writer: &buf, + Discoverer: disc, + }) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "groupRef: empty-group") + assert.Contains(t, output, "backends: []") +} + +func TestInit_DiscoveryError(t *testing.T) { + t.Parallel() + + disc := newDiscovererMock(t) + disc.EXPECT().ListWorkloadsInGroup(gomock.Any(), "test-group").Return(nil, errors.New("connection refused")) + + err := Init(context.Background(), InitConfig{ + GroupName: "test-group", + Writer: &bytes.Buffer{}, + Discoverer: disc, + }) + require.Error(t, err) + assert.ErrorContains(t, err, "failed to list workloads in group") + assert.ErrorContains(t, err, "connection refused") +} + +func TestInit_RenderedYAMLIsValid(t *testing.T) { + t.Parallel() + + disc := newDiscovererMock(t) + disc.EXPECT().ListWorkloadsInGroup(gomock.Any(), "test-group").Return([]workloads.TypedWorkload{testWorkload}, nil) + disc.EXPECT().GetWorkloadAsVMCPBackend(gomock.Any(), testWorkload).Return(testBackend, nil) + + path := filepath.Join(t.TempDir(), "vmcp.yaml") + err := Init(context.Background(), InitConfig{ + GroupName: "test-group", + OutputPath: path, + Discoverer: disc, + }) + require.NoError(t, err) + + loader := vmcpconfig.NewYAMLLoader(path, &env.OSReader{}) + cfg, err := loader.Load() + require.NoError(t, err) + + validator := vmcpconfig.NewValidator() + require.NoError(t, validator.Validate(cfg)) + + assert.Equal(t, "test-group", cfg.Group) + assert.Equal(t, "test-group-vmcp", cfg.Name) + require.Len(t, cfg.Backends, 1) + assert.Equal(t, "my-server", cfg.Backends[0].Name) + assert.Equal(t, "http://127.0.0.1:9001/mcp", cfg.Backends[0].URL) + assert.Equal(t, "streamable-http", cfg.Backends[0].Transport) +} + +func TestInit_OutputFilePermissions(t *testing.T) { + t.Parallel() + + disc := newDiscovererMock(t) + disc.EXPECT().ListWorkloadsInGroup(gomock.Any(), "test-group").Return([]workloads.TypedWorkload{}, nil) + + path := filepath.Join(t.TempDir(), "vmcp.yaml") + err := Init(context.Background(), InitConfig{ + GroupName: "test-group", + OutputPath: path, + Discoverer: disc, + }) + require.NoError(t, err) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestInit_NilDiscoverer(t *testing.T) { + t.Parallel() + + err := Init(context.Background(), InitConfig{ + GroupName: "test-group", + }) + require.Error(t, err) + assert.ErrorContains(t, err, "discoverer is required") +} + +func TestInit_EmptyGroupName(t *testing.T) { + t.Parallel() + + err := Init(context.Background(), InitConfig{ + Discoverer: newDiscovererMock(t), + }) + require.Error(t, err) + assert.ErrorContains(t, err, "invalid group name") +} From e4bfbfe3fba091f0fbd504a8e8475cf7987f7768 Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota <yolanda@stacklok.com> Date: Fri, 17 Apr 2026 18:09:11 +0200 Subject: [PATCH 075/117] Add thv vmcp serve and thv vmcp validate subcommands (#4900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create cmd/thv/app/vmcp.go with newVMCPCommand(), newVMCPServeCommand(), and newVMCPValidateCommand() — thin wrappers that parse flags and delegate to pkg/vmcp/cli/Serve and pkg/vmcp/cli/Validate respectively. Register the command in NewRootCmd() and add "vmcp" to the informationalCommands map so it bypasses container runtime preflight. Regenerate CLI docs via task docs. This is Phase 2 of RFC THV-0059, making thv vmcp serve and thv vmcp validate available to users for the first time. Closes #4883 Co-authored-by: taskbot <taskbot@users.noreply.github.com> --- cmd/thv/app/commands.go | 7 ++- cmd/thv/app/vmcp.go | 81 +++++++++++++++++++++++++++++++ cmd/thv/main.go | 9 +++- docs/cli/thv.md | 1 + docs/cli/thv_vmcp.md | 39 +++++++++++++++ docs/cli/thv_vmcp_serve.md | 47 ++++++++++++++++++ docs/cli/thv_vmcp_validate.md | 44 +++++++++++++++++ pkg/migration/migration.go | 20 ++++---- pkg/vmcp/aggregator/discoverer.go | 2 +- pkg/vmcp/cli/serve.go | 16 +++++- pkg/vmcp/cli/serve_test.go | 29 +++++++++++ 11 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 cmd/thv/app/vmcp.go create mode 100644 docs/cli/thv_vmcp.md create mode 100644 docs/cli/thv_vmcp_serve.md create mode 100644 docs/cli/thv_vmcp_validate.md diff --git a/cmd/thv/app/commands.go b/cmd/thv/app/commands.go index 8bc78cc0a0..14fe7bc348 100644 --- a/cmd/thv/app/commands.go +++ b/cmd/thv/app/commands.go @@ -70,6 +70,7 @@ func NewRootCmd(enableUpdates bool) *cobra.Command { rootCmd.AddCommand(newSecretCommand()) rootCmd.AddCommand(inspectorCommand()) rootCmd.AddCommand(newMCPCommand()) + rootCmd.AddCommand(newVMCPCommand()) rootCmd.AddCommand(groupCmd) rootCmd.AddCommand(skillCmd) rootCmd.AddCommand(statusCmd) @@ -100,7 +101,10 @@ func IsInformationalCommand(args []string) bool { command := args[1] - // Commands that are entirely informational and don't need container runtime + // Commands that don't need container runtime or startup migrations. + // "vmcp" is safe here: telemetry/secret-scope migrations only affect thv run state, + // and EnsureDefaultGroupExists is called inside pkg/vmcp/cli/Serve when dynamic + // backend discovery is used (i.e. when no static backends are configured). informationalCommands := map[string]bool{ "version": true, "search": true, @@ -108,6 +112,7 @@ func IsInformationalCommand(args []string) bool { "registry": true, "mcp": true, "skill": true, + "vmcp": true, } return informationalCommands[command] diff --git a/cmd/thv/app/vmcp.go b/cmd/thv/app/vmcp.go new file mode 100644 index 0000000000..16507fa18c --- /dev/null +++ b/cmd/thv/app/vmcp.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "github.com/spf13/cobra" + + vmcpcli "github.com/stacklok/toolhive/pkg/vmcp/cli" +) + +// newVMCPCommand returns the top-level "vmcp" Cobra command with subcommands attached. +func newVMCPCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "vmcp", + Short: "Run and manage a Virtual MCP Server locally", + Long: `The vmcp command provides subcommands to run and validate a Virtual MCP +Server (vMCP) locally without Kubernetes. A vMCP aggregates multiple MCP +servers from a ToolHive group into a single unified endpoint.`, + } + cmd.AddCommand(newVMCPServeCommand()) + cmd.AddCommand(newVMCPValidateCommand()) + return cmd +} + +// newVMCPServeCommand returns the "vmcp serve" subcommand. +func newVMCPServeCommand() *cobra.Command { + var ( + configPath string + host string + port int + enableAudit bool + ) + cmd := &cobra.Command{ + Use: "serve", + Short: "Start the Virtual MCP Server", + Long: `Start the Virtual MCP Server to aggregate and proxy multiple MCP servers. + +The server reads the configuration file specified by --config and starts +listening for MCP client connections, aggregating tools, resources, and +prompts from all configured backend MCP servers.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return vmcpcli.Serve(cmd.Context(), vmcpcli.ServeConfig{ + ConfigPath: configPath, + Host: host, + Port: port, + EnableAudit: enableAudit, + }) + }, + } + cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to vMCP configuration file (required)") + cmd.Flags().StringVar(&host, "host", "127.0.0.1", "Host address to bind to") + cmd.Flags().IntVar(&port, "port", 4483, "Port to listen on") + cmd.Flags().BoolVar(&enableAudit, "enable-audit", false, "Enable audit logging with default configuration") + _ = cmd.MarkFlagRequired("config") + return cmd +} + +// newVMCPValidateCommand returns the "vmcp validate" subcommand. +func newVMCPValidateCommand() *cobra.Command { + var configPath string + cmd := &cobra.Command{ + Use: "validate", + Short: "Validate a vMCP configuration file", + Long: `Validate the vMCP configuration file for syntax and semantic errors. + +This command checks YAML syntax, required field presence, middleware +configuration correctness, and backend configuration validity. Exits 0 +for valid configurations, non-zero with a descriptive error otherwise.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return vmcpcli.Validate(cmd.Context(), vmcpcli.ValidateConfig{ + ConfigPath: configPath, + }) + }, + } + cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to vMCP configuration file (required)") + _ = cmd.MarkFlagRequired("config") + return cmd +} diff --git a/cmd/thv/main.go b/cmd/thv/main.go index 7e9d37fc77..fb03b8282e 100644 --- a/cmd/thv/main.go +++ b/cmd/thv/main.go @@ -66,8 +66,13 @@ func main() { // Renames bare system keys (BEARER_TOKEN_, REGISTRY_OAUTH_, etc.) to __thv_<scope>_ namespace migration.CheckAndPerformSecretScopeMigration() - // Ensure default group exists (creates it for fresh installs, no-op otherwise) - migration.EnsureDefaultGroupExists() + // Ensure the default group exists on fresh installs so that commands + // which default to --group default (e.g. run, list) work without the + // user having to create the group manually. + if err := migration.EnsureDefaultGroupExists(); err != nil { + slog.Error("failed to ensure default group exists", "error", err) + os.Exit(1) + } } cmd := app.NewRootCmd(!app.IsCompletionCommand(os.Args)) diff --git a/docs/cli/thv.md b/docs/cli/thv.md index 9f6b4ac6db..21660ca8e3 100644 --- a/docs/cli/thv.md +++ b/docs/cli/thv.md @@ -57,4 +57,5 @@ thv [flags] * [thv status](thv_status.md) - Show detailed status of an MCP server * [thv stop](thv_stop.md) - Stop one or more MCP servers * [thv version](thv_version.md) - Show the version of ToolHive +* [thv vmcp](thv_vmcp.md) - Run and manage a Virtual MCP Server locally diff --git a/docs/cli/thv_vmcp.md b/docs/cli/thv_vmcp.md new file mode 100644 index 0000000000..28652e90e7 --- /dev/null +++ b/docs/cli/thv_vmcp.md @@ -0,0 +1,39 @@ +--- +title: thv vmcp +hide_title: true +description: Reference for ToolHive CLI command `thv vmcp` +last_update: + author: autogenerated +slug: thv_vmcp +mdx: + format: md +--- + +## thv vmcp + +Run and manage a Virtual MCP Server locally + +### Synopsis + +The vmcp command provides subcommands to run and validate a Virtual MCP +Server (vMCP) locally without Kubernetes. A vMCP aggregates multiple MCP +servers from a ToolHive group into a single unified endpoint. + +### Options + +``` + -h, --help help for vmcp +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers +* [thv vmcp serve](thv_vmcp_serve.md) - Start the Virtual MCP Server +* [thv vmcp validate](thv_vmcp_validate.md) - Validate a vMCP configuration file + diff --git a/docs/cli/thv_vmcp_serve.md b/docs/cli/thv_vmcp_serve.md new file mode 100644 index 0000000000..d3ce081581 --- /dev/null +++ b/docs/cli/thv_vmcp_serve.md @@ -0,0 +1,47 @@ +--- +title: thv vmcp serve +hide_title: true +description: Reference for ToolHive CLI command `thv vmcp serve` +last_update: + author: autogenerated +slug: thv_vmcp_serve +mdx: + format: md +--- + +## thv vmcp serve + +Start the Virtual MCP Server + +### Synopsis + +Start the Virtual MCP Server to aggregate and proxy multiple MCP servers. + +The server reads the configuration file specified by --config and starts +listening for MCP client connections, aggregating tools, resources, and +prompts from all configured backend MCP servers. + +``` +thv vmcp serve [flags] +``` + +### Options + +``` + -c, --config string Path to vMCP configuration file (required) + --enable-audit Enable audit logging with default configuration + -h, --help help for serve + --host string Host address to bind to (default "127.0.0.1") + --port int Port to listen on (default 4483) +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv vmcp](thv_vmcp.md) - Run and manage a Virtual MCP Server locally + diff --git a/docs/cli/thv_vmcp_validate.md b/docs/cli/thv_vmcp_validate.md new file mode 100644 index 0000000000..0f88282be0 --- /dev/null +++ b/docs/cli/thv_vmcp_validate.md @@ -0,0 +1,44 @@ +--- +title: thv vmcp validate +hide_title: true +description: Reference for ToolHive CLI command `thv vmcp validate` +last_update: + author: autogenerated +slug: thv_vmcp_validate +mdx: + format: md +--- + +## thv vmcp validate + +Validate a vMCP configuration file + +### Synopsis + +Validate the vMCP configuration file for syntax and semantic errors. + +This command checks YAML syntax, required field presence, middleware +configuration correctness, and backend configuration validity. Exits 0 +for valid configurations, non-zero with a descriptive error otherwise. + +``` +thv vmcp validate [flags] +``` + +### Options + +``` + -c, --config string Path to vMCP configuration file (required) + -h, --help help for validate +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv vmcp](thv_vmcp.md) - Run and manage a Virtual MCP Server locally + diff --git a/pkg/migration/migration.go b/pkg/migration/migration.go index 2e3575c171..8b9d781a32 100644 --- a/pkg/migration/migration.go +++ b/pkg/migration/migration.go @@ -7,24 +7,22 @@ package migration import ( "context" "log/slog" - "sync" + "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/groups" ) -// ensureDefaultGroupOnce ensures the default group check only runs once per process -var ensureDefaultGroupOnce sync.Once - // EnsureDefaultGroupExists ensures the default group exists, creating it if necessary. // This is called at application startup for fresh installs and is a no-op when // the group already exists (e.g. after a previous migration or existing setup). -func EnsureDefaultGroupExists() { - ensureDefaultGroupOnce.Do(func() { - if err := ensureDefaultGroupExists(context.Background()); err != nil { - slog.Error("failed to ensure default group exists", "error", err) - return - } - }) +// In Kubernetes environments this is always a no-op: MCPGroup CRDs are +// operator/user-managed resources and the caller's service account may not +// have create permission on them. +func EnsureDefaultGroupExists() error { + if runtime.IsKubernetesRuntime() { + return nil + } + return ensureDefaultGroupExists(context.Background()) } func ensureDefaultGroupExists(ctx context.Context) error { diff --git a/pkg/vmcp/aggregator/discoverer.go b/pkg/vmcp/aggregator/discoverer.go index a40dd740a0..4a26b9937a 100644 --- a/pkg/vmcp/aggregator/discoverer.go +++ b/pkg/vmcp/aggregator/discoverer.go @@ -160,7 +160,7 @@ func (d *backendDiscoverer) Discover(ctx context.Context, groupRef string) (back return nil, fmt.Errorf("failed to check if group exists: %w", err) } if !exists { - return nil, fmt.Errorf("group %s not found", groupRef) + return nil, fmt.Errorf("%w: %s", groups.ErrGroupNotFound, groupRef) } // Get all typedWorkloads in the group diff --git a/pkg/vmcp/cli/serve.go b/pkg/vmcp/cli/serve.go index abe1e40a8a..45a78e7276 100644 --- a/pkg/vmcp/cli/serve.go +++ b/pkg/vmcp/cli/serve.go @@ -28,6 +28,7 @@ import ( "github.com/stacklok/toolhive/pkg/authserver/server/keys" "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/migration" "github.com/stacklok/toolhive/pkg/telemetry" "github.com/stacklok/toolhive/pkg/versions" "github.com/stacklok/toolhive/pkg/vmcp" @@ -418,8 +419,14 @@ func discoverBackends( cfg.Group, ) } else { - // Dynamic mode: discover backends at runtime from K8s API. + // Dynamic mode: discover backends at runtime from the active workload manager (K8s or local). slog.Info("dynamic mode: initializing group manager for backend discovery") + // EnsureDefaultGroupExists is a no-op in Kubernetes (service account has no + // create permission on MCPGroup CRDs). If the group does not exist, + // Discover returns ErrGroupNotFound which is handled below. + if err := migration.EnsureDefaultGroupExists(); err != nil { + return nil, nil, nil, fmt.Errorf("failed to ensure default group exists: %w", err) + } groupsManager, err := groups.NewManager() if err != nil { return nil, nil, nil, fmt.Errorf("failed to create groups manager: %w", err) @@ -447,6 +454,13 @@ func runDiscovery( slog.Info(fmt.Sprintf("Discovering backends in group: %s", groupRef)) backends, err := discoverer.Discover(ctx, groupRef) if err != nil { + // In Kubernetes mode the MCPGroup CRD is operator/user-managed and may + // not exist yet. Treat a missing group as zero backends so vMCP can + // start and serve once backends are registered later. + if runtime.IsKubernetesRuntime() && errors.Is(err, groups.ErrGroupNotFound) { + slog.Warn(fmt.Sprintf("Group %s not found - vmcp will start but have no backends to proxy", groupRef)) + return []vmcp.Backend{}, backendClient, outgoingRegistry, nil + } return nil, nil, nil, fmt.Errorf("failed to discover backends: %w", err) } diff --git a/pkg/vmcp/cli/serve_test.go b/pkg/vmcp/cli/serve_test.go index 76e5394033..022ebe7a4a 100644 --- a/pkg/vmcp/cli/serve_test.go +++ b/pkg/vmcp/cli/serve_test.go @@ -4,6 +4,7 @@ package cli import ( + "fmt" "os" "path/filepath" "testing" @@ -14,6 +15,7 @@ import ( "gopkg.in/yaml.v3" authserverconfig "github.com/stacklok/toolhive/pkg/authserver" + "github.com/stacklok/toolhive/pkg/groups" "github.com/stacklok/toolhive/pkg/vmcp" aggregatormocks "github.com/stacklok/toolhive/pkg/vmcp/aggregator/mocks" clientmocks "github.com/stacklok/toolhive/pkg/vmcp/client/mocks" @@ -203,6 +205,33 @@ func TestCreateSessionFactory_NoSecretKubernetes(t *testing.T) { require.Nil(t, factory) } +// TestRunDiscovery_KubernetesGroupNotFound exercises the Kubernetes-specific branch +// in runDiscovery where ErrGroupNotFound is treated as a non-fatal condition. +// vMCP should start with zero backends and return nil error so it can begin +// serving before the MCPGroup CRD is created by the operator. +func TestRunDiscovery_KubernetesGroupNotFound(t *testing.T) { + // Cannot run in parallel: t.Setenv modifies the process environment. + t.Setenv("TOOLHIVE_RUNTIME", "kubernetes") + + ctrl := gomock.NewController(t) + discoverer := aggregatormocks.NewMockBackendDiscoverer(ctrl) + backendClient := vmcpmocks.NewMockBackendClient(ctrl) + registry := clientmocks.NewMockOutgoingAuthRegistry(ctrl) + + const groupRef = "test-group" + discoverer.EXPECT(). + Discover(gomock.Any(), groupRef). + Return(nil, fmt.Errorf("wrapped: %w", groups.ErrGroupNotFound)) + + backends, gotClient, gotRegistry, err := runDiscovery(t.Context(), groupRef, discoverer, backendClient, registry) + + require.NoError(t, err) + assert.NotNil(t, backends) + assert.Empty(t, backends) + assert.Same(t, backendClient, gotClient) + assert.Same(t, registry, gotRegistry) +} + // TestRunDiscovery_ZeroBackends exercises the branch in runDiscovery where the // discoverer succeeds but returns no backends. The function must return a // non-error, an empty (non-nil) backend slice, and pass through the client and From 4ebcf3bb3716bc3d13246ee39d002d319431c628 Mon Sep 17 00:00:00 2001 From: Dan Barr <danbarr@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:22:13 -0400 Subject: [PATCH 076/117] Update README (#4915) Update README intro sections Signed-off-by: Dan Barr <6922515+danbarr@users.noreply.github.com> Co-authored-by: Dan Barr <6922515+danbarr@users.noreply.github.com> --- README.md | 55 +++++++++++++++++++++++++++---------------------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 2e14a9dc70..93d0ce0c8f 100644 --- a/README.md +++ b/README.md @@ -5,48 +5,47 @@ <br> +# The open source MCP platform trusted by developers and enterprises + [![Release][release-img]][release] [![Build status][ci-img]][ci] [![Coverage Status][coveralls-img]][coveralls] [![License: Apache 2.0][license-img]][license] [![Star on GitHub][stars-img]][stars] [![Discord][discord-img]][discord] -# ToolHive - simplify and secure MCP servers +## Run any MCP server securely, instantly, anywhere. + +ToolHive runs every MCP server in an isolated container, enforces identity and access policy per request, and gives platform teams the observability they need to put MCP in production. -**Run any Model Context Protocol (MCP) server: securely, instantly, anywhere.** +## Why ToolHive? -ToolHive includes everything you need to use MCP servers in production. Rather than build or combine components yourself, use ToolHive's Registry Server, Runtime, Gateway, and Portal to get up and running quickly and safely. +Here are some of the more common use cases for ToolHive: -ToolHive keeps you in control of your MCP estate. Integrate with popular clients in seconds and deploy pre-vetted MCP servers in locked-down containers with a single click or command. ToolHive is available as a desktop app, web app, CLI, and Kubernetes operator. +<table> + <tr valign="top"> + <td><strong>Developers.</strong> Run MCP servers with more security and more (token) savings</td> + <td><strong>Platform Engineers.</strong> Run MCP on your existing Kubernetes infrastructure</td> + <td><strong>Enterprises.</strong> Self-host MCP servers and stay in control of your data</td> + </tr> + <tr valign="top"> + <td>Connect Claude Code, Cursor, GitHub Copilot, or your preferred client to MCP servers with a single click or command.<br><br> + ToolHive wraps every MCP server in an isolated container with a minimal permission file (no local credentials) and uses semantic tool search to reduce your token usage by up to 85%.</td> + <td>Put an end to shadow MCP use by your developers, and give your security team the audit logs and identity enforcement they require.<br><br> + ToolHive includes a Kubernetes operator, so you can declare policies, integrate with your IdP and observability stack, emit OTel traces, and more … all with familiar tools and patterns.</td> + <td>Most MCP solutions are SaaS, but your compliance requirements prohibit sensitive info from being processed by SaaS providers.<br><br> + ToolHive is the exception that allows you to self-host your MCP registry, gateway, etc. You can pilot the entire platform, and when you’re ready to scale, Stacklok’s got the added capabilities and expert team ready!</td> + </tr> + <tr valign="top"> + <td><a href="https://stacklok.com/download/">Download ToolHive and get started</a></td> + <td><a href="https://docs.stacklok.com/toolhive/guides-k8s/">Explore the Kubernetes operator in our docs</a><br><br><a href="https://stacklok.com/resources/how-to-run-ai-agents-on-kubernetes">Read more about running MCP on Kubernetes</a></td> + <td><a href="https://stacklok.com/platform/">Learn more about Stacklok’s platform</a><br><br><a href="https://docs.stacklok.com/toolhive/enterprise">Compare open source ToolHive and Stacklok Enterprise</a></td> + </tr> +</table> <picture> <source media="(prefers-color-scheme: dark)" srcset="docs/images/toolhive-diagram-dark.svg"> <img src="docs/images/toolhive-diagram-light.svg" alt="ToolHive diagram" width="800" style="padding: 20px 0" /> </picture> ---- - -<table> -<tr> -<td width="50%"> - -## Why ToolHive? - -- **Instant deployment:** Start any MCP server with one click or command, using Docker or Kubernetes. -- **Secure by default:** Every server runs in an isolated container with only the permissions it needs. Secrets are managed securely, never in plaintext. -- **Works everywhere:** Use the UI and CLI for local development, or the Kubernetes Operator for production and scale. -- **Seamless integration:** ToolHive auto-configures popular clients like GitHub Copilot, Cursor, VS Code Server, and more. - -<br> -</td> -<td width="50%" align="center"> - <picture> - <source media="(prefers-color-scheme: dark)" srcset="docs/images/toolhive-sources-dark.svg"> - <img src="docs/images/toolhive-sources-light.svg" alt="ToolHive sources diagram" width="400px" /> - </picture> -</td> -</tr> -</table> - ## Quick links - 📥 [Downloads](https://stacklok.com/download/) From 213638793dc7b745b441d04e5f073afd007cd77e Mon Sep 17 00:00:00 2001 From: Laurel Orr <57237365+lorr1@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:39:42 -0700 Subject: [PATCH 077/117] Treat unauthenticated backends as routable in vMCP health (#4866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Treat unauthenticated backends as routable in vMCP health monitoring Backends returning 401 due to missing user-level OAuth tokens (e.g., upstreamInject auth) are reachable and running — they just require per-request user auth. Health probes lack user tokens, so they correctly detect the unauthenticated state, but this should not cause PhaseFailed. Introduce Summary.Routable() (healthy + unauthenticated) and use it for phase determination, BackendCount, and status messages across the health monitor, operator controller, and status collector. Give BackendUnauthenticated its own CRD status "unauthenticated" instead of mapping to "unavailable". Fixes #4824 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address review feedback on unauthenticated backends PR - Fix godoc: move pluralBackend() above formatBackendMessage doc comment - Fix grammar: add pluralRequire()/quantifyBackends() helpers for correct subject-verb agreement ("1 requires" not "1 require") and drop "All" prefix for singular counts - Fix /status endpoint: treat BackendUnauthenticated as routable, consistent with health monitor - Rename condition Reason from AllBackendsHealthy to AllBackendsRoutable (v1alpha1, no stability guarantee) - Add TestBuildConditions cases for unauthenticated branches - Regenerate CRD manifests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Regenerate CRD docs for BackendCount description update Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update TODO to reference follow-up issue #4920 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Rename NoHealthyBackends condition reason to NoRoutableBackends Aligns with the AllBackendsHealthy → AllBackendsRoutable rename already applied to the ready-phase reason string. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../api/v1alpha1/virtualmcpserver_types.go | 13 +-- .../virtualmcpserver_controller.go | 25 ++--- .../pkg/virtualmcpserverstatus/collector.go | 12 ++- ...olhive.stacklok.dev_virtualmcpservers.yaml | 6 +- ...olhive.stacklok.dev_virtualmcpservers.yaml | 6 +- docs/operator/crd-api.md | 2 +- pkg/vmcp/discovery/middleware.go | 4 + pkg/vmcp/health/monitor.go | 92 +++++++++++++++---- pkg/vmcp/health/status_builder_test.go | 86 ++++++++++++++++- pkg/vmcp/server/status.go | 4 +- pkg/vmcp/status/k8s_reporter_test.go | 16 ++-- pkg/vmcp/types.go | 8 +- pkg/vmcp/types_test.go | 14 +-- .../virtualmcp_yardstick_base_test.go | 2 +- 14 files changed, 218 insertions(+), 72 deletions(-) diff --git a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go index 50509d1d1b..d71947b483 100644 --- a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go +++ b/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go @@ -181,10 +181,11 @@ type BackendAuthConfig struct { // These are the user-facing values stored in VirtualMCPServer.Status.DiscoveredBackends. // Use BackendHealthStatus.ToCRDStatus() to convert from internal health status. const ( - BackendStatusReady = "ready" - BackendStatusUnavailable = "unavailable" - BackendStatusDegraded = "degraded" - BackendStatusUnknown = "unknown" + BackendStatusReady = "ready" + BackendStatusUnavailable = "unavailable" + BackendStatusDegraded = "degraded" + BackendStatusUnknown = "unknown" + BackendStatusUnauthenticated = "unauthenticated" ) // DiscoveredBackend is an alias to the canonical definition in pkg/vmcp/types.go @@ -222,8 +223,8 @@ type VirtualMCPServerStatus struct { // +optional DiscoveredBackends []DiscoveredBackend `json:"discoveredBackends,omitempty"` - // BackendCount is the number of healthy/ready backends - // (excludes unavailable, degraded, and unknown backends) + // BackendCount is the number of routable backends (ready + unauthenticated). + // Excludes unavailable, degraded, and unknown backends. // +optional BackendCount int32 `json:"backendCount,omitempty"` diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index 5070f3c7ed..911bb4b811 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -1514,14 +1514,17 @@ type statusDecision struct { conditionState metav1.ConditionStatus } -// countBackendHealth counts ready and unhealthy backends -func countBackendHealth(ctx context.Context, backends []mcpv1alpha1.DiscoveredBackend) (ready, unhealthy int) { +// countBackendHealth counts routable and unhealthy backends. +// Unauthenticated backends are routable — they are reachable but require per-request +// user auth (e.g., upstream OAuth). Health probes lack user tokens, but real requests +// with valid OAuth tokens will be served. +func countBackendHealth(ctx context.Context, backends []mcpv1alpha1.DiscoveredBackend) (routable, unhealthy int) { ctxLogger := log.FromContext(ctx) for _, backend := range backends { switch backend.Status { - case mcpv1alpha1.BackendStatusReady: - ready++ + case mcpv1alpha1.BackendStatusReady, mcpv1alpha1.BackendStatusUnauthenticated: + routable++ case mcpv1alpha1.BackendStatusUnavailable, mcpv1alpha1.BackendStatusDegraded, mcpv1alpha1.BackendStatusUnknown: @@ -1532,7 +1535,7 @@ func countBackendHealth(ctx context.Context, backends []mcpv1alpha1.DiscoveredBa unhealthy++ } } - return ready, unhealthy + return routable, unhealthy } // determineStatusFromBackends evaluates backend health to determine status @@ -1542,11 +1545,11 @@ func (*VirtualMCPServerReconciler) determineStatusFromBackends( ) statusDecision { ctxLogger := log.FromContext(ctx) - ready, unhealthy := countBackendHealth(ctx, vmcp.Status.DiscoveredBackends) - total := ready + unhealthy + routable, unhealthy := countBackendHealth(ctx, vmcp.Status.DiscoveredBackends) + total := routable + unhealthy // All backends unhealthy - if ready == 0 && unhealthy > 0 { + if routable == 0 && unhealthy > 0 { return statusDecision{ phase: mcpv1alpha1.VirtualMCPServerPhaseDegraded, message: fmt.Sprintf("Virtual MCP server is running but all %d backends are unhealthy", unhealthy), @@ -1560,15 +1563,15 @@ func (*VirtualMCPServerReconciler) determineStatusFromBackends( if unhealthy > 0 { return statusDecision{ phase: mcpv1alpha1.VirtualMCPServerPhaseDegraded, - message: fmt.Sprintf("Virtual MCP server is running with %d/%d backends available", ready, total), + message: fmt.Sprintf("Virtual MCP server is running with %d/%d backends available", routable, total), reason: "BackendsDegraded", conditionMsg: "Some backends are unhealthy", conditionState: metav1.ConditionFalse, } } - // All backends ready - if ready > 0 { + // All backends routable + if routable > 0 { return statusDecision{ phase: mcpv1alpha1.VirtualMCPServerPhaseReady, message: "Virtual MCP server is running", diff --git a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go index 5cf066a6b6..5d3b9a29fd 100644 --- a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go +++ b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go @@ -211,14 +211,16 @@ func (s *StatusCollector) UpdateStatus(ctx context.Context, vmcpStatus *mcpv1alp // Apply discovered backends change if s.discoveredBackends != nil { vmcpStatus.DiscoveredBackends = s.discoveredBackends - // BackendCount represents the number of ready backends - var readyCount int32 + // BackendCount represents the number of routable backends (ready + unauthenticated). + // Unauthenticated backends are reachable but require per-request user auth. + var routableCount int32 for _, backend := range s.discoveredBackends { - if backend.Status == mcpv1alpha1.BackendStatusReady { - readyCount++ + if backend.Status == mcpv1alpha1.BackendStatusReady || + backend.Status == mcpv1alpha1.BackendStatusUnauthenticated { + routableCount++ } } - vmcpStatus.BackendCount = readyCount + vmcpStatus.BackendCount = routableCount } ctxLogger.V(1).Info("Batched status update applied", diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index d9d0552db2..7502cd9021 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -2142,8 +2142,8 @@ spec: properties: backendCount: description: |- - BackendCount is the number of healthy/ready backends - (excludes unavailable, degraded, and unknown backends) + BackendCount is the number of routable backends (ready + unauthenticated). + Excludes unavailable, degraded, and unknown backends. format: int32 type: integer conditions: @@ -2256,7 +2256,7 @@ spec: type: string status: description: |- - Status is the current status of the backend (ready, degraded, unavailable, unknown). + Status is the current status of the backend (ready, degraded, unavailable, unauthenticated, unknown). Use BackendHealthStatus.ToCRDStatus() to populate this field. type: string url: diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 1c075ae8a5..04b58af0a3 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -2145,8 +2145,8 @@ spec: properties: backendCount: description: |- - BackendCount is the number of healthy/ready backends - (excludes unavailable, degraded, and unknown backends) + BackendCount is the number of routable backends (ready + unauthenticated). + Excludes unavailable, degraded, and unknown backends. format: int32 type: integer conditions: @@ -2259,7 +2259,7 @@ spec: type: string status: description: |- - Status is the current status of the backend (ready, degraded, unavailable, unknown). + Status is the current status of the backend (ready, degraded, unavailable, unauthenticated, unknown). Use BackendHealthStatus.ToCRDStatus() to populate this field. type: string url: diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index d514fa4e33..0453460af2 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -3332,7 +3332,7 @@ _Appears in:_ | `message` _string_ | Message provides additional information about the current phase | | Optional: \{\} <br /> | | `url` _string_ | URL is the URL where the Virtual MCP server can be accessed | | Optional: \{\} <br /> | | `discoveredBackends` _[api.v1alpha1.DiscoveredBackend](#apiv1alpha1discoveredbackend) array_ | DiscoveredBackends lists discovered backend configurations from the MCPGroup | | Optional: \{\} <br /> | -| `backendCount` _integer_ | BackendCount is the number of healthy/ready backends<br />(excludes unavailable, degraded, and unknown backends) | | Optional: \{\} <br /> | +| `backendCount` _integer_ | BackendCount is the number of routable backends (ready + unauthenticated).<br />Excludes unavailable, degraded, and unknown backends. | | Optional: \{\} <br /> | | `oidcConfigHash` _string_ | OIDCConfigHash is the hash of the referenced MCPOIDCConfig spec for change detection.<br />Only populated when IncomingAuth.OIDCConfigRef is set. | | Optional: \{\} <br /> | | `telemetryConfigHash` _string_ | TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection.<br />Only populated when TelemetryConfigRef is set. | | Optional: \{\} <br /> | diff --git a/pkg/vmcp/discovery/middleware.go b/pkg/vmcp/discovery/middleware.go index 7b4a9052a7..ddd8c09e9b 100644 --- a/pkg/vmcp/discovery/middleware.go +++ b/pkg/vmcp/discovery/middleware.go @@ -135,6 +135,10 @@ func Middleware( // or degraded. Backends that are unhealthy, unknown, or unauthenticated are excluded // from capability aggregation to prevent exposing tools from unavailable backends. // +// TODO(#4920): Unauthenticated backends are treated as routable for phase determination +// but are excluded here because discovery probes cannot carry user tokens. If health +// probes could authenticate, these backends would be fully healthy and included here. +// // Health status filtering: // - healthy: included (fully operational) // - degraded: included (slow but working) diff --git a/pkg/vmcp/health/monitor.go b/pkg/vmcp/health/monitor.go index 29588146d7..75bfd085c5 100644 --- a/pkg/vmcp/health/monitor.go +++ b/pkg/vmcp/health/monitor.go @@ -547,6 +547,13 @@ type Summary struct { Unauthenticated int } +// Routable returns the number of backends that can serve traffic. +// This includes healthy backends and unauthenticated backends (which are +// reachable but require per-request user auth, e.g., upstream OAuth). +func (s Summary) Routable() int { + return s.Healthy + s.Unauthenticated +} + // String returns a human-readable summary. func (s Summary) String() string { return fmt.Sprintf("total=%d healthy=%d degraded=%d unhealthy=%d unknown=%d unauthenticated=%d", @@ -557,11 +564,12 @@ func (s Summary) String() string { // This converts backend health information into the format needed for status reporting // to the Kubernetes API or CLI output. // -// Phase determination: -// - Ready: All backends healthy, or no backends configured (cold start) +// Phase determination (unauthenticated backends are routable — they need per-request user auth +// but are reachable and running): +// - Ready: All backends healthy or unauthenticated, or no backends configured (cold start) // - Pending: Backends configured but no health check data yet (waiting for first check) -// - Degraded: Some backends healthy, some degraded/unhealthy -// - Failed: No healthy backends (and at least one backend exists) +// - Degraded: Some backends routable (healthy/unauthenticated), some degraded/unhealthy +// - Failed: No routable backends (and at least one backend exists) // // Returns a Status instance with current health information and discovered backends. // @@ -592,16 +600,18 @@ func (m *Monitor) BuildStatus() *vmcp.Status { Message: message, Conditions: conditions, DiscoveredBackends: discoveredBackends, - BackendCount: int32(summary.Healthy), //nolint:gosec // healthy count is bounded by backend list size + BackendCount: int32(summary.Routable()), //nolint:gosec // routable count is bounded by backend list size Timestamp: time.Now(), } } // determinePhase determines the overall phase based on backend health. +// Unauthenticated backends are treated as routable — they are reachable and running, +// they just require per-request user auth (e.g., upstream OAuth). // Takes both the health summary and the count of configured backends to distinguish: // - No backends configured (configuredCount==0): Ready (cold start) // - Backends configured but no health data (configuredCount>0 && summary.Total==0): Pending -// - Has health data: Ready/Degraded/Failed based on health status +// - Has health data: Ready/Degraded/Failed based on routable (healthy + unauthenticated) count func determinePhase(summary Summary, configuredBackendCount int) vmcp.Phase { if summary.Total == 0 { // No health data yet - distinguish cold start from waiting for first check @@ -610,10 +620,11 @@ func determinePhase(summary Summary, configuredBackendCount int) vmcp.Phase { } return vmcp.PhasePending // Backends configured but health checks not complete } - if summary.Healthy == summary.Total { + + if summary.Routable() == summary.Total { return vmcp.PhaseReady } - if summary.Healthy == 0 { + if summary.Routable() == 0 { return vmcp.PhaseFailed } return vmcp.PhaseDegraded @@ -629,18 +640,27 @@ func formatStatusMessage(summary Summary, phase vmcp.Phase, configuredBackendCou return fmt.Sprintf("Waiting for initial health checks (%d backends configured)", configuredBackendCount) } if phase == vmcp.PhaseReady { - return fmt.Sprintf("All %d backends healthy", summary.Healthy) + if summary.Unauthenticated == 0 { + return fmt.Sprintf("All %d %s healthy", summary.Healthy, pluralBackend(summary.Healthy)) + } + if summary.Healthy == 0 { + return fmt.Sprintf("%s %s authentication", + quantifyBackends(summary.Unauthenticated), pluralRequire(summary.Unauthenticated)) + } + return fmt.Sprintf("%d %s healthy, %d %s authentication", + summary.Healthy, pluralBackend(summary.Healthy), + summary.Unauthenticated, pluralRequire(summary.Unauthenticated)) } - // Format unhealthy backend counts (shared by Failed and Degraded) - unhealthyDetails := fmt.Sprintf("%d degraded, %d unhealthy, %d unknown, %d unauthenticated", - summary.Degraded, summary.Unhealthy, summary.Unknown, summary.Unauthenticated) + // Format non-routable backend counts (shared by Failed and Degraded) + nonRoutableDetails := fmt.Sprintf("%d degraded, %d unhealthy, %d unknown", + summary.Degraded, summary.Unhealthy, summary.Unknown) if phase == vmcp.PhaseFailed { - return fmt.Sprintf("No healthy backends (%s)", unhealthyDetails) + return fmt.Sprintf("No routable backends (%s)", nonRoutableDetails) } // Degraded - return fmt.Sprintf("%d/%d backends healthy (%s)", summary.Healthy, summary.Total, unhealthyDetails) + return fmt.Sprintf("%d/%d backends routable (%s)", summary.Routable(), summary.Total, nonRoutableDetails) } // convertToDiscoveredBackends converts backend health states to DiscoveredBackend format. @@ -712,6 +732,30 @@ func extractAuthInfo(backend vmcp.Backend) (authConfigRef, authType string) { return backend.AuthConfigRef, backend.AuthConfig.Type } +// pluralBackend returns "backend" or "backends" based on count. +func pluralBackend(n int) string { + if n == 1 { + return "backend" + } + return "backends" +} + +// pluralRequire returns "requires" or "require" based on count for subject-verb agreement. +func pluralRequire(n int) string { + if n == 1 { + return "requires" + } + return "require" +} + +// quantifyBackends returns "All N backends" for plural, "1 backend" for singular. +func quantifyBackends(n int) string { + if n == 1 { + return fmt.Sprintf("%d backend", n) + } + return fmt.Sprintf("All %d backends", n) +} + // formatBackendMessage creates a human-readable message for a backend's health state. // This returns generic error categories to avoid exposing sensitive error details in status. // Detailed errors are logged when they occur (in performHealthCheck) for debugging. @@ -821,19 +865,27 @@ func buildConditions(summary Summary, phase vmcp.Phase, configuredBackendCount i switch phase { case vmcp.PhaseReady: readyCondition.Status = metav1.ConditionTrue - readyCondition.Reason = "AllBackendsHealthy" - // Distinguish cold start (no backends configured) from having healthy backends + readyCondition.Reason = "AllBackendsRoutable" + // Distinguish cold start (no backends configured) from having routable backends if summary.Total == 0 && configuredBackendCount == 0 { readyCondition.Message = "Ready, no backends configured" + } else if summary.Unauthenticated == 0 { + readyCondition.Message = fmt.Sprintf("All %d %s are healthy", + summary.Healthy, pluralBackend(summary.Healthy)) + } else if summary.Healthy == 0 { + readyCondition.Message = fmt.Sprintf("%s %s authentication", + quantifyBackends(summary.Unauthenticated), pluralRequire(summary.Unauthenticated)) } else { - readyCondition.Message = fmt.Sprintf("All %d backends are healthy", summary.Healthy) + readyCondition.Message = fmt.Sprintf("%d %s healthy, %d %s authentication", + summary.Healthy, pluralBackend(summary.Healthy), + summary.Unauthenticated, pluralRequire(summary.Unauthenticated)) } case vmcp.PhaseDegraded: readyCondition.Reason = "SomeBackendsUnhealthy" - readyCondition.Message = fmt.Sprintf("%d/%d backends healthy", summary.Healthy, summary.Total) + readyCondition.Message = fmt.Sprintf("%d/%d backends routable", summary.Routable(), summary.Total) case vmcp.PhaseFailed: - readyCondition.Reason = "NoHealthyBackends" - readyCondition.Message = "No healthy backends available" + readyCondition.Reason = "NoRoutableBackends" + readyCondition.Message = "No routable backends available" case vmcp.PhasePending: readyCondition.Reason = "BackendsPending" readyCondition.Message = fmt.Sprintf("Waiting for initial health checks (%d backends configured)", configuredBackendCount) diff --git a/pkg/vmcp/health/status_builder_test.go b/pkg/vmcp/health/status_builder_test.go index 283f7837ad..89e0e1f0f2 100644 --- a/pkg/vmcp/health/status_builder_test.go +++ b/pkg/vmcp/health/status_builder_test.go @@ -44,7 +44,7 @@ func TestBuildConditions(t *testing.T) { phase: vmcp.PhaseReady, configuredBackendCount: 3, expectedReadyStatus: metav1.ConditionTrue, - expectedReason: "AllBackendsHealthy", + expectedReason: "AllBackendsRoutable", expectedMessage: "All 3 backends are healthy", hasDegradedCond: false, }, @@ -57,7 +57,7 @@ func TestBuildConditions(t *testing.T) { phase: vmcp.PhaseReady, configuredBackendCount: 0, expectedReadyStatus: metav1.ConditionTrue, - expectedReason: "AllBackendsHealthy", + expectedReason: "AllBackendsRoutable", expectedMessage: "Ready, no backends configured", hasDegradedCond: false, }, @@ -84,9 +84,50 @@ func TestBuildConditions(t *testing.T) { phase: vmcp.PhaseFailed, configuredBackendCount: 2, expectedReadyStatus: metav1.ConditionFalse, - expectedReason: "NoHealthyBackends", + expectedReason: "NoRoutableBackends", hasDegradedCond: false, }, + { + name: "all backends unauthenticated", + summary: Summary{ + Total: 2, + Unauthenticated: 2, + }, + phase: vmcp.PhaseReady, + configuredBackendCount: 2, + expectedReadyStatus: metav1.ConditionTrue, + expectedReason: "AllBackendsRoutable", + expectedMessage: "All 2 backends require authentication", + hasDegradedCond: false, + }, + { + name: "mixed healthy and unauthenticated", + summary: Summary{ + Total: 3, + Healthy: 2, + Unauthenticated: 1, + }, + phase: vmcp.PhaseReady, + configuredBackendCount: 3, + expectedReadyStatus: metav1.ConditionTrue, + expectedReason: "AllBackendsRoutable", + expectedMessage: "2 backends healthy, 1 requires authentication", + hasDegradedCond: false, + }, + { + name: "degraded with unauthenticated and degraded backend", + summary: Summary{ + Total: 3, + Healthy: 1, + Unauthenticated: 1, + Degraded: 1, + }, + phase: vmcp.PhaseDegraded, + configuredBackendCount: 3, + expectedReadyStatus: metav1.ConditionFalse, + expectedReason: "SomeBackendsUnhealthy", + hasDegradedCond: true, + }, } for _, tt := range tests { @@ -356,6 +397,45 @@ func TestBuildStatus_PhaseLogic(t *testing.T) { expectedCount: 0, expectedMessage: "Ready, no backends configured", }, + { + name: "all backends unauthenticated", + backendStates: map[string]vmcp.BackendHealthStatus{ + "b1": vmcp.BackendUnauthenticated, + "b2": vmcp.BackendUnauthenticated, + }, + expectedPhase: vmcp.PhaseReady, + expectedCount: 2, + expectedMessage: "All 2 backends require authentication", + }, + { + name: "mixed healthy and unauthenticated", + backendStates: map[string]vmcp.BackendHealthStatus{ + "b1": vmcp.BackendHealthy, + "b2": vmcp.BackendUnauthenticated, + }, + expectedPhase: vmcp.PhaseReady, + expectedCount: 2, + expectedMessage: "1 backend healthy, 1 requires authentication", + }, + { + name: "mixed unauthenticated and unhealthy", + backendStates: map[string]vmcp.BackendHealthStatus{ + "b1": vmcp.BackendUnauthenticated, + "b2": vmcp.BackendUnhealthy, + }, + expectedPhase: vmcp.PhaseDegraded, + expectedCount: 1, + }, + { + name: "mixed healthy unhealthy and unauthenticated", + backendStates: map[string]vmcp.BackendHealthStatus{ + "b1": vmcp.BackendHealthy, + "b2": vmcp.BackendUnhealthy, + "b3": vmcp.BackendUnauthenticated, + }, + expectedPhase: vmcp.PhaseDegraded, + expectedCount: 2, + }, } for _, tt := range tests { diff --git a/pkg/vmcp/server/status.go b/pkg/vmcp/server/status.go index 0eafe5f284..f7f6581e01 100644 --- a/pkg/vmcp/server/status.go +++ b/pkg/vmcp/server/status.go @@ -26,7 +26,7 @@ type StatusResponse struct { // BackendStatus represents the status of a single backend MCP server. type BackendStatus struct { Name string `json:"name"` - Health string `json:"health"` // "healthy", "degraded", "unhealthy", "unknown" + Health string `json:"health"` // "healthy", "degraded", "unhealthy", "unauthenticated", "unknown" Transport string `json:"transport"` // MCP transport protocol AuthType string `json:"auth_type,omitempty"` // "unauthenticated", "header_injection", "token_exchange" } @@ -93,7 +93,7 @@ func (s *Server) buildStatusResponse(ctx context.Context) StatusResponse { } backendStatuses = append(backendStatuses, status) - if healthStatus == vmcp.BackendHealthy { + if healthStatus == vmcp.BackendHealthy || healthStatus == vmcp.BackendUnauthenticated { hasHealthyBackend = true } } diff --git a/pkg/vmcp/status/k8s_reporter_test.go b/pkg/vmcp/status/k8s_reporter_test.go index 4d7340b857..42c6b0c639 100644 --- a/pkg/vmcp/status/k8s_reporter_test.go +++ b/pkg/vmcp/status/k8s_reporter_test.go @@ -337,7 +337,7 @@ func TestK8sReporter_ReportStatus_ConditionUpdates(t *testing.T) { Type: "Ready", Status: metav1.ConditionTrue, LastTransitionTime: metav1.Now(), - Reason: "AllBackendsHealthy", + Reason: "AllBackendsRoutable", Message: "All backends are healthy", }, }, @@ -357,7 +357,7 @@ func TestK8sReporter_ReportStatus_ConditionUpdates(t *testing.T) { require.Len(t, updated.Status.Conditions, 1) assert.Equal(t, "Ready", updated.Status.Conditions[0].Type) assert.Equal(t, metav1.ConditionTrue, updated.Status.Conditions[0].Status) - assert.Equal(t, "AllBackendsHealthy", updated.Status.Conditions[0].Reason) + assert.Equal(t, "AllBackendsRoutable", updated.Status.Conditions[0].Reason) assert.Equal(t, "All backends are healthy", updated.Status.Conditions[0].Message) // Second report: update message while keeping Status True @@ -370,7 +370,7 @@ func TestK8sReporter_ReportStatus_ConditionUpdates(t *testing.T) { Type: "Ready", Status: metav1.ConditionTrue, LastTransitionTime: metav1.Now(), - Reason: "AllBackendsHealthy", + Reason: "AllBackendsRoutable", Message: "All backends are still healthy", }, }, @@ -400,8 +400,8 @@ func TestK8sReporter_ReportStatus_ConditionUpdates(t *testing.T) { Type: "Ready", Status: metav1.ConditionFalse, LastTransitionTime: metav1.Now(), - Reason: "NoHealthyBackends", - Message: "No healthy backends available", + Reason: "NoRoutableBackends", + Message: "No routable backends available", }, }, BackendCount: 0, @@ -418,8 +418,8 @@ func TestK8sReporter_ReportStatus_ConditionUpdates(t *testing.T) { require.NoError(t, err) require.Len(t, updated.Status.Conditions, 1) assert.Equal(t, metav1.ConditionFalse, updated.Status.Conditions[0].Status) - assert.Equal(t, "NoHealthyBackends", updated.Status.Conditions[0].Reason) - assert.Equal(t, "No healthy backends available", updated.Status.Conditions[0].Message) + assert.Equal(t, "NoRoutableBackends", updated.Status.Conditions[0].Reason) + assert.Equal(t, "No routable backends available", updated.Status.Conditions[0].Message) } // TestK8sReporter_ReportStatus_RemovesStaleConditions tests that conditions @@ -486,7 +486,7 @@ func TestK8sReporter_ReportStatus_RemovesStaleConditions(t *testing.T) { Type: "Ready", Status: metav1.ConditionTrue, LastTransitionTime: metav1.Now(), - Reason: "AllBackendsHealthy", + Reason: "AllBackendsRoutable", Message: "All 3 backends healthy", }, }, diff --git a/pkg/vmcp/types.go b/pkg/vmcp/types.go index aec819004e..0418ce5e15 100644 --- a/pkg/vmcp/types.go +++ b/pkg/vmcp/types.go @@ -152,7 +152,7 @@ const ( // - healthy → ready // - degraded → degraded // - unhealthy → unavailable -// - unauthenticated → unavailable (unauthenticated is a reason, not a status) +// - unauthenticated → unauthenticated (backend is reachable but needs per-request user auth) // - unknown → unknown func (s BackendHealthStatus) ToCRDStatus() string { switch s { @@ -160,8 +160,10 @@ func (s BackendHealthStatus) ToCRDStatus() string { return "ready" case BackendDegraded: return "degraded" - case BackendUnhealthy, BackendUnauthenticated: + case BackendUnhealthy: return "unavailable" + case BackendUnauthenticated: + return "unauthenticated" case BackendUnknown: return "unknown" default: @@ -210,7 +212,7 @@ type DiscoveredBackend struct { // +optional URL string `json:"url,omitempty"` - // Status is the current status of the backend (ready, degraded, unavailable, unknown). + // Status is the current status of the backend (ready, degraded, unavailable, unauthenticated, unknown). // Use BackendHealthStatus.ToCRDStatus() to populate this field. // +optional Status string `json:"status,omitempty"` diff --git a/pkg/vmcp/types_test.go b/pkg/vmcp/types_test.go index dcfeea919f..cb88396ef6 100644 --- a/pkg/vmcp/types_test.go +++ b/pkg/vmcp/types_test.go @@ -12,6 +12,7 @@ import ( ) const testStatusUnavailable = "unavailable" +const testStatusUnauthenticated = "unauthenticated" func TestBackendHealthStatus_ToCRDStatus(t *testing.T) { t.Parallel() @@ -37,9 +38,9 @@ func TestBackendHealthStatus_ToCRDStatus(t *testing.T) { expected: testStatusUnavailable, }, { - name: "unauthenticated maps to unavailable", + name: "unauthenticated maps to unauthenticated", status: BackendUnauthenticated, - expected: testStatusUnavailable, + expected: testStatusUnauthenticated, }, { name: "unknown maps to unknown", @@ -83,10 +84,11 @@ func TestBackendHealthStatus_ToCRDStatus_AllHealthStatusesCovered(t *testing.T) // Verify each status maps to a valid CRD status validCRDStatuses := map[string]bool{ - "ready": true, - "degraded": true, - testStatusUnavailable: true, - "unknown": true, + "ready": true, + "degraded": true, + testStatusUnavailable: true, + testStatusUnauthenticated: true, + "unknown": true, } for _, status := range allStatuses { diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go index dc461a23de..ea8ca25263 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go @@ -405,7 +405,7 @@ var _ = Describe("VirtualMCPServer Yardstick Base", Ordered, func() { return fmt.Errorf("new backend %s not found in discovered backends: %v", backend3Name, backendNames) } - // BackendCount only includes healthy backends, so check this separately + // BackendCount includes routable backends (healthy + unauthenticated), so check this separately // We expect all backends to eventually become healthy if status.BackendCount != 3 { return fmt.Errorf("expected 3 healthy backends, got %d (discovered: %v)", status.BackendCount, backendNames) From f5e0dbd221aeb1e7bb6593bf11b7d8b86d918a95 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:33:08 +0300 Subject: [PATCH 078/117] Bump github.com/go-git/go-git/v5 from 5.17.2 to 5.18.0 (#4921) Bumps [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) from 5.17.2 to 5.18.0. - [Release notes](https://github.com/go-git/go-git/releases) - [Commits](https://github.com/go-git/go-git/compare/v5.17.2...v5.18.0) --- updated-dependencies: - dependency-name: github.com/go-git/go-git/v5 dependency-version: 5.18.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3c0de8462e..92c9916a5c 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 github.com/go-chi/chi/v5 v5.2.5 github.com/go-git/go-billy/v5 v5.8.0 - github.com/go-git/go-git/v5 v5.17.2 + github.com/go-git/go-git/v5 v5.18.0 github.com/go-jose/go-jose/v3 v3.0.5 github.com/go-jose/go-jose/v4 v4.1.4 github.com/gofrs/flock v0.13.0 diff --git a/go.sum b/go.sum index 175a969dbe..5001d83960 100644 --- a/go.sum +++ b/go.sum @@ -247,8 +247,8 @@ github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDz github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104= -github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= +github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= From eb7ddef53d337c0e4f5fbfa1df48b71b105feb85 Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota <yolanda@stacklok.com> Date: Mon, 20 Apr 2026 09:59:52 +0200 Subject: [PATCH 079/117] Add EmbeddingServiceManager for TEI container lifecycle (#4912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements pkg/vmcp/cli/embedding_manager.go — the component that manages the lifecycle of a HuggingFace Text Embeddings Inference (TEI) container for the Tier 2 semantic optimizer in thv vmcp serve. Key behaviours: - Deterministic container naming: thv-embedding-<sha256(model)[:8]> - Idempotent Start: reuses an already-running container (reads port from toolhive-port label) or deploys a new one with LabelAuxiliary - Model cache persisted at ~/.toolhive/embedding-models → /data bind mount - Health polling with exponential backoff (2 s → 30 s, factor 2), draining response bodies to allow connection reuse - Stop is a no-op when this instance did not start the container - ContainerFactory narrow interface enables full unit testing without a live container daemon Closes #4884 Co-authored-by: taskbot <taskbot@users.noreply.github.com> --- pkg/vmcp/cli/embedding_manager.go | 366 ++++++++++++++++++ pkg/vmcp/cli/embedding_manager_test.go | 369 +++++++++++++++++++ pkg/vmcp/cli/mocks/mock_container_factory.go | 57 +++ 3 files changed, 792 insertions(+) create mode 100644 pkg/vmcp/cli/embedding_manager.go create mode 100644 pkg/vmcp/cli/embedding_manager_test.go create mode 100644 pkg/vmcp/cli/mocks/mock_container_factory.go diff --git a/pkg/vmcp/cli/embedding_manager.go b/pkg/vmcp/cli/embedding_manager.go new file mode 100644 index 0000000000..7013647837 --- /dev/null +++ b/pkg/vmcp/cli/embedding_manager.go @@ -0,0 +1,366 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package cli provides the business logic for the vMCP serve, validate, and init +// commands. It is designed to be imported by both the standalone vmcp binary +// (cmd/vmcp/app) and the thv vmcp subcommand (cmd/thv/app). +package cli + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/stacklok/toolhive-core/permissions" + "github.com/stacklok/toolhive/pkg/container/runtime" + "github.com/stacklok/toolhive/pkg/labels" + "github.com/stacklok/toolhive/pkg/networking" +) + +const ( + // defaultTEIImage is the default HuggingFace Text Embeddings Inference image. + defaultTEIImage = "ghcr.io/huggingface/text-embeddings-inference:cpu-latest" + + // teiModelCachePath is the path inside the TEI container where models are cached. + teiModelCachePath = "/data" + + // teiContainerNamePrefix is the prefix for TEI container names. + teiContainerNamePrefix = "thv-embedding-" + + // teiContainerPort is the port that the TEI HTTP server listens on inside the container. + teiContainerPort = "80" + + // healthPath is the TEI HTTP health endpoint path. + // Returns 200 once the model is fully loaded and ready to serve. + healthPath = "/health" + + // pollInitialInterval is the starting backoff interval for health polling. + pollInitialInterval = 2 * time.Second + + // pollMultiplier is the exponential growth factor applied after each failed poll. + pollMultiplier = 2 + + // pollMaxInterval is the upper bound for the exponential backoff sleep. + pollMaxInterval = 30 * time.Second +) + +// modelShortHash returns the first 8 hexadecimal characters of the SHA-256 hash +// of the given model name string. Using a hash avoids invalid container-name +// characters (e.g., slashes in "BAAI/bge-small-en-v1.5"). +func modelShortHash(model string) string { + sum := sha256.Sum256([]byte(model)) + return hex.EncodeToString(sum[:])[:8] +} + +// containerNameForModel returns the canonical TEI container name for a model. +// Format: thv-embedding-<8-char-hex> +func containerNameForModel(model string) string { + return teiContainerNamePrefix + modelShortHash(model) +} + +// ContainerFactory is the minimal interface over *container.Factory that +// EmbeddingServiceManager requires. Defined here to allow mock injection in unit tests; +// in production callers pass container.NewFactory(). +// +//go:generate mockgen -destination=mocks/mock_container_factory.go -package=mocks -source=embedding_manager.go ContainerFactory +type ContainerFactory interface { + // Create initialises a container runtime backed by the host daemon. + Create(ctx context.Context) (runtime.Runtime, error) +} + +// EmbeddingServiceManagerConfig holds the parameters for constructing an +// EmbeddingServiceManager. +type EmbeddingServiceManagerConfig struct { + // Model is the HuggingFace model name (e.g. "BAAI/bge-small-en-v1.5"). + // Required; must be non-empty. + Model string + + // Image is the TEI container image to run. + // Defaults to defaultTEIImage when empty. + Image string +} + +// EmbeddingServiceManager manages the lifecycle of a TEI container used by the +// Tier 2 semantic optimizer. It creates or reuses a container deterministically +// named after the model, health-polls with exponential backoff, and only stops +// the container if this instance started it. +// +// On Docker the container is bound to a localhost port and the model cache is +// bind-mounted from the host. On Kubernetes the TEI pod is exposed via a +// ClusterIP Service and reached at http://<containerName>:<teiContainerPort>; +// the host bind-mount is skipped because Kubernetes ignores Docker permission +// profiles. +type EmbeddingServiceManager struct { + factory ContainerFactory + cfg EmbeddingServiceManagerConfig + containerName string + port int + started bool // true only when DeployWorkload was called by this instance + isKubernetes bool // true when running against a Kubernetes runtime + + // portFinder returns an available host port. On Docker defaults to + // networking.FindAvailable; on Kubernetes returns the fixed container port + // (80) because no host-port allocation is needed. Overridden in unit tests. + portFinder func() int + + // urlFor returns the base URL for the TEI service given a port. + // On Docker: http://localhost:<port>. On Kubernetes: http://<containerName>:<teiContainerPort>. + // Overridden in unit tests. + urlFor func(port int) string + + // healthURLFor returns the full health-check URL for a given port. + // Overridden in unit tests to redirect to an httptest server. + healthURLFor func(port int) string + + // modelCacheDir returns the host-side path used as the bind-mount source + // for the TEI model cache. Only used on Docker. Defaults to + // ~/.toolhive/embedding-models; overridden in unit tests to a t.TempDir() path. + modelCacheDir func() (string, error) +} + +// NewEmbeddingServiceManager constructs an EmbeddingServiceManager from the given +// factory and config. Returns an error when cfg.Model is empty. +func NewEmbeddingServiceManager(factory ContainerFactory, cfg EmbeddingServiceManagerConfig) (*EmbeddingServiceManager, error) { + if cfg.Model == "" { + return nil, fmt.Errorf("model must not be empty") + } + if cfg.Image == "" { + cfg.Image = defaultTEIImage + } + + containerName := containerNameForModel(cfg.Model) + isK8s := runtime.IsKubernetesRuntime() + + mgr := &EmbeddingServiceManager{ + factory: factory, + cfg: cfg, + containerName: containerName, + isKubernetes: isK8s, + modelCacheDir: func() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to determine home directory for TEI model cache: %w", err) + } + return filepath.Join(homeDir, ".toolhive", "embedding-models"), nil + }, + } + + if isK8s { + // On Kubernetes, the TEI pod is reachable via the ClusterIP Service that the + // runtime creates as "mcp-<containerName>". No host-port allocation or + // localhost binding needed. + svcName := fmt.Sprintf("mcp-%s", containerName) + mgr.portFinder = func() int { return 80 } + mgr.urlFor = func(_ int) string { + return fmt.Sprintf("http://%s:%s", svcName, teiContainerPort) + } + mgr.healthURLFor = func(_ int) string { + return fmt.Sprintf("http://%s:%s%s", svcName, teiContainerPort, healthPath) + } + } else { + mgr.portFinder = networking.FindAvailable + mgr.urlFor = func(port int) string { + return fmt.Sprintf("http://localhost:%d", port) + } + mgr.healthURLFor = func(port int) string { + return fmt.Sprintf("http://localhost:%d%s", port, healthPath) + } + } + + return mgr, nil +} + +// Start ensures the TEI container is running and returns its HTTP base URL. +// On Docker this is http://localhost:<port>; on Kubernetes it is +// http://<containerName>:<teiContainerPort>. +// +// On first call it checks for an existing running container with the same name; +// if found, it returns that container's URL without starting a new one +// (idempotent reuse). If no container is running, Start deploys a new one, +// then polls its /health endpoint with exponential backoff until it responds +// 200 or ctx is cancelled. +// +// Returns a non-nil error if the container cannot be started or the health +// check never succeeds within the context deadline. +func (m *EmbeddingServiceManager) Start(ctx context.Context) (string, error) { + rt, err := m.factory.Create(ctx) + if err != nil { + return "", fmt.Errorf("failed to create container runtime: %w", err) + } + + running, err := rt.IsWorkloadRunning(ctx, m.containerName) + if err != nil { + if !errors.Is(err, runtime.ErrContainerNotFound) && !errors.Is(err, runtime.ErrWorkloadNotFound) { + return "", fmt.Errorf("failed to check whether TEI container %q is running: %w", m.containerName, err) + } + // Container does not exist yet — fall through to deploy. + running = false + } + + if running { + return m.reuseContainer(ctx, rt) + } + + return m.deployContainer(ctx, rt) +} + +// reuseContainer retrieves the port of an already-running TEI container, +// polls health to confirm it is ready, and returns its URL without changing +// m.started (caller must not stop it). +func (m *EmbeddingServiceManager) reuseContainer(ctx context.Context, rt runtime.Runtime) (string, error) { + info, err := rt.GetWorkloadInfo(ctx, m.containerName) + if err != nil { + return "", fmt.Errorf("failed to get info for existing TEI container %q: %w", m.containerName, err) + } + + port, err := labels.GetPort(info.Labels) + if err != nil { + return "", fmt.Errorf("failed to read port label from existing TEI container %q: %w", m.containerName, err) + } + + slog.Debug("reusing existing TEI container", "name", m.containerName, "port", port) + m.port = port + + if err := m.pollHealth(ctx); err != nil { + return "", err + } + return m.urlFor(port), nil +} + +// deployContainer allocates a port, deploys the TEI container, marks +// m.started = true, then polls health. +// +// On Docker it also creates the model-cache host directory and adds a localhost +// port binding. On Kubernetes those steps are skipped: the runtime ignores +// Docker permission profiles, and the pod is reachable via its ClusterIP Service. +func (m *EmbeddingServiceManager) deployContainer(ctx context.Context, rt runtime.Runtime) (string, error) { + port := m.portFinder() + if port == 0 { + return "", fmt.Errorf("could not find an available port for TEI container %q", m.containerName) + } + + m.port = port + opts := runtime.NewDeployWorkloadOptions() + opts.ExposedPorts[teiContainerPort+"/tcp"] = struct{}{} + + var profile *permissions.Profile + + if !m.isKubernetes { + // On Docker: bind-mount the model cache from the host and expose a + // localhost port so the embedding client can reach TEI directly. + modelCacheHost, err := m.modelCacheDir() + if err != nil { + return "", err + } + if err := os.MkdirAll(modelCacheHost, 0o700); err != nil { + return "", fmt.Errorf("failed to create TEI model cache directory %q: %w", modelCacheHost, err) + } + opts.PortBindings[teiContainerPort+"/tcp"] = []runtime.PortBinding{ + {HostIP: "127.0.0.1", HostPort: strconv.Itoa(port)}, + } + profile = &permissions.Profile{ + Write: []permissions.MountDeclaration{ + permissions.MountDeclaration(modelCacheHost + ":" + teiModelCachePath), + }, + } + } + + labelsMap := make(map[string]string) + labels.AddStandardLabels(labelsMap, m.containerName, m.containerName, "streamable-http", port) + labelsMap[labels.LabelAuxiliary] = labels.LabelToolHiveValue + + slog.Debug("deploying TEI container", + "name", m.containerName, + "image", m.cfg.Image, + "model", m.cfg.Model, + "port", port, + "kubernetes", m.isKubernetes) + + if _, err := rt.DeployWorkload( + ctx, + m.cfg.Image, + m.containerName, + []string{"--model-id", m.cfg.Model}, + nil, + labelsMap, + profile, + "streamable-http", + opts, + false, + ); err != nil { + return "", fmt.Errorf("failed to deploy TEI container %q: %w", m.containerName, err) + } + m.started = true + + if err := m.pollHealth(ctx); err != nil { + return "", err + } + return m.urlFor(m.port), nil +} + +// pollHealth polls the TEI /health endpoint with exponential backoff until it +// returns HTTP 200 or ctx is cancelled. Response bodies are always drained and +// closed to allow connection reuse. +func (m *EmbeddingServiceManager) pollHealth(ctx context.Context) error { + healthURL := m.healthURLFor(m.port) + interval := pollInitialInterval + + timer := time.NewTimer(interval) + defer timer.Stop() + + for { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) + if err != nil { + return fmt.Errorf("failed to build TEI health request: %w", err) + } + resp, err := http.DefaultClient.Do(req) //nolint:gosec // URL constructed from localhost+known port + if err == nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + slog.Debug("TEI container is healthy", "name", m.containerName, "url", healthURL) + return nil + } + slog.Debug("TEI container not yet healthy", "name", m.containerName, "status", resp.StatusCode) + } else { + slog.Debug("TEI health check failed", "name", m.containerName, "error", err) + } + + select { + case <-ctx.Done(): + return fmt.Errorf("TEI container %q did not become healthy within deadline: %w", + m.containerName, ctx.Err()) + case <-timer.C: + interval = min(interval*pollMultiplier, pollMaxInterval) + timer.Reset(interval) + } + } +} + +// Stop stops the TEI container if this EmbeddingServiceManager instance started it. +// If the container was already running when Start was called (reuse case), +// Stop is a no-op — the container belongs to whichever process created it. +func (m *EmbeddingServiceManager) Stop(ctx context.Context) error { + if !m.started { + return nil + } + + rt, err := m.factory.Create(ctx) + if err != nil { + return fmt.Errorf("failed to create container runtime for stop: %w", err) + } + + if err := rt.StopWorkload(ctx, m.containerName); err != nil { + return fmt.Errorf("failed to stop TEI container %q: %w", m.containerName, err) + } + return nil +} diff --git a/pkg/vmcp/cli/embedding_manager_test.go b/pkg/vmcp/cli/embedding_manager_test.go new file mode 100644 index 0000000000..2cd1ddf7ae --- /dev/null +++ b/pkg/vmcp/cli/embedding_manager_test.go @@ -0,0 +1,369 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/container/runtime" + runtimemocks "github.com/stacklok/toolhive/pkg/container/runtime/mocks" + "github.com/stacklok/toolhive/pkg/vmcp/cli/mocks" +) + +func TestContainerNameForModel(t *testing.T) { + t.Parallel() + + t.Run("deterministic", func(t *testing.T) { + t.Parallel() + assert.Equal(t, containerNameForModel("BAAI/bge-small-en-v1.5"), + containerNameForModel("BAAI/bge-small-en-v1.5")) + }) + + t.Run("different models produce different names", func(t *testing.T) { + t.Parallel() + assert.NotEqual(t, + containerNameForModel("BAAI/bge-small-en-v1.5"), + containerNameForModel("sentence-transformers/all-MiniLM-L6-v2")) + }) + + t.Run("has expected prefix", func(t *testing.T) { + t.Parallel() + name := containerNameForModel("BAAI/bge-small-en-v1.5") + assert.True(t, strings.HasPrefix(name, "thv-embedding-"), + "expected prefix thv-embedding- in %q", name) + }) + + t.Run("no slashes in name", func(t *testing.T) { + t.Parallel() + name := containerNameForModel("BAAI/bge-small-en-v1.5") + assert.NotContains(t, name, "/") + }) + + t.Run("hash is 8 hex chars", func(t *testing.T) { + t.Parallel() + name := containerNameForModel("BAAI/bge-small-en-v1.5") + hash := strings.TrimPrefix(name, "thv-embedding-") + require.Len(t, hash, 8) + for _, c := range hash { + assert.True(t, (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'), + "expected hex char, got %c", c) + } + }) +} + +func TestNewEmbeddingServiceManager_EmptyModel(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockFactory := mocks.NewMockContainerFactory(ctrl) + + _, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{Model: ""}) + assert.ErrorContains(t, err, "model") +} + +func TestNewEmbeddingServiceManager_DefaultImage(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockFactory := mocks.NewMockContainerFactory(ctrl) + + mgr, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{ + Model: "BAAI/bge-small-en-v1.5", + }) + require.NoError(t, err) + assert.Equal(t, defaultTEIImage, mgr.cfg.Image) + assert.Equal(t, containerNameForModel("BAAI/bge-small-en-v1.5"), mgr.containerName) +} + +func TestStart_ReuseExistingContainer(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockFactory := mocks.NewMockContainerFactory(ctrl) + mockRT := runtimemocks.NewMockRuntime(ctrl) + + healthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(healthServer.Close) + + mgr, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{ + Model: "BAAI/bge-small-en-v1.5", + }) + require.NoError(t, err) + + // Redirect health checks to the test server; pin the port so the returned + // URL matches what GetWorkloadInfo reports via the label. + u, err := url.Parse(healthServer.URL) + require.NoError(t, err) + port, err := strconv.Atoi(u.Port()) + require.NoError(t, err) + mgr.healthURLFor = func(_ int) string { return healthServer.URL + healthPath } + mgr.urlFor = func(_ int) string { return fmt.Sprintf("http://localhost:%d", port) } + + mockFactory.EXPECT().Create(gomock.Any()).Return(mockRT, nil) + mockRT.EXPECT().IsWorkloadRunning(gomock.Any(), mgr.containerName).Return(true, nil) + mockRT.EXPECT().GetWorkloadInfo(gomock.Any(), mgr.containerName).Return(runtime.ContainerInfo{ + Labels: map[string]string{"toolhive-port": strconv.Itoa(port)}, + }, nil) + + gotURL, err := mgr.Start(context.Background()) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("http://localhost:%d", port), gotURL) + assert.False(t, mgr.started, "started must be false when reusing an existing container") +} + +func TestStart_FactoryError(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockFactory := mocks.NewMockContainerFactory(ctrl) + + mgr, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{ + Model: "BAAI/bge-small-en-v1.5", + }) + require.NoError(t, err) + + mockFactory.EXPECT().Create(gomock.Any()).Return(nil, fmt.Errorf("daemon unavailable")) + + _, err = mgr.Start(context.Background()) + assert.ErrorContains(t, err, "daemon unavailable") +} + +// pinPortAndHealth configures mgr to use the port of server for port allocation +// and redirects health checks to server. Call t.Cleanup(server.Close) separately. +func pinPortAndHealth(t *testing.T, mgr *EmbeddingServiceManager, server *httptest.Server) { + t.Helper() + u, err := url.Parse(server.URL) + require.NoError(t, err) + port, err := strconv.Atoi(u.Port()) + require.NoError(t, err) + mgr.portFinder = func() int { return port } + mgr.healthURLFor = func(_ int) string { return server.URL + healthPath } +} + +func TestStart_DeployNewContainer(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockFactory := mocks.NewMockContainerFactory(ctrl) + mockRT := runtimemocks.NewMockRuntime(ctrl) + + healthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(healthServer.Close) + + mgr, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{ + Model: "BAAI/bge-small-en-v1.5", + }) + require.NoError(t, err) + pinPortAndHealth(t, mgr, healthServer) + + mockFactory.EXPECT().Create(gomock.Any()).Return(mockRT, nil) + mockRT.EXPECT().IsWorkloadRunning(gomock.Any(), mgr.containerName).Return(false, nil) + mockRT.EXPECT().DeployWorkload( + gomock.Any(), + defaultTEIImage, + mgr.containerName, + []string{"--model-id", "BAAI/bge-small-en-v1.5"}, + gomock.Nil(), + gomock.Any(), + gomock.Any(), + "streamable-http", + gomock.Any(), + false, + ).Return(0, nil) + + gotURL, err := mgr.Start(context.Background()) + require.NoError(t, err) + assert.True(t, mgr.started, "started must be true after deploying a new container") + assert.Contains(t, gotURL, "http://localhost:") +} + +// TestStart_DeployNewContainer_Kubernetes verifies that on a Kubernetes runtime +// the manager deploys without a localhost port binding or host bind-mount, and +// returns a Kubernetes service URL. +func TestStart_DeployNewContainer_Kubernetes(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockFactory := mocks.NewMockContainerFactory(ctrl) + mockRT := runtimemocks.NewMockRuntime(ctrl) + + healthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(healthServer.Close) + + mgr, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{ + Model: "BAAI/bge-small-en-v1.5", + }) + require.NoError(t, err) + + // Simulate Kubernetes runtime without environment mutation. + svcName := fmt.Sprintf("mcp-%s", mgr.containerName) + mgr.isKubernetes = true + mgr.portFinder = func() int { return 80 } + mgr.urlFor = func(_ int) string { + return fmt.Sprintf("http://%s:%s", svcName, teiContainerPort) + } + mgr.healthURLFor = func(_ int) string { return healthServer.URL + healthPath } + + mockFactory.EXPECT().Create(gomock.Any()).Return(mockRT, nil) + mockRT.EXPECT().IsWorkloadRunning(gomock.Any(), mgr.containerName).Return(false, nil) + mockRT.EXPECT().DeployWorkload( + gomock.Any(), + defaultTEIImage, + mgr.containerName, + []string{"--model-id", "BAAI/bge-small-en-v1.5"}, + gomock.Nil(), // no env vars + gomock.Any(), // labels + gomock.Nil(), // no permission profile on Kubernetes + "streamable-http", + gomock.Any(), + false, + ).Return(0, nil) + + gotURL, err := mgr.Start(context.Background()) + require.NoError(t, err) + assert.True(t, mgr.started) + assert.Equal(t, fmt.Sprintf("http://%s:%s", svcName, teiContainerPort), gotURL) +} + +func TestStart_HealthPollTimeout(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockFactory := mocks.NewMockContainerFactory(ctrl) + mockRT := runtimemocks.NewMockRuntime(ctrl) + + healthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + t.Cleanup(healthServer.Close) + + mgr, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{ + Model: "BAAI/bge-small-en-v1.5", + }) + require.NoError(t, err) + pinPortAndHealth(t, mgr, healthServer) + + mockFactory.EXPECT().Create(gomock.Any()).Return(mockRT, nil) + mockRT.EXPECT().IsWorkloadRunning(gomock.Any(), gomock.Any()).Return(false, nil) + mockRT.EXPECT().DeployWorkload(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(0, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + t.Cleanup(cancel) + + _, err = mgr.Start(ctx) + assert.ErrorContains(t, err, "healthy") +} + +func TestStart_DeployError(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockFactory := mocks.NewMockContainerFactory(ctrl) + mockRT := runtimemocks.NewMockRuntime(ctrl) + + healthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(healthServer.Close) + + mgr, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{ + Model: "BAAI/bge-small-en-v1.5", + }) + require.NoError(t, err) + pinPortAndHealth(t, mgr, healthServer) + + mockFactory.EXPECT().Create(gomock.Any()).Return(mockRT, nil) + mockRT.EXPECT().IsWorkloadRunning(gomock.Any(), gomock.Any()).Return(false, nil) + mockRT.EXPECT().DeployWorkload(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(0, fmt.Errorf("image pull failed")) + + _, err = mgr.Start(context.Background()) + assert.ErrorContains(t, err, "image pull failed") + assert.False(t, mgr.started) +} + +func TestStart_ZeroPort(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockFactory := mocks.NewMockContainerFactory(ctrl) + mockRT := runtimemocks.NewMockRuntime(ctrl) + + mgr, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{ + Model: "BAAI/bge-small-en-v1.5", + }) + require.NoError(t, err) + mgr.portFinder = func() int { return 0 } + + mockFactory.EXPECT().Create(gomock.Any()).Return(mockRT, nil) + mockRT.EXPECT().IsWorkloadRunning(gomock.Any(), gomock.Any()).Return(false, nil) + + _, err = mgr.Start(context.Background()) + assert.ErrorContains(t, err, "port") +} + +func TestStop_OwnsContainer(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockFactory := mocks.NewMockContainerFactory(ctrl) + mockRT := runtimemocks.NewMockRuntime(ctrl) + + mgr, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{ + Model: "BAAI/bge-small-en-v1.5", + }) + require.NoError(t, err) + mgr.started = true // simulate this instance having deployed the container + + mockFactory.EXPECT().Create(gomock.Any()).Return(mockRT, nil) + mockRT.EXPECT().StopWorkload(gomock.Any(), mgr.containerName).Return(nil) + + err = mgr.Stop(context.Background()) + assert.NoError(t, err) +} + +func TestStop_ReuseContainer(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockFactory := mocks.NewMockContainerFactory(ctrl) + + mgr, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{ + Model: "BAAI/bge-small-en-v1.5", + }) + require.NoError(t, err) + // mgr.started == false (default) — reuse scenario; factory.Create and StopWorkload must NOT be called + + err = mgr.Stop(context.Background()) + assert.NoError(t, err) +} + +func TestStop_RuntimeError(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockFactory := mocks.NewMockContainerFactory(ctrl) + mockRT := runtimemocks.NewMockRuntime(ctrl) + + mgr, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{ + Model: "BAAI/bge-small-en-v1.5", + }) + require.NoError(t, err) + mgr.started = true + + mockFactory.EXPECT().Create(gomock.Any()).Return(mockRT, nil) + mockRT.EXPECT().StopWorkload(gomock.Any(), mgr.containerName).Return(fmt.Errorf("stop failed")) + + err = mgr.Stop(context.Background()) + assert.ErrorContains(t, err, "stop failed") +} diff --git a/pkg/vmcp/cli/mocks/mock_container_factory.go b/pkg/vmcp/cli/mocks/mock_container_factory.go new file mode 100644 index 0000000000..7f354f1558 --- /dev/null +++ b/pkg/vmcp/cli/mocks/mock_container_factory.go @@ -0,0 +1,57 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: embedding_manager.go +// +// Generated by this command: +// +// mockgen -destination=mocks/mock_container_factory.go -package=mocks -source=embedding_manager.go ContainerFactory +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + runtime "github.com/stacklok/toolhive/pkg/container/runtime" + gomock "go.uber.org/mock/gomock" +) + +// MockContainerFactory is a mock of ContainerFactory interface. +type MockContainerFactory struct { + ctrl *gomock.Controller + recorder *MockContainerFactoryMockRecorder + isgomock struct{} +} + +// MockContainerFactoryMockRecorder is the mock recorder for MockContainerFactory. +type MockContainerFactoryMockRecorder struct { + mock *MockContainerFactory +} + +// NewMockContainerFactory creates a new mock instance. +func NewMockContainerFactory(ctrl *gomock.Controller) *MockContainerFactory { + mock := &MockContainerFactory{ctrl: ctrl} + mock.recorder = &MockContainerFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockContainerFactory) EXPECT() *MockContainerFactoryMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockContainerFactory) Create(ctx context.Context) (runtime.Runtime, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx) + ret0, _ := ret[0].(runtime.Runtime) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockContainerFactoryMockRecorder) Create(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockContainerFactory)(nil).Create), ctx) +} From c331aa4996cbc5f93d2dc6142c80c6b385e1a772 Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:07:05 +0200 Subject: [PATCH 080/117] Fix skill install for qualified namespace/name references and missing parent directory (#4841) * Fall back to registry lookup when Install receives a qualified namespace/name * Create parent directory before acquiring lock in WriteFiles * Block multi-segment OCI refs from triggering registry fallback Extend the fallback guard in Install and GetContent to also reject refs with more than one '/' (e.g. ghcr.io/org/skill). Previously only ':' and '@' were checked, so a tagless multi-segment OCI reference could silently trigger a registry lookup on pull failure. Add a Debug log line before the fallback so operators can see when the fallback path is taken. Fix installFromResolvedRegistry passing opts.Name (the OCI reference string) to installAndRegister instead of result.Skill.Metadata.Name, which caused incorrect group entries and broken rollback. * Add WriteFiles test for missing parent directory * Extend OCI fallback tests with digest, multi-segment, and ambiguity cases Add three new test cases to TestInstallQualifiedNameOCIFallback: - digest ref (@sha256:...) must not trigger registry fallback - multi-segment OCI ref (ghcr.io/org/skill) must not trigger fallback - multiple registry matches must surface a conflict error to the caller Also pin the Pull mock's third argument in the three qualifying-name cases to the expected reference string instead of gomock.Any(). * Extract isUnambiguousOCIRef helper for registry-fallback guard Replace the inlined string-based guard in Install and GetContent with a named helper that combines parsed-form inspection (name.Digest type assertion) with string checks for the tag and segment-count cases. A pure parsed-form check is not sufficient: nameref.ParseReference normalizes "foo/bar" to "foo/bar:latest" (name.Tag), so the parsed reference cannot distinguish a user-supplied tag from the default. The helper documents this constraint and combines both signals. --- pkg/skills/gitresolver/writer.go | 6 + pkg/skills/gitresolver/writer_test.go | 19 ++ pkg/skills/skillsvc/skillsvc.go | 107 +++++++--- pkg/skills/skillsvc/skillsvc_test.go | 274 ++++++++++++++++++++++++++ 4 files changed, 380 insertions(+), 26 deletions(-) diff --git a/pkg/skills/gitresolver/writer.go b/pkg/skills/gitresolver/writer.go index 8712703544..3702a0c9ea 100644 --- a/pkg/skills/gitresolver/writer.go +++ b/pkg/skills/gitresolver/writer.go @@ -22,6 +22,12 @@ func WriteFiles(files []FileEntry, targetDir string, force bool) error { // Sanitize targetDir early so all downstream os calls use the clean path. targetDir = filepath.Clean(targetDir) + // Ensure the parent directory exists before acquiring the lock. + // WithFileLock opens targetDir+".lock", which requires the parent to exist. + if err := os.MkdirAll(filepath.Dir(targetDir), skills.DirPermissions); err != nil { + return fmt.Errorf("creating parent directory: %w", err) + } + return fileutils.WithFileLock(targetDir, func() error { // Handle existing directory if _, statErr := os.Stat(targetDir); statErr == nil { // lgtm[go/path-injection] #nosec G304 diff --git a/pkg/skills/gitresolver/writer_test.go b/pkg/skills/gitresolver/writer_test.go index ba934339e9..b41b56301c 100644 --- a/pkg/skills/gitresolver/writer_test.go +++ b/pkg/skills/gitresolver/writer_test.go @@ -79,6 +79,25 @@ func TestWriteFiles(t *testing.T) { }, } + t.Run("parent directory does not exist", func(t *testing.T) { + t.Parallel() + baseDir := resolvedTempDir(t) + // Point targetDir one level deeper than a non-existent subdirectory. + targetDir := filepath.Join(baseDir, "nonexistent", "my-skill") + + files := []FileEntry{{Path: "SKILL.md", Content: []byte("# Skill"), Mode: 0644}} + err := WriteFiles(files, targetDir, false) + require.NoError(t, err) + + // Both the parent and the skill directory must now exist. + _, statErr := os.Stat(targetDir) + require.NoError(t, statErr) + + content, readErr := os.ReadFile(filepath.Join(targetDir, "SKILL.md")) + require.NoError(t, readErr) + assert.Equal(t, []byte("# Skill"), content) + }) + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/pkg/skills/skillsvc/skillsvc.go b/pkg/skills/skillsvc/skillsvc.go index b195beccfa..545f4d1b10 100644 --- a/pkg/skills/skillsvc/skillsvc.go +++ b/pkg/skills/skillsvc/skillsvc.go @@ -235,11 +235,26 @@ func (s *service) Install(ctx context.Context, opts skills.InstallOptions) (*ski ) } if isOCI { - result, err := s.installFromOCI(ctx, opts, scope, ref) - if err != nil { - return nil, err + result, ociErr := s.installFromOCI(ctx, opts, scope, ref) + if ociErr == nil { + return s.installAndRegister(ctx, result, opts.Group, opts.Name, scope, opts.ProjectRoot) + } + // OCI pull failed — fall back to registry lookup for names that look + // like a qualified "namespace/name". Names that are unambiguously OCI + // (digest, explicit tag, or multi-segment path) must not trigger a + // registry search. See isUnambiguousOCIRef for the full rule set. + if isUnambiguousOCIRef(opts.Name, ref) { + return nil, ociErr + } + slog.Debug("OCI pull failed, attempting registry fallback", "name", opts.Name, "error", ociErr) + resolved, regErr := s.resolveFromRegistry(opts.Name) + if regErr != nil { + return nil, regErr } - return s.installAndRegister(ctx, result, opts.Group, opts.Name, scope, opts.ProjectRoot) + if resolved != nil { + return s.installFromResolvedRegistry(ctx, opts, scope, resolved) + } + return nil, ociErr } // Plain skill name — validate and proceed with existing flow. @@ -309,24 +324,7 @@ func (s *service) installFromRegistryLookup( return nil, regErr } if resolved != nil { - switch { - case resolved.OCIRef != nil: - slog.Info("resolved skill from registry (OCI)", "name", opts.Name, "oci_reference", resolved.OCIRef.String()) - opts.Name = resolved.OCIRef.String() - result, ociErr := s.installFromOCI(ctx, opts, scope, resolved.OCIRef) - if ociErr != nil { - return nil, ociErr - } - return s.installAndRegister(ctx, result, opts.Group, opts.Name, scope, opts.ProjectRoot) - case resolved.GitURL != "": - slog.Info("resolved skill from registry (git)", "name", opts.Name, "git_url", resolved.GitURL) - opts.Name = resolved.GitURL - result, gitErr := s.installFromGit(ctx, opts, scope) - if gitErr != nil { - return nil, gitErr - } - return s.installAndRegister(ctx, result, opts.Group, result.Skill.Metadata.Name, scope, opts.ProjectRoot) - } + return s.installFromResolvedRegistry(ctx, opts, scope, resolved) } return nil, httperr.WithCode( @@ -337,6 +335,41 @@ func (s *service) installFromRegistryLookup( ) } +// installFromResolvedRegistry dispatches an install to the appropriate +// backend (OCI or git) based on the result of a registry lookup. +func (s *service) installFromResolvedRegistry( + ctx context.Context, + opts skills.InstallOptions, + scope skills.Scope, + resolved *registryResolveResult, +) (*skills.InstallResult, error) { + switch { + case resolved.OCIRef != nil: + slog.Info("resolved skill from registry (OCI)", "name", opts.Name, "oci_reference", resolved.OCIRef.String()) + opts.Name = resolved.OCIRef.String() + result, ociErr := s.installFromOCI(ctx, opts, scope, resolved.OCIRef) + if ociErr != nil { + return nil, ociErr + } + // Use the skill name extracted from the artifact, not opts.Name which + // holds the OCI ref string. installFromOCI mutates its own copy of opts + // (Go pass-by-value), so the caller never sees the updated name. + return s.installAndRegister(ctx, result, opts.Group, result.Skill.Metadata.Name, scope, opts.ProjectRoot) + case resolved.GitURL != "": + slog.Info("resolved skill from registry (git)", "name", opts.Name, "git_url", resolved.GitURL) + opts.Name = resolved.GitURL + result, gitErr := s.installFromGit(ctx, opts, scope) + if gitErr != nil { + return nil, gitErr + } + return s.installAndRegister(ctx, result, opts.Group, result.Skill.Metadata.Name, scope, opts.ProjectRoot) + } + return nil, httperr.WithCode( + fmt.Errorf("skill %q resolved from registry but has no installable package", opts.Name), + http.StatusUnprocessableEntity, + ) +} + // Uninstall removes an installed skill and cleans up files for all clients. func (s *service) Uninstall(ctx context.Context, opts skills.UninstallOptions) error { if err := skills.ValidateSkillName(opts.Name); err != nil { @@ -465,10 +498,11 @@ func (s *service) GetContent(ctx context.Context, opts skills.ContentOptions) (* } // OCI failed — try resolving via registry name lookup (e.g. "skill-creator" - // or "io.github.stacklok/skill-creator" from the catalog index). - // Skip for refs that are clearly OCI references (contain : or @) to avoid - // a wasted network round-trip searching for e.g. "skill:v1". - if strings.ContainsAny(ref, ":@") { + // or "io.github.stacklok/skill-creator" from the catalog index). Skip for + // refs that are unambiguously OCI (digest, explicit tag, or multi-segment + // path) to avoid a wasted network round-trip. + if parsedRef, isOCI, parseErr := parseOCIReference(ref); parseErr == nil && isOCI && + isUnambiguousOCIRef(ref, parsedRef) { return nil, ociErr } resolved, regErr := s.resolveFromRegistry(ref) @@ -810,6 +844,27 @@ func parseOCIReference(name string) (nameref.Reference, bool, error) { return ref, true, nil } +// isUnambiguousOCIRef reports whether raw was clearly intended by the user as +// an OCI reference, meaning a failed pull must NOT fall back to a registry +// catalogue lookup. A ref is unambiguous if any of the following hold: +// +// - the parsed form is a digest reference (e.g. "name@sha256:...") +// - the raw string contains ':' (explicit tag such as "name:v1") +// - the raw string has more than one '/' (multi-segment path such as +// "ghcr.io/org/skill") +// +// The parsed Reference alone is insufficient for the tag case: +// nameref.ParseReference normalizes "foo/bar" to "foo/bar:latest" (a name.Tag), +// making it indistinguishable from an explicitly tagged reference. We therefore +// rely on the parsed form for the digest check and fall back to string +// inspection for the tag and segment-count checks. +func isUnambiguousOCIRef(raw string, ref nameref.Reference) bool { + if _, isDigest := ref.(nameref.Digest); isDigest { + return true + } + return strings.Contains(raw, ":") || strings.Count(raw, "/") > 1 +} + // installFromOCI pulls a skill artifact from a remote registry, extracts // metadata and layer data, then delegates to the standard extraction flow. func (s *service) installFromOCI( diff --git a/pkg/skills/skillsvc/skillsvc_test.go b/pkg/skills/skillsvc/skillsvc_test.go index a924a86545..22bf569f00 100644 --- a/pkg/skills/skillsvc/skillsvc_test.go +++ b/pkg/skills/skillsvc/skillsvc_test.go @@ -2931,6 +2931,280 @@ func TestInstallFromRegistryGitFallback(t *testing.T) { } } +// TestInstallQualifiedNameOCIFallback covers the scenario where Install +// receives a "namespace/name" string, which is parsed as an OCI reference but +// fails to pull because the namespace looks like a registry host. The service +// must fall back to a registry catalogue lookup and complete the install from +// the resolved package (OCI or git). Names that carry an explicit tag or digest +// (unambiguously OCI) must NOT trigger a fallback. +func TestInstallQualifiedNameOCIFallback(t *testing.T) { + t.Parallel() + + commitHash := testCommitHash + + tests := []struct { + name string + opts skills.InstallOptions + setup func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) + wantCode int + wantErr string + wantName string + }{ + { + name: "qualified namespace/name falls back to registry OCI package", + opts: skills.InstallOptions{Name: "io.github.stacklok/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + // First Pull is for the raw "io.github.stacklok/my-skill:latest" — fails. + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "io.github.stacklok/my-skill:latest"). + Return(godigest.Digest(""), fmt.Errorf("no such host")). + Times(1) + // Second Pull is after registry lookup resolves the real OCI ref. + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/stacklok/my-skill:v1.0.0"). + Return(indexDigest, nil) + + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + { + Namespace: "io.github.stacklok", + Name: "my-skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "oci", Identifier: "ghcr.io/stacklok/my-skill:v1.0.0"}, + }, + }, + }, nil) + + installBase := filepath.Join(tempDir(t), "installed") + require.NoError(t, os.MkdirAll(installBase, 0o755)) + + store := storemocks.NewMockSkillStore(ctrl) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + + pr := skillsmocks.NewMockPathResolver(ctrl) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(installBase, "my-skill"), nil) + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + + return lookup, reg, ociStore, nil, store, pr + }, + wantName: "my-skill", + }, + { + name: "qualified namespace/name falls back to registry git package", + opts: skills.InstallOptions{Name: "io.github.stacklok/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "io.github.stacklok/my-skill:latest"). + Return(godigest.Digest(""), fmt.Errorf("no such host")) + + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + { + Namespace: "io.github.stacklok", + Name: "my-skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "git", URL: "https://github.com/stacklok/my-skill"}, + }, + }, + }, nil) + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "1.0.0"}, + Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("---\nname: my-skill\n---\n"), Mode: 0644}}, + CommitHash: commitHash, + }, nil) + + installBase := filepath.Join(tempDir(t), "installed") + require.NoError(t, os.MkdirAll(installBase, 0o755)) + + store := storemocks.NewMockSkillStore(ctrl) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + + pr := skillsmocks.NewMockPathResolver(ctrl) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(installBase, "my-skill"), nil) + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + + return lookup, reg, ociStore, gr, store, pr + }, + wantName: "my-skill", + }, + { + name: "explicit OCI tag does not fall back to registry on pull failure", + opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/org/my-skill:v1"). + Return(godigest.Digest(""), fmt.Errorf("auth required")) + + // pathResolver must be non-nil so installFromOCI proceeds past its + // nil guard and reaches the Pull call. + pr := skillsmocks.NewMockPathResolver(ctrl) + + store := storemocks.NewMockSkillStore(ctrl) + + // No lookup mock — gomock will fail the test if SearchSkills is called. + return nil, reg, ociStore, nil, store, pr + }, + wantCode: http.StatusBadRequest, + wantErr: "auth required", + }, + { + name: "qualified name with no registry match returns original OCI error", + opts: skills.InstallOptions{Name: "io.github.stacklok/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "io.github.stacklok/my-skill:latest"). + Return(godigest.Digest(""), fmt.Errorf("no such host")) + + // pathResolver must be non-nil so installFromOCI proceeds past its + // nil guard and reaches the Pull call. + pr := skillsmocks.NewMockPathResolver(ctrl) + + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return(nil, nil) + + store := storemocks.NewMockSkillStore(ctrl) + return lookup, reg, ociStore, nil, store, pr + }, + wantCode: http.StatusBadRequest, + wantErr: "no such host", + }, + { + name: "digest ref does not fall back to registry on pull failure", + // A full 64-char SHA256 hex digest — required for nameref.ParseReference to accept it. + opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/org/my-skill@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"). + Return(godigest.Digest(""), fmt.Errorf("manifest unknown")) + + pr := skillsmocks.NewMockPathResolver(ctrl) + store := storemocks.NewMockSkillStore(ctrl) + // No lookup mock — gomock will fail the test if SearchSkills is called. + return nil, reg, ociStore, nil, store, pr + }, + wantCode: http.StatusBadRequest, + wantErr: "manifest unknown", + }, + { + name: "multi-segment OCI ref does not fall back to registry on pull failure", + opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/org/my-skill:latest"). + Return(godigest.Digest(""), fmt.Errorf("auth required")) + + pr := skillsmocks.NewMockPathResolver(ctrl) + store := storemocks.NewMockSkillStore(ctrl) + // No lookup mock — gomock will fail if SearchSkills is called. + return nil, reg, ociStore, nil, store, pr + }, + wantCode: http.StatusBadRequest, + wantErr: "auth required", + }, + { + name: "registry ambiguity error surfaced to caller", + // resolveFromRegistry returns a conflict error when multiple registry + // entries match the same name — the Install method must propagate it. + opts: skills.InstallOptions{Name: "io.github.stacklok/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "io.github.stacklok/my-skill:latest"). + Return(godigest.Digest(""), fmt.Errorf("no such host")) + + pr := skillsmocks.NewMockPathResolver(ctrl) + + // Return two results with the same namespace/name so that + // resolveFromRegistry treats this as an ambiguous match and + // returns a conflict error rather than nil. + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + {Namespace: "io.github.stacklok", Name: "my-skill", Packages: []regtypes.SkillPackage{{RegistryType: "git", URL: "https://github.com/a/my-skill"}}}, + {Namespace: "io.github.stacklok", Name: "my-skill", Packages: []regtypes.SkillPackage{{RegistryType: "git", URL: "https://github.com/b/my-skill"}}}, + }, nil) + + store := storemocks.NewMockSkillStore(ctrl) + return lookup, reg, ociStore, nil, store, pr + }, + wantCode: http.StatusConflict, + wantErr: "ambiguous", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + lookup, reg, ociStore, gr, store, pr := tt.setup(t, ctrl) + + var opts []Option + if lookup != nil { + opts = append(opts, WithSkillLookup(lookup)) + } + if reg != nil { + opts = append(opts, WithRegistryClient(reg)) + } + if ociStore != nil { + opts = append(opts, WithOCIStore(ociStore)) + } + if gr != nil { + opts = append(opts, WithGitResolver(gr)) + } + if pr != nil { + opts = append(opts, WithPathResolver(pr)) + } + + svc := New(store, opts...) + result, err := svc.Install(t.Context(), tt.opts) + + if tt.wantCode != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + if tt.wantErr != "" { + assert.Contains(t, err.Error(), tt.wantErr) + } + return + } + require.NoError(t, err) + if tt.wantName != "" { + assert.Equal(t, tt.wantName, result.Skill.Metadata.Name) + } + }) + } +} + func TestUninstallRemovesSkillFromGroups(t *testing.T) { t.Parallel() From 8223becaa603c3426d2410dfba95a0e6b8d0e1bc Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota <yolanda@stacklok.com> Date: Mon, 20 Apr 2026 11:19:01 +0200 Subject: [PATCH 081/117] Add zero-config quick mode for thv vmcp serve (#4913) * Add zero-config quick mode for thv vmcp serve When --config is omitted, --group enables quick mode: a minimal in-memory config is generated from the named ToolHive group so no config file is needed for the common local-aggregation case. - Add GroupRef field to ServeConfig; Serve() branches on ConfigPath vs GroupRef vs neither (error) - Add generateQuickModeConfig: anonymous auth, inline outgoing, prefix aggregation, validated before use - Remove MarkFlagRequired("config"); add --group flag to serve command - Regenerate docs/cli/thv_vmcp_serve.md Closes #4886 * fixes from review --------- Co-authored-by: taskbot <taskbot@users.noreply.github.com> --- cmd/thv/app/vmcp.go | 12 +++-- docs/cli/thv_vmcp_serve.md | 7 ++- pkg/vmcp/cli/serve.go | 88 ++++++++++++++++++++++++++++--- pkg/vmcp/cli/serve_test.go | 105 +++++++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 11 deletions(-) diff --git a/cmd/thv/app/vmcp.go b/cmd/thv/app/vmcp.go index 16507fa18c..6c8d0c2c0b 100644 --- a/cmd/thv/app/vmcp.go +++ b/cmd/thv/app/vmcp.go @@ -27,6 +27,7 @@ servers from a ToolHive group into a single unified endpoint.`, func newVMCPServeCommand() *cobra.Command { var ( configPath string + group string host string port int enableAudit bool @@ -38,22 +39,27 @@ func newVMCPServeCommand() *cobra.Command { The server reads the configuration file specified by --config and starts listening for MCP client connections, aggregating tools, resources, and -prompts from all configured backend MCP servers.`, +prompts from all configured backend MCP servers. + +When --config is omitted, --group enables zero-config quick mode: a minimal +in-memory configuration is generated from the named ToolHive group, so no +configuration file is needed for the common case of aggregating a local group.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return vmcpcli.Serve(cmd.Context(), vmcpcli.ServeConfig{ ConfigPath: configPath, + GroupRef: group, Host: host, Port: port, EnableAudit: enableAudit, }) }, } - cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to vMCP configuration file (required)") + cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to vMCP configuration file") + cmd.Flags().StringVar(&group, "group", "", "ToolHive group name (zero-config quick mode when --config is omitted)") cmd.Flags().StringVar(&host, "host", "127.0.0.1", "Host address to bind to") cmd.Flags().IntVar(&port, "port", 4483, "Port to listen on") cmd.Flags().BoolVar(&enableAudit, "enable-audit", false, "Enable audit logging with default configuration") - _ = cmd.MarkFlagRequired("config") return cmd } diff --git a/docs/cli/thv_vmcp_serve.md b/docs/cli/thv_vmcp_serve.md index d3ce081581..8ad0b393eb 100644 --- a/docs/cli/thv_vmcp_serve.md +++ b/docs/cli/thv_vmcp_serve.md @@ -21,6 +21,10 @@ The server reads the configuration file specified by --config and starts listening for MCP client connections, aggregating tools, resources, and prompts from all configured backend MCP servers. +When --config is omitted, --group enables zero-config quick mode: a minimal +in-memory configuration is generated from the named ToolHive group, so no +configuration file is needed for the common case of aggregating a local group. + ``` thv vmcp serve [flags] ``` @@ -28,8 +32,9 @@ thv vmcp serve [flags] ### Options ``` - -c, --config string Path to vMCP configuration file (required) + -c, --config string Path to vMCP configuration file --enable-audit Enable audit logging with default configuration + --group string ToolHive group name (zero-config quick mode when --config is omitted) -h, --help help for serve --host string Host address to bind to (default "127.0.0.1") --port int Port to listen on (default 4483) diff --git a/pkg/vmcp/cli/serve.go b/pkg/vmcp/cli/serve.go index 45a78e7276..9b42b8d926 100644 --- a/pkg/vmcp/cli/serve.go +++ b/pkg/vmcp/cli/serve.go @@ -12,6 +12,7 @@ import ( "errors" "fmt" "log/slog" + "net" "os" "path/filepath" "time" @@ -50,9 +51,15 @@ import ( // ServeConfig holds all parameters needed to start the vMCP server. // Populated by the caller from Cobra flag values or equivalent. +// At least one of ConfigPath or GroupRef must be non-empty; ConfigPath takes +// precedence when both are provided. type ServeConfig struct { // ConfigPath is the path to the vMCP YAML configuration file. + // When set, takes precedence over GroupRef. ConfigPath string + // GroupRef is a ToolHive group name used for zero-config quick mode when + // ConfigPath is empty. A minimal in-memory config is generated from this value. + GroupRef string // Host is the address the server binds to (e.g. "127.0.0.1"). Host string // Port is the TCP port the server listens on. @@ -62,17 +69,49 @@ type ServeConfig struct { EnableAudit bool } +// validateQuickModeHost returns an error when the config represents quick mode +// (GroupRef set, ConfigPath empty) and Host is not a loopback address. Quick +// mode always uses anonymous auth, so binding to a non-loopback interface would +// expose an unauthenticated server on the network. Empty host is treated as the +// default loopback address; "localhost" is accepted as a known loopback name. +func (c ServeConfig) validateQuickModeHost() error { + if c.ConfigPath != "" || c.GroupRef == "" { + return nil + } + h := c.Host + if h == "" { + h = "127.0.0.1" + } + if h == "localhost" { + return nil + } + ip := net.ParseIP(h) + if ip == nil || !ip.IsLoopback() { + return fmt.Errorf("quick mode (--group) only supports loopback bind addresses (e.g. 127.0.0.1); got %q", c.Host) + } + return nil +} + // Serve loads configuration, initializes all subsystems, and starts the vMCP // server. It blocks until the context is cancelled or the server stops. // //nolint:gocyclo // Complexity from server initialization sequence is acceptable here. func Serve(ctx context.Context, cfg ServeConfig) error { - if cfg.ConfigPath == "" { - return fmt.Errorf("no configuration file specified, use --config flag") + if err := cfg.validateQuickModeHost(); err != nil { + return err } - // Load and validate configuration - vmcpCfg, err := loadAndValidateConfig(cfg.ConfigPath) + // Load and validate configuration — file path takes precedence over group quick mode. + vmcpCfg, err := func() (*config.Config, error) { + switch { + case cfg.ConfigPath != "": + return loadAndValidateConfig(cfg.ConfigPath) + case cfg.GroupRef != "": + return generateQuickModeConfig(cfg.GroupRef) + default: + return nil, fmt.Errorf("either --config or --group must be specified") + } + }() if err != nil { return err } @@ -85,9 +124,13 @@ func Serve(ctx context.Context, cfg ServeConfig) error { } // Load auth server config from sibling file if present. - authServerRC, err := loadAuthServerConfig(cfg.ConfigPath) - if err != nil { - return err + // Skip in quick mode (no config file) — there is no sibling directory to search. + var authServerRC *authserverconfig.RunConfig + if cfg.ConfigPath != "" { + authServerRC, err = loadAuthServerConfig(cfg.ConfigPath) + if err != nil { + return err + } } // Auto-populate SubjectProviderName on any token_exchange strategy that @@ -368,6 +411,37 @@ func loadAndValidateConfig(configPath string) (*config.Config, error) { return cfg, nil } +// generateQuickModeConfig constructs a minimal in-memory config for zero-config +// quick mode (thv vmcp serve --group <name>). It sets groupRef from groupRef, +// incomingAuth to anonymous, and outgoingAuth.source to "inline" so no +// Kubernetes API access is required. The generated config is validated before +// being returned; returns an error if groupRef is empty or validation fails. +func generateQuickModeConfig(groupRef string) (*config.Config, error) { + if groupRef == "" { + return nil, fmt.Errorf("--group must not be empty") + } + cfg := &config.Config{ + Name: groupRef, + Group: groupRef, + IncomingAuth: &config.IncomingAuthConfig{ + Type: config.IncomingAuthTypeAnonymous, + }, + OutgoingAuth: &config.OutgoingAuthConfig{ + Source: "inline", + }, + Aggregation: &config.AggregationConfig{ + ConflictResolution: vmcp.ConflictStrategyPrefix, + ConflictResolutionConfig: &config.ConflictResolutionConfig{ + PrefixFormat: "{workload}_", + }, + }, + } + if err := config.NewValidator().Validate(cfg); err != nil { + return nil, fmt.Errorf("quick-mode config validation failed: %w", err) + } + return cfg, nil +} + // loadAuthServerConfig loads the auth server RunConfig from a sibling file // alongside the main config. The operator serializes authserver.RunConfig as a // separate ConfigMap key (authserver-config.yaml). diff --git a/pkg/vmcp/cli/serve_test.go b/pkg/vmcp/cli/serve_test.go index 022ebe7a4a..667b285779 100644 --- a/pkg/vmcp/cli/serve_test.go +++ b/pkg/vmcp/cli/serve_test.go @@ -19,6 +19,7 @@ import ( "github.com/stacklok/toolhive/pkg/vmcp" aggregatormocks "github.com/stacklok/toolhive/pkg/vmcp/aggregator/mocks" clientmocks "github.com/stacklok/toolhive/pkg/vmcp/client/mocks" + "github.com/stacklok/toolhive/pkg/vmcp/config" vmcpmocks "github.com/stacklok/toolhive/pkg/vmcp/mocks" ) @@ -232,6 +233,110 @@ func TestRunDiscovery_KubernetesGroupNotFound(t *testing.T) { assert.Same(t, registry, gotRegistry) } +// TestGenerateQuickModeConfig covers the generateQuickModeConfig helper. +func TestGenerateQuickModeConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + groupRef string + wantErr bool + errContains string + }{ + { + name: "valid group sets groupRef and inline source", + groupRef: "default", + }, + { + name: "group name with hyphens", + groupRef: "my-group", + }, + { + name: "empty groupRef returns error", + groupRef: "", + wantErr: true, + errContains: "--group must not be empty", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cfg, err := generateQuickModeConfig(tc.groupRef) + if tc.wantErr { + require.Error(t, err) + require.ErrorContains(t, err, tc.errContains) + require.Nil(t, cfg) + return + } + + require.NoError(t, err) + require.NotNil(t, cfg) + require.Equal(t, tc.groupRef, cfg.Group) + require.NotNil(t, cfg.OutgoingAuth) + require.Equal(t, "inline", cfg.OutgoingAuth.Source) + require.NotNil(t, cfg.IncomingAuth) + require.Equal(t, "anonymous", cfg.IncomingAuth.Type) + // Verify the generated config passes the real validator. + require.NoError(t, config.NewValidator().Validate(cfg)) + }) + } +} + +// TestServe_NeitherConfigNorGroup verifies that Serve returns an error when +// both --config and --group are absent. +func TestServe_NeitherConfigNorGroup(t *testing.T) { + t.Parallel() + + err := Serve(t.Context(), ServeConfig{}) + require.Error(t, err) + require.ErrorContains(t, err, "--config or --group") +} + +// TestValidateQuickModeHost exercises ServeConfig.validateQuickModeHost directly +// so the test never starts the HTTP server and cannot hang. +func TestValidateQuickModeHost(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configPath string + groupRef string + host string + wantErr bool + errContains string + }{ + // Quick mode (no --config): loopback-only + {name: "quick mode: loopback IPv4 allowed", groupRef: "my-group", host: "127.0.0.1"}, + {name: "quick mode: loopback IPv6 allowed", groupRef: "my-group", host: "::1"}, + {name: "quick mode: localhost allowed", groupRef: "my-group", host: "localhost"}, + {name: "quick mode: empty host treated as loopback", groupRef: "my-group", host: ""}, + {name: "quick mode: all-interfaces rejected", groupRef: "my-group", host: "0.0.0.0", wantErr: true, errContains: "quick mode"}, + {name: "quick mode: LAN IP rejected", groupRef: "my-group", host: "192.168.1.10", wantErr: true, errContains: "quick mode"}, + {name: "quick mode: non-IP hostname rejected", groupRef: "my-group", host: "not-an-ip", wantErr: true, errContains: "quick mode"}, + // Config-file mode: host check does not apply + {name: "config mode: non-loopback allowed", configPath: "/some/config.yaml", host: "0.0.0.0"}, + // Both flags set: ConfigPath takes precedence, host check skipped + {name: "both flags: non-loopback allowed", configPath: "/some/config.yaml", groupRef: "my-group", host: "0.0.0.0"}, + // Neither flag: check is a no-op + {name: "neither flag: no-op", host: "0.0.0.0"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := ServeConfig{ConfigPath: tc.configPath, GroupRef: tc.groupRef, Host: tc.host}.validateQuickModeHost() + if tc.wantErr { + require.Error(t, err) + require.ErrorContains(t, err, tc.errContains) + } else { + require.NoError(t, err) + } + }) + } +} + // TestRunDiscovery_ZeroBackends exercises the branch in runDiscovery where the // discoverer succeeds but returns no backends. The function must return a // non-error, an empty (non-nil) backend slice, and pass through the client and From 92897a6d42994f14a8df4d9608f44e4b89c36f2c Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota <yolanda@stacklok.com> Date: Mon, 20 Apr 2026 11:36:08 +0200 Subject: [PATCH 082/117] embedding_manager: address Copilot review findings from #4912 (#4938) - Fix struct comment to use correct Kubernetes service name (mcp-<containerName>) - Guard against nil ContainerFactory in NewEmbeddingServiceManager - Use 127.0.0.1 instead of localhost in Docker URL/health closures - Validate model with strings.TrimSpace to reject whitespace-only values Closes #4937 Co-authored-by: taskbot <taskbot@users.noreply.github.com> --- pkg/vmcp/cli/embedding_manager.go | 17 ++++++++++------ pkg/vmcp/cli/embedding_manager_test.go | 28 +++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/pkg/vmcp/cli/embedding_manager.go b/pkg/vmcp/cli/embedding_manager.go index 7013647837..4353e9c5a1 100644 --- a/pkg/vmcp/cli/embedding_manager.go +++ b/pkg/vmcp/cli/embedding_manager.go @@ -18,6 +18,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "time" "github.com/stacklok/toolhive-core/permissions" @@ -96,9 +97,9 @@ type EmbeddingServiceManagerConfig struct { // // On Docker the container is bound to a localhost port and the model cache is // bind-mounted from the host. On Kubernetes the TEI pod is exposed via a -// ClusterIP Service and reached at http://<containerName>:<teiContainerPort>; -// the host bind-mount is skipped because Kubernetes ignores Docker permission -// profiles. +// ClusterIP Service named "mcp-<containerName>" and reached at +// http://mcp-<containerName>:<teiContainerPort>; the host bind-mount is +// skipped because Kubernetes ignores Docker permission profiles. type EmbeddingServiceManager struct { factory ContainerFactory cfg EmbeddingServiceManagerConfig @@ -128,8 +129,12 @@ type EmbeddingServiceManager struct { } // NewEmbeddingServiceManager constructs an EmbeddingServiceManager from the given -// factory and config. Returns an error when cfg.Model is empty. +// factory and config. Returns an error when factory is nil or cfg.Model is empty. func NewEmbeddingServiceManager(factory ContainerFactory, cfg EmbeddingServiceManagerConfig) (*EmbeddingServiceManager, error) { + if factory == nil { + return nil, fmt.Errorf("container factory must not be nil") + } + cfg.Model = strings.TrimSpace(cfg.Model) if cfg.Model == "" { return nil, fmt.Errorf("model must not be empty") } @@ -169,10 +174,10 @@ func NewEmbeddingServiceManager(factory ContainerFactory, cfg EmbeddingServiceMa } else { mgr.portFinder = networking.FindAvailable mgr.urlFor = func(port int) string { - return fmt.Sprintf("http://localhost:%d", port) + return fmt.Sprintf("http://127.0.0.1:%d", port) } mgr.healthURLFor = func(port int) string { - return fmt.Sprintf("http://localhost:%d%s", port, healthPath) + return fmt.Sprintf("http://127.0.0.1:%d%s", port, healthPath) } } diff --git a/pkg/vmcp/cli/embedding_manager_test.go b/pkg/vmcp/cli/embedding_manager_test.go index 2cd1ddf7ae..4e1dab6a7e 100644 --- a/pkg/vmcp/cli/embedding_manager_test.go +++ b/pkg/vmcp/cli/embedding_manager_test.go @@ -64,6 +64,13 @@ func TestContainerNameForModel(t *testing.T) { }) } +func TestNewEmbeddingServiceManager_NilFactory(t *testing.T) { + t.Parallel() + + _, err := NewEmbeddingServiceManager(nil, EmbeddingServiceManagerConfig{Model: "BAAI/bge-small-en-v1.5"}) + assert.ErrorContains(t, err, "factory") +} + func TestNewEmbeddingServiceManager_EmptyModel(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) @@ -73,6 +80,25 @@ func TestNewEmbeddingServiceManager_EmptyModel(t *testing.T) { assert.ErrorContains(t, err, "model") } +func TestNewEmbeddingServiceManager_WhitespaceModel(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockFactory := mocks.NewMockContainerFactory(ctrl) + + _, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{Model: " "}) + assert.ErrorContains(t, err, "model") +} + +func TestNewEmbeddingServiceManager_WhitespaceModelTrimmed(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockFactory := mocks.NewMockContainerFactory(ctrl) + + mgr, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{Model: " BAAI/bge-small-en-v1.5 "}) + require.NoError(t, err) + assert.Equal(t, "BAAI/bge-small-en-v1.5", mgr.cfg.Model) +} + func TestNewEmbeddingServiceManager_DefaultImage(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) @@ -186,7 +212,7 @@ func TestStart_DeployNewContainer(t *testing.T) { gotURL, err := mgr.Start(context.Background()) require.NoError(t, err) assert.True(t, mgr.started, "started must be true after deploying a new container") - assert.Contains(t, gotURL, "http://localhost:") + assert.Contains(t, gotURL, "http://127.0.0.1:") } // TestStart_DeployNewContainer_Kubernetes verifies that on a Kubernetes runtime From e6c453a861e853763c32ffb07d5748e7ecb9f64a Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota <yolanda@stacklok.com> Date: Mon, 20 Apr 2026 11:38:31 +0200 Subject: [PATCH 083/117] Add thv vmcp init subcommand (#4939) Wire the vmcp init subcommand into cmd/thv/app/vmcp.go, delegating to pkg/vmcp/cli.Init. Constructs a workloads.DiscovererAdapter from the CLI context and accepts --group/-g (required) and --config/-c (optional, defaults to stdout). Regenerate CLI docs. Closes #4885 Co-authored-by: taskbot <taskbot@users.noreply.github.com> --- cmd/thv/app/vmcp.go | 42 ++++++++++++++++++++++++++++ cmd/thv/app/vmcp_test.go | 58 +++++++++++++++++++++++++++++++++++++++ docs/cli/thv_vmcp.md | 1 + docs/cli/thv_vmcp_init.md | 50 +++++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 cmd/thv/app/vmcp_test.go create mode 100644 docs/cli/thv_vmcp_init.md diff --git a/cmd/thv/app/vmcp.go b/cmd/thv/app/vmcp.go index 6c8d0c2c0b..eea6a989f9 100644 --- a/cmd/thv/app/vmcp.go +++ b/cmd/thv/app/vmcp.go @@ -4,9 +4,12 @@ package app import ( + "fmt" + "github.com/spf13/cobra" vmcpcli "github.com/stacklok/toolhive/pkg/vmcp/cli" + "github.com/stacklok/toolhive/pkg/workloads" ) // newVMCPCommand returns the top-level "vmcp" Cobra command with subcommands attached. @@ -20,6 +23,7 @@ servers from a ToolHive group into a single unified endpoint.`, } cmd.AddCommand(newVMCPServeCommand()) cmd.AddCommand(newVMCPValidateCommand()) + cmd.AddCommand(newVMCPInitCommand()) return cmd } @@ -63,6 +67,44 @@ configuration file is needed for the common case of aggregating a local group.`, return cmd } +// newVMCPInitCommand returns the "vmcp init" subcommand. +func newVMCPInitCommand() *cobra.Command { + var ( + groupName string + outputPath string + ) + cmd := &cobra.Command{ + Use: "init", + Short: "Generate a starter vMCP configuration file", + Long: `Discover running workloads in a ToolHive group and generate a starter +vMCP YAML configuration file pre-populated with one backend entry per +accessible workload. + +The generated file can be reviewed and customized, then passed to +'thv vmcp validate --config' to check it and 'thv vmcp serve --config' +to start the aggregated server. + +If neither --output nor --config is provided, the generated YAML is written to stdout.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + manager, err := workloads.NewManager(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to create workload manager: %w", err) + } + return vmcpcli.Init(cmd.Context(), vmcpcli.InitConfig{ + GroupName: groupName, + OutputPath: outputPath, + Discoverer: workloads.NewDiscovererAdapter(manager), + }) + }, + } + cmd.Flags().StringVarP(&groupName, "group", "g", "", "ToolHive group name to discover workloads from (required)") + cmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output file path for the generated config (default: stdout)") + cmd.Flags().StringVarP(&outputPath, "config", "c", "", "Output file path for the generated config; alias for --output") + _ = cmd.MarkFlagRequired("group") + return cmd +} + // newVMCPValidateCommand returns the "vmcp validate" subcommand. func newVMCPValidateCommand() *cobra.Command { var configPath string diff --git a/cmd/thv/app/vmcp_test.go b/cmd/thv/app/vmcp_test.go new file mode 100644 index 0000000000..24ce1ba7d2 --- /dev/null +++ b/cmd/thv/app/vmcp_test.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewVMCPInitCommand_Flags(t *testing.T) { + t.Parallel() + + cmd := newVMCPInitCommand() + + groupFlag := cmd.Flags().Lookup("group") + require.NotNil(t, groupFlag, "expected --group flag to be registered") + assert.Equal(t, "g", groupFlag.Shorthand) + assert.Equal(t, "", groupFlag.DefValue) + + outputFlag := cmd.Flags().Lookup("output") + require.NotNil(t, outputFlag, "expected --output flag to be registered") + assert.Equal(t, "o", outputFlag.Shorthand) + assert.Equal(t, "", outputFlag.DefValue) + + configFlag := cmd.Flags().Lookup("config") + require.NotNil(t, configFlag, "expected --config flag to be registered") + assert.Equal(t, "c", configFlag.Shorthand) + assert.Equal(t, "", configFlag.DefValue) +} + +func TestNewVMCPInitCommand_GroupRequired(t *testing.T) { + t.Parallel() + + cmd := newVMCPInitCommand() + // Execute with no flags: Cobra should reject before RunE is called. + cmd.SetArgs([]string{}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "group") +} + +func TestNewVMCPCommand_InitRegistered(t *testing.T) { + t.Parallel() + + cmd := newVMCPCommand() + + var found bool + for _, sub := range cmd.Commands() { + if sub.Use == "init" { + found = true + break + } + } + assert.True(t, found, "expected 'init' to be registered as a subcommand of 'vmcp'") +} diff --git a/docs/cli/thv_vmcp.md b/docs/cli/thv_vmcp.md index 28652e90e7..d9d0ece7f4 100644 --- a/docs/cli/thv_vmcp.md +++ b/docs/cli/thv_vmcp.md @@ -34,6 +34,7 @@ servers from a ToolHive group into a single unified endpoint. ### SEE ALSO * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers +* [thv vmcp init](thv_vmcp_init.md) - Generate a starter vMCP configuration file * [thv vmcp serve](thv_vmcp_serve.md) - Start the Virtual MCP Server * [thv vmcp validate](thv_vmcp_validate.md) - Validate a vMCP configuration file diff --git a/docs/cli/thv_vmcp_init.md b/docs/cli/thv_vmcp_init.md new file mode 100644 index 0000000000..5f5c420e9e --- /dev/null +++ b/docs/cli/thv_vmcp_init.md @@ -0,0 +1,50 @@ +--- +title: thv vmcp init +hide_title: true +description: Reference for ToolHive CLI command `thv vmcp init` +last_update: + author: autogenerated +slug: thv_vmcp_init +mdx: + format: md +--- + +## thv vmcp init + +Generate a starter vMCP configuration file + +### Synopsis + +Discover running workloads in a ToolHive group and generate a starter +vMCP YAML configuration file pre-populated with one backend entry per +accessible workload. + +The generated file can be reviewed and customized, then passed to +'thv vmcp validate --config' to check it and 'thv vmcp serve --config' +to start the aggregated server. + +If neither --output nor --config is provided, the generated YAML is written to stdout. + +``` +thv vmcp init [flags] +``` + +### Options + +``` + -c, --config string Output file path for the generated config; alias for --output + -g, --group string ToolHive group name to discover workloads from (required) + -h, --help help for init + -o, --output string Output file path for the generated config (default: stdout) +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv vmcp](thv_vmcp.md) - Run and manage a Virtual MCP Server locally + From c17cb40c7b0a3ce9db386e014579e4fe618f3519 Mon Sep 17 00:00:00 2001 From: Muhammad Amir Ejaz <amir@stacklok.com> Date: Mon, 20 Apr 2026 16:09:54 +0500 Subject: [PATCH 084/117] Add --system flag to secret list and delete commands (#4910) * Add --system flag to secret list and delete commands Allows advanced/emergency inspection and removal of stale system-managed tokens (__thv_* keys: registry OAuth, workload auth, enterprise login) that are hidden from normal secret commands. Uses a raw unscoped provider that bypasses the UserProvider filter. - thv secret list --system: shows all __thv_* keys with scope label - thv secret delete --system: deletes a named __thv_* key; rejects non-system keys to prevent misuse Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add unit tests and fix linter errors for --system secret commands Extract runSystemSecretList, runSystemSecretDelete, formatSystemSecretEntry, and validateSystemKeyName as testable helpers. Add 22 unit tests covering mock-based and integration paths against a real EncryptedManager. Fix errcheck linter errors: check fmt.Fprintln return values in runSystemSecretList. Fix lll violation: shorten --system flag description to stay within the 130-char line limit. Regenerate CLI docs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Show full key in system secret list for copy-paste deletion thv secret list --system previously displayed the short name (e.g. OAUTH_REFRESH_TOKEN_foo [workloads]) but delete --system required the full internal key (__thv_workloads_OAUTH_REFRESH_TOKEN_foo). Users had no way to derive the correct key from the list output. Show the full __thv_<scope>_<name> key so it can be passed directly to thv secret delete --system without guesswork. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Address PR review feedback on --system secret commands - Move getSystemSecretsProvider to pkg/auth/secrets as GetSystemSecretsProvider, matching the GetUserSecretsProvider pattern and satisfying the thin-wrapper principle for CLI commands - Add CanList / CanDelete capability checks to the --system list and delete paths so read-only providers (1Password, env) produce the same friendly error message as the non-system paths - Call warnWorkloadsUsingSecret before system key deletion; strip the __thv_<scope>_ prefix to match the bare name stored in workload configs - Remove the redundant validateSystemKeyName call from runSystemSecretDelete; validation is now owned solely by the RunE closure which calls it before touching the provider Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add ParseSystemKey and export IsSystemKey to pkg/secrets The __thv_<scope>_<name> key format was parsed by hand in two places in the CLI layer. Centralise the parsing in pkg/secrets, which owns the format, so call sites don't need to duplicate TrimPrefix + Cut logic. - Export isSystemKey as IsSystemKey so CLI code no longer needs to call strings.HasPrefix(name, secrets.SystemKeyPrefix) directly - Add ParseSystemKey(key) (scope, name, ok) for the same reason - Wire formatSystemSecretEntry, validateSystemKeyName, and the workload warning strip in the delete RunE to the new helpers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --- cmd/thv/app/secret.go | 118 +++++++++++++- cmd/thv/app/secret_test.go | 292 ++++++++++++++++++++++++++++++++++ docs/cli/thv_secret_delete.md | 3 +- docs/cli/thv_secret_list.md | 3 +- pkg/auth/secrets/secrets.go | 25 +++ pkg/secrets/migration.go | 2 +- pkg/secrets/scoped.go | 28 +++- 7 files changed, 455 insertions(+), 16 deletions(-) create mode 100644 cmd/thv/app/secret_test.go diff --git a/cmd/thv/app/secret.go b/cmd/thv/app/secret.go index 641487d611..37af3a234f 100644 --- a/cmd/thv/app/secret.go +++ b/cmd/thv/app/secret.go @@ -225,7 +225,9 @@ The secret must exist in your configured secrets provider, otherwise the command } func newSecretDeleteCommand() *cobra.Command { - return &cobra.Command{ + var systemFlag bool + + cmd := &cobra.Command{ Use: "delete <name>", Short: "Delete a secret", Long: `Remove a secret from the configured secrets provider. @@ -245,6 +247,29 @@ If your provider is read-only or doesn't support deletion, this command returns return fmt.Errorf("validation error: secret name cannot be empty") } + if systemFlag { + // Validate the key name before touching the provider so a + // typo surfaces the right error even when secrets are not set up. + if err := validateSystemKeyName(name); err != nil { + return err + } + provider, err := authsecrets.GetSystemSecretsProvider() + if err != nil { + return fmt.Errorf("failed to create secrets provider: %w", err) + } + if !provider.Capabilities().CanDelete { + configProvider := config.NewDefaultProvider() + cfg := configProvider.GetConfig() + providerType, _ := cfg.Secrets.GetProviderType() + return fmt.Errorf("the %s secrets provider does not support deleting secrets", providerType) + } + // Workload configs reference the bare (unscoped) name, so strip + // the __thv_<scope>_ prefix before searching for affected workloads. + _, bareName, _ := secrets.ParseSystemKey(name) + warnWorkloadsUsingSecret(ctx, bareName) + return runSystemSecretDelete(ctx, provider, name) + } + manager, err := getSecretsManager() if err != nil { return fmt.Errorf("failed to create secrets manager: %w", err) @@ -269,10 +294,16 @@ If your provider is read-only or doesn't support deletion, this command returns return nil }, } + + cmd.Flags().BoolVar(&systemFlag, "system", false, "Allow deleting a system-managed secret (emergency use only)") + + return cmd } func newSecretListCommand() *cobra.Command { - return &cobra.Command{ + var systemFlag bool + + cmd := &cobra.Command{ Use: "list", Short: "List all available secrets", Long: `Display all secrets available in the configured secrets provider. @@ -282,6 +313,21 @@ If descriptions exist for the secrets, the command displays them alongside the n Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() + + if systemFlag { + provider, err := authsecrets.GetSystemSecretsProvider() + if err != nil { + return fmt.Errorf("failed to create secrets provider: %w", err) + } + if !provider.Capabilities().CanList { + configProvider := config.NewDefaultProvider() + cfg := configProvider.GetConfig() + providerType, _ := cfg.Secrets.GetProviderType() + return fmt.Errorf("the %s secrets provider does not support listing secrets", providerType) + } + return runSystemSecretList(ctx, provider, os.Stdout) + } + manager, err := getSecretsManager() if err != nil { return fmt.Errorf("failed to create secrets manager: %w", err) @@ -295,18 +341,18 @@ If descriptions exist for the secrets, the command displays them alongside the n return fmt.Errorf("the %s secrets provider does not support listing secrets", providerType) } - secrets, err := manager.ListSecrets(ctx) + listedSecrets, err := manager.ListSecrets(ctx) if err != nil { return fmt.Errorf("failed to list secrets: %w", err) } - if len(secrets) == 0 { + if len(listedSecrets) == 0 { fmt.Println("No secrets found") return nil } fmt.Println("Available secrets:") - for _, description := range secrets { + for _, description := range listedSecrets { fmt.Printf(" - %s", description.Key) // Add description if available. if description.Description != "" { @@ -318,6 +364,10 @@ If descriptions exist for the secrets, the command displays them alongside the n return nil }, } + + cmd.Flags().BoolVar(&systemFlag, "system", false, "List system-managed secrets (registry auth, workload tokens)") + + return cmd } func newSecretResetKeyringCommand() *cobra.Command { @@ -431,6 +481,64 @@ For more information, visit: https://developer.1password.com/docs/service-accoun return nil } +// runSystemSecretList lists system-managed secrets from the given provider, +// writing formatted output to w. Only keys prefixed with SystemKeyPrefix are shown. +func runSystemSecretList(ctx context.Context, provider secrets.Provider, w io.Writer) error { + allSecrets, err := provider.ListSecrets(ctx) + if err != nil { + return fmt.Errorf("failed to list secrets: %w", err) + } + + var systemSecrets []secrets.SecretDescription + for _, s := range allSecrets { + if strings.HasPrefix(s.Key, secrets.SystemKeyPrefix) { + systemSecrets = append(systemSecrets, s) + } + } + + if len(systemSecrets) == 0 { + _, err = fmt.Fprintln(w, "No system-managed secrets found") + return err + } + + if _, err = fmt.Fprintln(w, "System-managed secrets:"); err != nil { + return err + } + for _, s := range systemSecrets { + if _, err = fmt.Fprintln(w, formatSystemSecretEntry(s.Key)); err != nil { + return err + } + } + + return nil +} + +// runSystemSecretDelete deletes a system-managed key from provider. +// Callers are responsible for validating the key name with validateSystemKeyName +// before calling this function. +func runSystemSecretDelete(ctx context.Context, provider secrets.Provider, name string) error { + if err := provider.DeleteSecret(ctx, name); err != nil { + return fmt.Errorf("failed to delete secret %s: %w", name, err) + } + return nil +} + +// formatSystemSecretEntry formats a system-managed secret key for display. +// Key format: __thv_<scope>_<name> +// The full key is shown so it can be passed directly to "thv secret delete --system". +func formatSystemSecretEntry(key string) string { + scope, _, _ := secrets.ParseSystemKey(key) + return fmt.Sprintf(" - %s [%s]", key, scope) +} + +// validateSystemKeyName returns an error if name is not a system-managed key. +func validateSystemKeyName(name string) error { + if !secrets.IsSystemKey(name) { + return fmt.Errorf("--system flag requires a system key (starting with %q); got %q", secrets.SystemKeyPrefix, name) + } + return nil +} + // warnWorkloadsUsingSecret checks if any workloads use the specified secret // and prints a warning message if so. func warnWorkloadsUsingSecret(ctx context.Context, secretName string) { diff --git a/cmd/thv/app/secret_test.go b/cmd/thv/app/secret_test.go new file mode 100644 index 0000000000..e822877a51 --- /dev/null +++ b/cmd/thv/app/secret_test.go @@ -0,0 +1,292 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/secrets" + secretsmocks "github.com/stacklok/toolhive/pkg/secrets/mocks" +) + +func TestFormatSystemSecretEntry(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + expected string + }{ + { + name: "simple scope and name", + key: "__thv_auth_session", + expected: " - __thv_auth_session [auth]", + }, + { + name: "name contains underscores, only first underscore splits scope", + key: "__thv_registry_REGISTRY_OAUTH_abc12345", + expected: " - __thv_registry_REGISTRY_OAUTH_abc12345 [registry]", + }, + { + name: "name contains underscore", + key: "__thv_workloads_token_abc", + expected: " - __thv_workloads_token_abc [workloads]", + }, + { + name: "name with multiple underscores", + key: "__thv_auth_session_access", + expected: " - __thv_auth_session_access [auth]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := formatSystemSecretEntry(tt.key) + require.Equal(t, tt.expected, got) + }) + } +} + +func TestValidateSystemKeyName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + wantErr bool + errContains []string + }{ + { + name: "valid system key with scope and name", + key: "__thv_auth_session", + wantErr: false, + }, + { + name: "valid system key with underscores in name", + key: "__thv_registry_REGISTRY_OAUTH_abc", + wantErr: false, + }, + { + name: "plain user secret rejected", + key: "my-secret", + wantErr: true, + errContains: []string{"--system", "__thv_"}, + }, + { + name: "missing double underscore prefix rejected", + key: "thv_auth_session", + wantErr: true, + errContains: []string{"--system", "__thv_"}, + }, + { + name: "empty string rejected", + key: "", + wantErr: true, + errContains: []string{"--system", "__thv_"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateSystemKeyName(tt.key) + if tt.wantErr { + require.Error(t, err) + for _, fragment := range tt.errContains { + require.True(t, strings.Contains(err.Error(), fragment), + "expected error message to contain %q, got: %s", fragment, err.Error()) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestRunSystemSecretList(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + storedKeys []secrets.SecretDescription + listErr error + wantErr bool + wantContains []string + wantAbsent []string + }{ + { + name: "system keys shown with scope labels", + storedKeys: []secrets.SecretDescription{ + {Key: "__thv_auth_session"}, + {Key: "__thv_registry_REGISTRY_OAUTH_abc12345"}, + }, + wantContains: []string{ + "System-managed secrets:", + " - __thv_auth_session [auth]", + " - __thv_registry_REGISTRY_OAUTH_abc12345 [registry]", + }, + }, + { + name: "non-system keys filtered out", + storedKeys: []secrets.SecretDescription{ + {Key: "my-user-secret"}, + {Key: "__thv_auth_session"}, + }, + wantContains: []string{" - __thv_auth_session [auth]"}, + wantAbsent: []string{"my-user-secret"}, + }, + { + name: "no system keys prints empty message", + storedKeys: []secrets.SecretDescription{{Key: "user-secret"}}, + wantContains: []string{"No system-managed secrets found"}, + wantAbsent: []string{"System-managed secrets:"}, + }, + { + name: "empty store prints empty message", + storedKeys: nil, + wantContains: []string{"No system-managed secrets found"}, + }, + { + name: "provider list error is returned", + listErr: errors.New("backend unavailable"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + provider := secretsmocks.NewMockProvider(ctrl) + provider.EXPECT().ListSecrets(gomock.Any()).Return(tt.storedKeys, tt.listErr) + + var buf bytes.Buffer + err := runSystemSecretList(context.Background(), provider, &buf) + + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + out := buf.String() + for _, want := range tt.wantContains { + require.Contains(t, out, want) + } + for _, absent := range tt.wantAbsent { + require.NotContains(t, out, absent) + } + }) + } +} + +func TestRunSystemSecretDelete(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + deleteErr error + wantErr bool + }{ + { + name: "valid system key is deleted", + key: "__thv_auth_session", + }, + { + name: "valid key with underscores in name is deleted", + key: "__thv_registry_REGISTRY_OAUTH_abc", + }, + { + name: "provider delete error is propagated", + key: "__thv_auth_session", + deleteErr: errors.New("keyring locked"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + provider := secretsmocks.NewMockProvider(ctrl) + provider.EXPECT().DeleteSecret(gomock.Any(), tt.key).Return(tt.deleteErr) + + err := runSystemSecretDelete(context.Background(), provider, tt.key) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +// newTestEncryptedProvider creates a real EncryptedManager backed by a temp +// file for integration-style tests. It does not touch the OS keyring. +func newTestEncryptedProvider(t *testing.T) secrets.Provider { + t.Helper() + + key := sha256.Sum256([]byte("integration-test-password")) + filePath := filepath.Join(t.TempDir(), "secrets_encrypted") + + provider, err := secrets.NewEncryptedManager(filePath, key[:]) + require.NoError(t, err) + return provider +} + +// TestRunSystemSecretListIntegration exercises runSystemSecretList against a +// real EncryptedManager instead of a mock, giving end-to-end coverage of the +// filtering and formatting path with actual encrypted storage. +// +//nolint:paralleltest // Uses real encrypted file; parallel is safe but serial keeps output readable +func TestRunSystemSecretListIntegration(t *testing.T) { + ctx := context.Background() + provider := newTestEncryptedProvider(t) + + // Seed a mix of system and user keys. + require.NoError(t, provider.SetSecret(ctx, "__thv_auth_session", "enterprise_refresh_tok")) + require.NoError(t, provider.SetSecret(ctx, "__thv_registry_REGISTRY_OAUTH_deadbeef", "registry_oauth_tok")) + require.NoError(t, provider.SetSecret(ctx, "user-visible-secret", "should-not-appear")) + + var buf bytes.Buffer + require.NoError(t, runSystemSecretList(ctx, provider, &buf)) + + out := buf.String() + require.Contains(t, out, "System-managed secrets:") + require.Contains(t, out, " - __thv_auth_session [auth]") + require.Contains(t, out, " - __thv_registry_REGISTRY_OAUTH_deadbeef [registry]") + require.NotContains(t, out, "user-visible-secret") +} + +// TestRunSystemSecretDeleteIntegration exercises the full delete path against a +// real EncryptedManager: seed a system key, delete it, confirm it's gone. +// +//nolint:paralleltest // Uses real encrypted file; serial keeps output readable +func TestRunSystemSecretDeleteIntegration(t *testing.T) { + ctx := context.Background() + provider := newTestEncryptedProvider(t) + + const key = "__thv_auth_session" + require.NoError(t, provider.SetSecret(ctx, key, "enterprise_refresh_tok")) + + // Delete the key via the function under test. + require.NoError(t, runSystemSecretDelete(ctx, provider, key)) + + // Confirm the key is gone. + _, err := provider.GetSecret(ctx, key) + require.Error(t, err, "key should no longer exist after deletion") +} diff --git a/docs/cli/thv_secret_delete.md b/docs/cli/thv_secret_delete.md index 2296ae6739..00e972d12c 100644 --- a/docs/cli/thv_secret_delete.md +++ b/docs/cli/thv_secret_delete.md @@ -30,7 +30,8 @@ thv secret delete <name> [flags] ### Options ``` - -h, --help help for delete + -h, --help help for delete + --system Allow deleting a system-managed secret (emergency use only) ``` ### Options inherited from parent commands diff --git a/docs/cli/thv_secret_list.md b/docs/cli/thv_secret_list.md index 8ffefa0c34..1a51255544 100644 --- a/docs/cli/thv_secret_list.md +++ b/docs/cli/thv_secret_list.md @@ -27,7 +27,8 @@ thv secret list [flags] ### Options ``` - -h, --help help for list + -h, --help help for list + --system List system-managed secrets (registry auth, workload tokens) ``` ### Options inherited from parent commands diff --git a/pkg/auth/secrets/secrets.go b/pkg/auth/secrets/secrets.go index 39d5793d1d..679fe782ea 100644 --- a/pkg/auth/secrets/secrets.go +++ b/pkg/auth/secrets/secrets.go @@ -201,6 +201,31 @@ func GetUserSecretsProvider() (secrets.Provider, error) { return provider, nil } +// GetSystemSecretsProvider returns a raw (unscoped) secrets provider for +// advanced/emergency operations on system-managed keys. Unlike +// GetUserSecretsProvider it does not apply UserProvider filtering, so callers +// can read and delete __thv_* prefixed keys directly. +func GetSystemSecretsProvider() (secrets.Provider, error) { + configProvider := config.NewDefaultProvider() + cfg := configProvider.GetConfig() + + if !cfg.Secrets.SetupCompleted { + return nil, secrets.ErrSecretsNotSetup + } + + providerType, err := cfg.Secrets.GetProviderType() + if err != nil { + return nil, fmt.Errorf("failed to get secrets provider type: %w", err) + } + + provider, err := secrets.CreateProvider(providerType) + if err != nil { + return nil, fmt.Errorf("failed to create secrets provider: %w", err) + } + + return provider, nil +} + // GetSecretsManager returns the secrets manager instance // This is exported so it can be reused by other packages func GetSecretsManager() (secrets.Provider, error) { diff --git a/pkg/secrets/migration.go b/pkg/secrets/migration.go index bc3e5514dc..0d59aab890 100644 --- a/pkg/secrets/migration.go +++ b/pkg/secrets/migration.go @@ -87,7 +87,7 @@ func DiscoverMigrations(ctx context.Context, provider Provider) ([]KeyMigration, for _, desc := range all { key := desc.Key // Skip already-migrated keys. - if isSystemKey(key) { + if IsSystemKey(key) { continue } for _, mapping := range SystemKeyPrefixMappings { diff --git a/pkg/secrets/scoped.go b/pkg/secrets/scoped.go index 47c57967c0..b3dc539cff 100644 --- a/pkg/secrets/scoped.go +++ b/pkg/secrets/scoped.go @@ -192,7 +192,7 @@ func NewUserProvider(inner Provider) Provider { // GetSecret returns the secret for name, or ErrReservedKeyName if the name is // a system-reserved key. func (u *UserProvider) GetSecret(ctx context.Context, name string) (string, error) { - if isSystemKey(name) { + if IsSystemKey(name) { return "", fmt.Errorf("%w: cannot get %q", ErrReservedKeyName, name) } return u.provider.GetSecret(ctx, name) @@ -201,7 +201,7 @@ func (u *UserProvider) GetSecret(ctx context.Context, name string) (string, erro // SetSecret stores value under name, or returns ErrReservedKeyName if the name // is system-reserved. func (u *UserProvider) SetSecret(ctx context.Context, name, value string) error { - if isSystemKey(name) { + if IsSystemKey(name) { return fmt.Errorf("%w: cannot set %q", ErrReservedKeyName, name) } return u.provider.SetSecret(ctx, name, value) @@ -210,7 +210,7 @@ func (u *UserProvider) SetSecret(ctx context.Context, name, value string) error // DeleteSecret removes name from the underlying store, or returns // ErrReservedKeyName if the name is system-reserved. func (u *UserProvider) DeleteSecret(ctx context.Context, name string) error { - if isSystemKey(name) { + if IsSystemKey(name) { return fmt.Errorf("%w: cannot delete %q", ErrReservedKeyName, name) } return u.provider.DeleteSecret(ctx, name) @@ -226,7 +226,7 @@ func (u *UserProvider) ListSecrets(ctx context.Context) ([]SecretDescription, er var result []SecretDescription for _, desc := range all { - if !isSystemKey(desc.Key) { + if !IsSystemKey(desc.Key) { result = append(result, desc) } } @@ -239,7 +239,7 @@ func (u *UserProvider) ListSecrets(ctx context.Context) ([]SecretDescription, er // ErrReservedKeyName is returned without deleting anything. func (u *UserProvider) DeleteSecrets(ctx context.Context, names []string) error { for _, name := range names { - if isSystemKey(name) { + if IsSystemKey(name) { return fmt.Errorf("%w: cannot delete %q", ErrReservedKeyName, name) } } @@ -259,7 +259,7 @@ func (u *UserProvider) Cleanup() error { var toDelete []string for _, desc := range all { - if !isSystemKey(desc.Key) { + if !IsSystemKey(desc.Key) { toDelete = append(toDelete, desc.Key) } } @@ -274,8 +274,20 @@ func (u *UserProvider) Capabilities() ProviderCapabilities { return u.provider.Capabilities() } -// isSystemKey reports whether name is reserved for system use, i.e. whether it +// IsSystemKey reports whether name is reserved for system use, i.e. whether it // starts with the system key prefix "__thv_". -func isSystemKey(name string) bool { +func IsSystemKey(name string) bool { return strings.HasPrefix(name, SystemKeyPrefix) } + +// ParseSystemKey parses a system-managed key of the form "__thv_<scope>_<name>" +// and returns its scope and name components. ok is false if key does not start +// with SystemKeyPrefix or contains no scope separator. +func ParseSystemKey(key string) (scope, name string, ok bool) { + rest, found := strings.CutPrefix(key, SystemKeyPrefix) + if !found { + return "", "", false + } + scope, name, ok = strings.Cut(rest, "_") + return scope, name, ok +} From d687eb7e7fe0e46595957464bdc844066404eff6 Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota <yolanda@stacklok.com> Date: Mon, 20 Apr 2026 15:07:49 +0200 Subject: [PATCH 085/117] Wire optimizer flags into thv vmcp serve (#4940) Add --optimizer, --optimizer-embedding, --embedding-model, and --embedding-image flags to the serve subcommand. Extend ServeConfig with the four corresponding fields. In Serve(), inject a non-nil OptimizerConfig when either Tier 1 or Tier 2 is active, start the TEI container via EmbeddingServiceManager when Tier 2 is requested, and defer Stop() for clean shutdown. --optimizer-embedding implies --optimizer. Regenerate CLI docs. Closes #4887 Co-authored-by: taskbot <taskbot@users.noreply.github.com> --- cmd/thv/app/vmcp.go | 36 +++++-- docs/cli/thv_vmcp_serve.md | 16 +-- pkg/vmcp/cli/embedding_manager.go | 3 + pkg/vmcp/cli/optimizer_wiring_test.go | 147 ++++++++++++++++++++++++++ pkg/vmcp/cli/serve.go | 72 +++++++++++++ 5 files changed, 258 insertions(+), 16 deletions(-) create mode 100644 pkg/vmcp/cli/optimizer_wiring_test.go diff --git a/cmd/thv/app/vmcp.go b/cmd/thv/app/vmcp.go index eea6a989f9..ef3cf72e81 100644 --- a/cmd/thv/app/vmcp.go +++ b/cmd/thv/app/vmcp.go @@ -30,11 +30,15 @@ servers from a ToolHive group into a single unified endpoint.`, // newVMCPServeCommand returns the "vmcp serve" subcommand. func newVMCPServeCommand() *cobra.Command { var ( - configPath string - group string - host string - port int - enableAudit bool + configPath string + group string + host string + port int + enableAudit bool + enableOptimizer bool + enableEmbedding bool + embeddingModel string + embeddingImage string ) cmd := &cobra.Command{ Use: "serve", @@ -51,16 +55,28 @@ configuration file is needed for the common case of aggregating a local group.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return vmcpcli.Serve(cmd.Context(), vmcpcli.ServeConfig{ - ConfigPath: configPath, - GroupRef: group, - Host: host, - Port: port, - EnableAudit: enableAudit, + ConfigPath: configPath, + GroupRef: group, + Host: host, + Port: port, + EnableAudit: enableAudit, + EnableOptimizer: enableOptimizer, + EnableEmbedding: enableEmbedding, + EmbeddingModel: embeddingModel, + EmbeddingImage: embeddingImage, }) }, } cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to vMCP configuration file") cmd.Flags().StringVar(&group, "group", "", "ToolHive group name (zero-config quick mode when --config is omitted)") + cmd.Flags().BoolVar(&enableOptimizer, "optimizer", false, + "Enable FTS5 keyword optimizer (Tier 1): exposes find_tool and call_tool instead of all backend tools") + cmd.Flags().BoolVar(&enableEmbedding, "optimizer-embedding", false, + "Enable managed TEI semantic optimizer (Tier 2); implies --optimizer") + cmd.Flags().StringVar(&embeddingModel, "embedding-model", "BAAI/bge-small-en-v1.5", + "HuggingFace model name for semantic search (Tier 2)") + cmd.Flags().StringVar(&embeddingImage, "embedding-image", + "ghcr.io/huggingface/text-embeddings-inference:cpu-latest", "TEI container image (Tier 2)") cmd.Flags().StringVar(&host, "host", "127.0.0.1", "Host address to bind to") cmd.Flags().IntVar(&port, "port", 4483, "Port to listen on") cmd.Flags().BoolVar(&enableAudit, "enable-audit", false, "Enable audit logging with default configuration") diff --git a/docs/cli/thv_vmcp_serve.md b/docs/cli/thv_vmcp_serve.md index 8ad0b393eb..f20718f6a4 100644 --- a/docs/cli/thv_vmcp_serve.md +++ b/docs/cli/thv_vmcp_serve.md @@ -32,12 +32,16 @@ thv vmcp serve [flags] ### Options ``` - -c, --config string Path to vMCP configuration file - --enable-audit Enable audit logging with default configuration - --group string ToolHive group name (zero-config quick mode when --config is omitted) - -h, --help help for serve - --host string Host address to bind to (default "127.0.0.1") - --port int Port to listen on (default 4483) + -c, --config string Path to vMCP configuration file + --embedding-image string TEI container image (Tier 2) (default "ghcr.io/huggingface/text-embeddings-inference:cpu-latest") + --embedding-model string HuggingFace model name for semantic search (Tier 2) (default "BAAI/bge-small-en-v1.5") + --enable-audit Enable audit logging with default configuration + --group string ToolHive group name (zero-config quick mode when --config is omitted) + -h, --help help for serve + --host string Host address to bind to (default "127.0.0.1") + --optimizer Enable FTS5 keyword optimizer (Tier 1): exposes find_tool and call_tool instead of all backend tools + --optimizer-embedding Enable managed TEI semantic optimizer (Tier 2); implies --optimizer + --port int Port to listen on (default 4483) ``` ### Options inherited from parent commands diff --git a/pkg/vmcp/cli/embedding_manager.go b/pkg/vmcp/cli/embedding_manager.go index 4353e9c5a1..9171397856 100644 --- a/pkg/vmcp/cli/embedding_manager.go +++ b/pkg/vmcp/cli/embedding_manager.go @@ -31,6 +31,9 @@ const ( // defaultTEIImage is the default HuggingFace Text Embeddings Inference image. defaultTEIImage = "ghcr.io/huggingface/text-embeddings-inference:cpu-latest" + // DefaultEmbeddingModel is the HuggingFace model used when EmbeddingModel is empty. + DefaultEmbeddingModel = "BAAI/bge-small-en-v1.5" + // teiModelCachePath is the path inside the TEI container where models are cached. teiModelCachePath = "/data" diff --git a/pkg/vmcp/cli/optimizer_wiring_test.go b/pkg/vmcp/cli/optimizer_wiring_test.go new file mode 100644 index 0000000000..b0f6e0a619 --- /dev/null +++ b/pkg/vmcp/cli/optimizer_wiring_test.go @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" +) + +// stubEmbeddingManager is a test double for the embeddingManager interface. +type stubEmbeddingManager struct { + startURL string + startErr error + stopErr error + startSeen bool + stopSeen bool +} + +func (s *stubEmbeddingManager) Start(_ context.Context) (string, error) { + s.startSeen = true + return s.startURL, s.startErr +} + +func (s *stubEmbeddingManager) Stop(_ context.Context) error { + s.stopSeen = true + return s.stopErr +} + +func TestInjectOptimizerConfig_NeitherTierEnabled(t *testing.T) { + t.Parallel() + + vmcpCfg := &vmcpconfig.Config{} + cfg := ServeConfig{EnableOptimizer: false, EnableEmbedding: false} + + cleanup, err := injectOptimizerConfig(context.Background(), cfg, vmcpCfg, nil) + + require.NoError(t, err) + assert.Nil(t, cleanup) + assert.Nil(t, vmcpCfg.Optimizer, "Optimizer must remain nil when neither tier is enabled") +} + +func TestInjectOptimizerConfig_Tier1Only(t *testing.T) { + t.Parallel() + + vmcpCfg := &vmcpconfig.Config{} + cfg := ServeConfig{EnableOptimizer: true, EnableEmbedding: false} + + cleanup, err := injectOptimizerConfig(context.Background(), cfg, vmcpCfg, nil) + + require.NoError(t, err) + assert.Nil(t, cleanup, "Tier 1 does not start TEI — no cleanup needed") + require.NotNil(t, vmcpCfg.Optimizer) + assert.Empty(t, vmcpCfg.Optimizer.EmbeddingService, "Tier 1 must not set an embedding service URL") +} + +func TestInjectOptimizerConfig_Tier1_PreservesExistingOptimizerConfig(t *testing.T) { + t.Parallel() + + existing := &vmcpconfig.OptimizerConfig{MaxToolsToReturn: 5} + vmcpCfg := &vmcpconfig.Config{Optimizer: existing} + cfg := ServeConfig{EnableOptimizer: true, EnableEmbedding: false} + + _, err := injectOptimizerConfig(context.Background(), cfg, vmcpCfg, nil) + + require.NoError(t, err) + assert.Same(t, existing, vmcpCfg.Optimizer, "Existing optimizer config must not be replaced") +} + +func TestInjectOptimizerConfig_Tier2_SetsEmbeddingURL(t *testing.T) { + t.Parallel() + + stub := &stubEmbeddingManager{startURL: "http://127.0.0.1:8080"} + vmcpCfg := &vmcpconfig.Config{} + cfg := ServeConfig{EnableEmbedding: true} + + cleanup, err := injectOptimizerConfig(context.Background(), cfg, vmcpCfg, stub) + + require.NoError(t, err) + require.NotNil(t, cleanup, "Tier 2 must return a cleanup func") + assert.True(t, stub.startSeen, "Start must be called for Tier 2") + require.NotNil(t, vmcpCfg.Optimizer) + assert.Equal(t, "http://127.0.0.1:8080", vmcpCfg.Optimizer.EmbeddingService) +} + +func TestInjectOptimizerConfig_Tier2_ImpliesOptimizer(t *testing.T) { + t.Parallel() + + stub := &stubEmbeddingManager{startURL: "http://127.0.0.1:8080"} + vmcpCfg := &vmcpconfig.Config{} + // EnableOptimizer is false — EnableEmbedding alone must activate the optimizer. + cfg := ServeConfig{EnableOptimizer: false, EnableEmbedding: true} + + _, err := injectOptimizerConfig(context.Background(), cfg, vmcpCfg, stub) + + require.NoError(t, err) + assert.NotNil(t, vmcpCfg.Optimizer, "Tier 2 must activate optimizer even without --optimizer flag") +} + +func TestInjectOptimizerConfig_Tier2_StartError(t *testing.T) { + t.Parallel() + + startErr := errors.New("docker daemon unavailable") + stub := &stubEmbeddingManager{startErr: startErr} + vmcpCfg := &vmcpconfig.Config{} + cfg := ServeConfig{EnableEmbedding: true} + + cleanup, err := injectOptimizerConfig(context.Background(), cfg, vmcpCfg, stub) + + require.Error(t, err) + assert.ErrorContains(t, err, "docker daemon unavailable") + assert.Nil(t, cleanup, "No cleanup func must be returned on Start failure") +} + +func TestInjectOptimizerConfig_Tier2_NilManagerReturnsError(t *testing.T) { + t.Parallel() + + vmcpCfg := &vmcpconfig.Config{} + cfg := ServeConfig{EnableEmbedding: true} + + cleanup, err := injectOptimizerConfig(context.Background(), cfg, vmcpCfg, nil) + + require.Error(t, err) + assert.ErrorContains(t, err, "embedding manager must not be nil") + assert.Nil(t, cleanup) +} + +func TestInjectOptimizerConfig_Tier2_CleanupCallsStop(t *testing.T) { + t.Parallel() + + stub := &stubEmbeddingManager{startURL: "http://127.0.0.1:9999"} + vmcpCfg := &vmcpconfig.Config{} + cfg := ServeConfig{EnableEmbedding: true} + + cleanup, err := injectOptimizerConfig(context.Background(), cfg, vmcpCfg, stub) + require.NoError(t, err) + require.NotNil(t, cleanup) + + cleanup() + assert.True(t, stub.stopSeen, "cleanup func must call Stop on the embedding manager") +} diff --git a/pkg/vmcp/cli/serve.go b/pkg/vmcp/cli/serve.go index 9b42b8d926..34b7fb28ce 100644 --- a/pkg/vmcp/cli/serve.go +++ b/pkg/vmcp/cli/serve.go @@ -27,6 +27,7 @@ import ( authserverconfig "github.com/stacklok/toolhive/pkg/authserver" authserverrunner "github.com/stacklok/toolhive/pkg/authserver/runner" "github.com/stacklok/toolhive/pkg/authserver/server/keys" + "github.com/stacklok/toolhive/pkg/container" "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/groups" "github.com/stacklok/toolhive/pkg/migration" @@ -67,6 +68,18 @@ type ServeConfig struct { // EnableAudit enables audit logging with default configuration when // the loaded config does not already define an audit section. EnableAudit bool + + // Optimizer tier selection (Phase 4 — flag-driven). + // EnableOptimizer enables Tier 1 FTS5 keyword search (find_tool / call_tool). + EnableOptimizer bool + // EnableEmbedding enables Tier 2 TEI semantic search; implies EnableOptimizer. + EnableEmbedding bool + // EmbeddingModel is the HuggingFace model name for the managed TEI container. + // Defaults to "BAAI/bge-small-en-v1.5" when empty. + EmbeddingModel string + // EmbeddingImage is the TEI container image. + // Defaults to the CPU TEI image when empty. + EmbeddingImage string } // validateQuickModeHost returns an error when the config represents quick mode @@ -280,6 +293,32 @@ func Serve(ctx context.Context, cfg ServeConfig) error { return fmt.Errorf("failed to create status reporter: %w", err) } + // Optimizer wiring — Phase 4: flag-driven Tier 1 (FTS5) and Tier 2 (TEI). + // Build the embedding manager only when Tier 2 is requested, to avoid + // unnecessary Docker / Kubernetes API calls for Tier 0 and Tier 1. + var embMgr embeddingManager + if cfg.EnableEmbedding { + model := cfg.EmbeddingModel + if model == "" { + model = DefaultEmbeddingModel + } + m, err := NewEmbeddingServiceManager(container.NewFactory(), EmbeddingServiceManagerConfig{ + Model: model, + Image: cfg.EmbeddingImage, + }) + if err != nil { + return fmt.Errorf("failed to create embedding service manager: %w", err) + } + embMgr = m + } + teiCleanup, err := injectOptimizerConfig(ctx, cfg, vmcpCfg, embMgr) + if err != nil { + return err + } + if teiCleanup != nil { + defer teiCleanup() + } + optCfg, err := optimizer.GetAndValidateConfig(vmcpCfg.Optimizer) if err != nil { return fmt.Errorf("failed to validate optimizer config: %w", err) @@ -371,6 +410,39 @@ func Serve(ctx context.Context, cfg ServeConfig) error { return srv.Start(ctx) } +// embeddingManager is the minimal interface over *EmbeddingServiceManager needed +// by the Serve lifecycle. Defined here to allow stub injection in unit tests; +// production code passes a *EmbeddingServiceManager. +type embeddingManager interface { + Start(ctx context.Context) (string, error) + Stop(ctx context.Context) error +} + +// injectOptimizerConfig ensures vmcpCfg.Optimizer is non-nil when flag-driven +// optimizer tiers are active, and starts the TEI container when EnableEmbedding +// is true. Returns a non-nil cleanup func only when a TEI container was started; +// the caller must defer it. mgr must be non-nil when cfg.EnableEmbedding is true. +func injectOptimizerConfig(ctx context.Context, cfg ServeConfig, vmcpCfg *config.Config, mgr embeddingManager) (func(), error) { + if !cfg.EnableOptimizer && !cfg.EnableEmbedding { + return nil, nil + } + if vmcpCfg.Optimizer == nil { + vmcpCfg.Optimizer = &config.OptimizerConfig{} + } + if !cfg.EnableEmbedding { + return nil, nil + } + if mgr == nil { + return nil, fmt.Errorf("embedding manager must not be nil when EnableEmbedding is true") + } + teiURL, err := mgr.Start(ctx) + if err != nil { + return nil, fmt.Errorf("failed to start TEI embedding service: %w", err) + } + vmcpCfg.Optimizer.EmbeddingService = teiURL + return func() { _ = mgr.Stop(context.Background()) }, nil +} + // getStatusReportingInterval extracts the status reporting interval from config. // Returns 0 if not configured, which uses the default interval. func getStatusReportingInterval(cfg *config.Config) time.Duration { From 8504427fab44e7f1e3afa6161d656c911767d816 Mon Sep 17 00:00:00 2001 From: Trey <tgrunnagle@gmail.com> Date: Mon, 20 Apr 2026 08:40:07 -0700 Subject: [PATCH 086/117] Document category-based redis ACLs (#4946) Update documentation around embedded authorization server redis configuration to use category-based ACLs. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- docs/redis-storage.md | 8 ++++---- examples/operator/redis-storage/redis-credentials.yaml | 2 +- examples/operator/redis-storage/sentinel-service.yaml | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/redis-storage.md b/docs/redis-storage.md index 6f763dafaf..1c195d2800 100644 --- a/docs/redis-storage.md +++ b/docs/redis-storage.md @@ -205,7 +205,7 @@ Create a ConfigMap or init container to provision the ACL file. The ACL user nee ``` # /data/users.acl -user toolhive-auth on ><your-secure-password> ~thv:auth:* &* +GET +SET +SETNX +DEL +EXISTS +EXPIRE +PEXPIRE +PTTL +MGET +SADD +SREM +SMEMBERS +EVAL +MULTI +EXEC +EVALSHA +PING +user toolhive-auth on ><your-secure-password> ~thv:auth:* &* +@read +@write +@keyspace +@scripting +@transaction +@connection ``` This ACL entry: @@ -213,11 +213,11 @@ This ACL entry: - `><your-secure-password>` — Sets the password - `~thv:auth:*` — Allows access to all keys with the `thv:auth:` prefix - `&*` — Allows access to all Pub/Sub channels; required by the go-redis Sentinel client to receive `+switch-master` failover notifications. In a multi-tenant Redis deployment, consider restricting this to specific channels if your Redis version supports it. -- `+GET +SET +DEL ...` — Grants only the commands used by the ToolHive auth server +- `+@read +@write +@keyspace +@scripting +@transaction +@connection` — Grants command categories used by the ToolHive auth server -> **Development / quick-start only:** You can replace the explicit command list with `+@all` to allow all commands, but this is not recommended for production environments. +> **Development / quick-start only:** You can replace the category grants with `+@all` to allow all commands, but this is not recommended for production environments. -> **Security note:** The auth server uses `GET`, `SET`, `SETNX`, `DEL`, `EXISTS`, `EXPIRE`, `PEXPIRE`, `PTTL`, `MGET`, `SADD`, `SREM`, `SMEMBERS`, `EVAL`, `EVALSHA`, `MULTI`, `EXEC`, and `PING`. Restrict the ACL to this set to follow the principle of least privilege. +> **Security note:** The auth server uses commands from the `@read`, `@write`, `@keyspace`, `@scripting`, `@transaction`, and `@connection` categories. These categories cover the specific commands the server needs (`GET`, `SET`, `DEL`, `EXPIRE`, `EVAL`, `MULTI`/`EXEC`, `PING`, etc.) while following the principle of least privilege at the category level. ### Step 4: Create the ToolHive Auth Config diff --git a/examples/operator/redis-storage/redis-credentials.yaml b/examples/operator/redis-storage/redis-credentials.yaml index f94f402035..9d5739c803 100644 --- a/examples/operator/redis-storage/redis-credentials.yaml +++ b/examples/operator/redis-storage/redis-credentials.yaml @@ -6,7 +6,7 @@ # External Secrets Operator, or Vault) instead of plaintext manifests. # # The corresponding Redis ACL entry should be: -# user toolhive-auth on ><password> ~thv:auth:* &* +GET +SET +SETNX +DEL +EXISTS +EXPIRE +SADD +SREM +SMEMBERS +EVAL +MULTI +EXEC +EVALSHA +PING +# user toolhive-auth on ><password> ~thv:auth:* &* +@read +@write +@keyspace +@scripting +@transaction +@connection # (see sentinel-service.yaml for the full ACL Secret that provisions this into Redis) apiVersion: v1 kind: Secret diff --git a/examples/operator/redis-storage/sentinel-service.yaml b/examples/operator/redis-storage/sentinel-service.yaml index 7693812237..508cf68a64 100644 --- a/examples/operator/redis-storage/sentinel-service.yaml +++ b/examples/operator/redis-storage/sentinel-service.yaml @@ -33,7 +33,8 @@ # The ACL entry grants the toolhive-auth user access to: # ~thv:auth:* — keys with the ToolHive auth prefix # &* — all Pub/Sub channels (required for Sentinel failover notifications) -# +GET +SET … — only the commands the auth server uses (principle of least privilege) +# +@read +@write +@keyspace +@scripting +@transaction +@connection +# — command categories the auth server uses (principle of least privilege) apiVersion: v1 kind: Secret metadata: @@ -41,7 +42,7 @@ metadata: namespace: redis type: Opaque stringData: - users.acl: "user toolhive-auth on ><your-redis-password> ~thv:auth:* &* +GET +SET +SETNX +DEL +EXISTS +EXPIRE +PEXPIRE +PTTL +MGET +SADD +SREM +SMEMBERS +EVAL +MULTI +EXEC +EVALSHA +PING" + users.acl: "user toolhive-auth on ><your-redis-password> ~thv:auth:* &* +@read +@write +@keyspace +@scripting +@transaction +@connection" --- # Headless Service gives Redis pods stable, individually addressable DNS names: # redis-0.redis.redis.svc.cluster.local From ee823d89c430bb6babb4071dadc494a73414bac9 Mon Sep 17 00:00:00 2001 From: reyortiz3 <reynier@stacklok.com> Date: Mon, 20 Apr 2026 15:51:54 -0400 Subject: [PATCH 087/117] Add policy_stopped workload status and enforce policy gate on restart (#4857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add policy_stopped workload status and enforce policy gate on restart Introduces WorkloadStatusPolicyStopped as a first-class workload status to support enterprise policy enforcement. When non-registry servers are blocked by policy, workloads can be set to this status so the UI and CLI can surface the reason clearly, rather than showing an opaque "error". - Add WorkloadStatusPolicyStopped constant to pkg/container/runtime/types.go - Update enums tag in pkg/core/workload.go to include policy_stopped - Map policy_stopped to BackendUnhealthy in mapWorkloadStatusToVMCPHealth - Call EagerCheckCreateServer in restartSingleWorkload so the policy gate blocks restart of workloads that violate the active policy - Add 🚫 indicator for policy_stopped in CLI list and status output, following the existing ⚠️ pattern for unauthenticated workloads - Regenerate swagger docs to include the new status enum value Part of stacklok/stacklok-enterprise-platform#406 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Remove enterprise reference from WorkloadStatusPolicyStopped comment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix exhaustive lint errors in status switch statements Add all WorkloadStatus cases to the switches in list and status commands to satisfy the exhaustive linter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Address PR review comments: fix policy gate bypass and missing enum - Add policy_stopped to workloadStatusResponse enums tag so the swagger spec for the status endpoint includes the new value - Add EagerCheckCreateServer check in maybeSetupContainerWorkload after loadRunnerFromState to cover the path where the outer LoadState call fails on a partial name but succeeds after label-based name resolution - Regenerate swagger docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Set policy_stopped status on blocked restart and deduplicate status indicator - Call SetWorkloadStatus with WorkloadStatusPolicyStopped in both policy gate failure paths in restartSingleWorkload and maybeSetupContainerWorkload, so a blocked restart transitions the workload into the new status instead of leaving it as stopped - Extract the status indicator switch (⚠️ / 🚫) from list.go and status.go into a single workloadStatusIndicator helper in common.go Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --- cmd/thv/app/common.go | 18 ++++++++++++++++++ cmd/thv/app/list.go | 8 ++------ cmd/thv/app/status.go | 6 +----- docs/server/docs.go | 8 ++++++-- docs/server/swagger.json | 8 ++++++-- docs/server/swagger.yaml | 4 ++++ pkg/api/v1/workload_types.go | 2 +- pkg/container/runtime/types.go | 3 +++ pkg/core/workload.go | 2 +- pkg/workloads/manager.go | 21 +++++++++++++++++++++ 10 files changed, 63 insertions(+), 17 deletions(-) diff --git a/cmd/thv/app/common.go b/cmd/thv/app/common.go index 53766aaf13..2cd23028c1 100644 --- a/cmd/thv/app/common.go +++ b/cmd/thv/app/common.go @@ -11,6 +11,7 @@ import ( groupval "github.com/stacklok/toolhive-core/validation/group" "github.com/stacklok/toolhive/pkg/config" + "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/secrets" "github.com/stacklok/toolhive/pkg/workloads" ) @@ -155,6 +156,23 @@ func completeLogsArgs(cmd *cobra.Command, args []string, _ string) ([]string, co return completions, cobra.ShellCompDirectiveNoFileComp } +// workloadStatusIndicator returns the status string with a visual indicator prepended +// for statuses that warrant user attention (unauthenticated, policy_stopped). +// All other statuses are returned as plain strings. +func workloadStatusIndicator(status runtime.WorkloadStatus) string { + switch status { + case runtime.WorkloadStatusUnauthenticated: + return "⚠️ " + string(status) + case runtime.WorkloadStatusPolicyStopped: + return "🚫 " + string(status) + case runtime.WorkloadStatusRunning, runtime.WorkloadStatusStopped, runtime.WorkloadStatusError, + runtime.WorkloadStatusStarting, runtime.WorkloadStatusStopping, runtime.WorkloadStatusUnhealthy, + runtime.WorkloadStatusRemoving, runtime.WorkloadStatusUnknown: + return string(status) + } + return string(status) +} + // AddGroupFlag adds a --group flag to the provided command for filtering by group. // If withShorthand is true, adds the -g shorthand as well. func AddGroupFlag(cmd *cobra.Command, groupVar *string, withShorthand bool) { diff --git a/cmd/thv/app/list.go b/cmd/thv/app/list.go index 759063e2ba..a3943bbd60 100644 --- a/cmd/thv/app/list.go +++ b/cmd/thv/app/list.go @@ -12,7 +12,6 @@ import ( "github.com/spf13/cobra" - rt "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/core" "github.com/stacklok/toolhive/pkg/workloads" ) @@ -165,11 +164,8 @@ func printTextOutput(workloadList []core.Workload) { // Print workload information for _, c := range workloadList { - // Highlight unauthenticated workloads with a warning indicator - status := string(c.Status) - if c.Status == rt.WorkloadStatusUnauthenticated { - status = "⚠️ " + status - } + // Highlight unauthenticated and policy-stopped workloads with indicators + status := workloadStatusIndicator(c.Status) // Print workload information if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n", diff --git a/cmd/thv/app/status.go b/cmd/thv/app/status.go index d10465f531..08d6171ed9 100644 --- a/cmd/thv/app/status.go +++ b/cmd/thv/app/status.go @@ -13,7 +13,6 @@ import ( "github.com/spf13/cobra" - "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/core" "github.com/stacklok/toolhive/pkg/workloads" ) @@ -100,10 +99,7 @@ func printStatusJSONOutput(workload core.Workload) error { func printStatusTextOutput(workload core.Workload) { w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) - status := string(workload.Status) - if workload.Status == runtime.WorkloadStatusUnauthenticated { - status = "⚠️ " + status - } + status := workloadStatusIndicator(workload.Status) // Print workload information in key-value format _, _ = fmt.Fprintf(w, "Name:\t%s\n", workload.Name) diff --git a/docs/server/docs.go b/docs/server/docs.go index 88e07fcd08..866c0ab5b3 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -931,6 +931,7 @@ const docTemplate = `{ "removing", "unknown", "unauthenticated", + "policy_stopped", "running", "stopped", "error", @@ -940,6 +941,7 @@ const docTemplate = `{ "removing", "unknown", "unauthenticated", + "policy_stopped", "running", "stopped", "error", @@ -948,7 +950,8 @@ const docTemplate = `{ "unhealthy", "removing", "unknown", - "unauthenticated" + "unauthenticated", + "policy_stopped" ], "type": "string", "x-enum-varnames": [ @@ -960,7 +963,8 @@ const docTemplate = `{ "WorkloadStatusUnhealthy", "WorkloadStatusRemoving", "WorkloadStatusUnknown", - "WorkloadStatusUnauthenticated" + "WorkloadStatusUnauthenticated", + "WorkloadStatusPolicyStopped" ] }, "github_com_stacklok_toolhive_pkg_container_templates.RuntimeConfig": { diff --git a/docs/server/swagger.json b/docs/server/swagger.json index be4022ac58..4ddb45ea40 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -924,6 +924,7 @@ "removing", "unknown", "unauthenticated", + "policy_stopped", "running", "stopped", "error", @@ -933,6 +934,7 @@ "removing", "unknown", "unauthenticated", + "policy_stopped", "running", "stopped", "error", @@ -941,7 +943,8 @@ "unhealthy", "removing", "unknown", - "unauthenticated" + "unauthenticated", + "policy_stopped" ], "type": "string", "x-enum-varnames": [ @@ -953,7 +956,8 @@ "WorkloadStatusUnhealthy", "WorkloadStatusRemoving", "WorkloadStatusUnknown", - "WorkloadStatusUnauthenticated" + "WorkloadStatusUnauthenticated", + "WorkloadStatusPolicyStopped" ] }, "github_com_stacklok_toolhive_pkg_container_templates.RuntimeConfig": { diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 5fa8ca2e2c..80942758f6 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -905,6 +905,7 @@ components: - removing - unknown - unauthenticated + - policy_stopped - running - stopped - error @@ -914,6 +915,7 @@ components: - removing - unknown - unauthenticated + - policy_stopped - running - stopped - error @@ -923,6 +925,7 @@ components: - removing - unknown - unauthenticated + - policy_stopped type: string x-enum-varnames: - WorkloadStatusRunning @@ -934,6 +937,7 @@ components: - WorkloadStatusRemoving - WorkloadStatusUnknown - WorkloadStatusUnauthenticated + - WorkloadStatusPolicyStopped github_com_stacklok_toolhive_pkg_container_templates.RuntimeConfig: description: |- RuntimeConfig allows overriding the default runtime configuration diff --git a/pkg/api/v1/workload_types.go b/pkg/api/v1/workload_types.go index a7044486c3..a87fa44b72 100644 --- a/pkg/api/v1/workload_types.go +++ b/pkg/api/v1/workload_types.go @@ -32,7 +32,7 @@ type workloadListResponse struct { type workloadStatusResponse struct { // Current status of the workload //nolint:lll // enums tag needed for swagger generation with --parseDependencyLevel - Status runtime.WorkloadStatus `json:"status" enums:"running,stopped,error,starting,stopping,unhealthy,removing,unknown,unauthenticated"` + Status runtime.WorkloadStatus `json:"status" enums:"running,stopped,error,starting,stopping,unhealthy,removing,unknown,unauthenticated,policy_stopped"` } // updateRequest represents the request to update an existing workload diff --git a/pkg/container/runtime/types.go b/pkg/container/runtime/types.go index 13299b033c..04838e73a4 100644 --- a/pkg/container/runtime/types.go +++ b/pkg/container/runtime/types.go @@ -44,6 +44,9 @@ const ( // WorkloadStatusUnauthenticated indicates that the workload is running but // cannot authenticate with the remote MCP server (e.g., expired refresh token). WorkloadStatusUnauthenticated WorkloadStatus = "unauthenticated" + // WorkloadStatusPolicyStopped indicates that the workload was stopped by + // policy enforcement. The StatusContext field carries the human-readable reason. + WorkloadStatusPolicyStopped WorkloadStatus = "policy_stopped" ) // ContainerInfo represents information about a container diff --git a/pkg/core/workload.go b/pkg/core/workload.go index 1583f74645..06199504dd 100644 --- a/pkg/core/workload.go +++ b/pkg/core/workload.go @@ -33,7 +33,7 @@ type Workload struct { ProxyMode string `json:"proxy_mode,omitempty"` // Status is the current status of the workload. //nolint:lll // enums tag needed for swagger generation with --parseDependencyLevel - Status runtime.WorkloadStatus `json:"status" enums:"running,stopped,error,starting,stopping,unhealthy,removing,unknown,unauthenticated"` + Status runtime.WorkloadStatus `json:"status" enums:"running,stopped,error,starting,stopping,unhealthy,removing,unknown,unauthenticated,policy_stopped"` // StatusContext provides additional context about the workload's status. // The exact meaning is determined by the status and the underlying runtime. StatusContext string `json:"status_context,omitempty"` diff --git a/pkg/workloads/manager.go b/pkg/workloads/manager.go index 249c396468..7b0187bac7 100644 --- a/pkg/workloads/manager.go +++ b/pkg/workloads/manager.go @@ -243,6 +243,8 @@ func mapWorkloadStatusToVMCPHealth(status rt.WorkloadStatus) vmcp.BackendHealthS return vmcp.BackendUnknown case rt.WorkloadStatusUnauthenticated: return vmcp.BackendUnauthenticated + case rt.WorkloadStatusPolicyStopped: + return vmcp.BackendUnhealthy default: return vmcp.BackendUnknown } @@ -1086,6 +1088,15 @@ func (d *DefaultManager) restartSingleWorkload(ctx context.Context, name string, return d.restartContainerWorkload(ctx, name, foreground) } + // Check policy gates before restarting — the loaded RunConfig carries the same + // fields (RegistryAPIURL, RegistryURL, RemoteURL) that the gate evaluates on create. + if err := runner.EagerCheckCreateServer(ctx, runConfig); err != nil { + if statusErr := d.statuses.SetWorkloadStatus(ctx, name, rt.WorkloadStatusPolicyStopped, err.Error()); statusErr != nil { + slog.Warn("Failed to set workload status to policy_stopped", "workload", name, "error", statusErr) + } + return fmt.Errorf("server restart blocked by policy: %w", err) + } + // Check if this is a remote workload if runConfig.RemoteURL != "" { return d.restartRemoteWorkload(ctx, name, runConfig, foreground) @@ -1295,6 +1306,16 @@ func (d *DefaultManager) maybeSetupContainerWorkload(ctx context.Context, name s return "", nil, fmt.Errorf("failed to load state for %s: %w", workloadName, err) } + // Check policy gates before restarting. This covers the case where the caller + // could not load state via the original name but we resolved the canonical name + // from container labels above, so the check must happen here. + if err := runner.EagerCheckCreateServer(ctx, mcpRunner.Config); err != nil { + if statusErr := d.statuses.SetWorkloadStatus(ctx, workloadName, rt.WorkloadStatusPolicyStopped, err.Error()); statusErr != nil { + slog.Warn("Failed to set workload status to policy_stopped", "workload", workloadName, "error", statusErr) + } + return "", nil, fmt.Errorf("server restart blocked by policy: %w", err) + } + // Set workload status to starting - use the workload name for status operations if err := d.statuses.SetWorkloadStatus(ctx, workloadName, rt.WorkloadStatusStarting, ""); err != nil { slog.Warn("Failed to set workload status to starting", "workload", workloadName, "error", err) From c1292c406eaa653209d75bb8690a50b0cefe59cf Mon Sep 17 00:00:00 2001 From: Jakub Hrozek <jakub.hrozek@posteo.se> Date: Mon, 20 Apr 2026 22:25:28 +0100 Subject: [PATCH 088/117] Extend group extraction with dual-claim and dot-notation support (#4911) A single Cedar authorizer must handle the group/role claim layouts of all major IDPs: Entra ID puts roles in "roles" and groups in "groups" (dual-claim), Okta uses "groups" or a URI-style claim, and Keycloak nests roles under "realm_access.roles". The previous single-claim extractGroupsFromClaims could not express any of these. Replace extractGroupsFromClaims with resolveNestedClaim + extractGroups + dedup. Groups are now extracted from both GroupClaimName and RoleClaimName, merged, and deduplicated before Cedar evaluation. resolveNestedClaim tries exact top-level match first (Auth0/Okta URL-style claim names containing dots) then dot-notation traversal (Keycloak-style nested claims). The well-known fallback ("groups", "roles", "cognito:groups") is preserved: when the configured GroupClaimName is absent from the token or unconfigured, the well-known names are still checked. This matches the documented contract that the custom name takes priority over defaults, not replaces them. E2E tested in a Kind cluster with real IDP tokens: Entra MCPServer (RoleClaimName: "roles"): JWT: { "roles": ["mcp-admin", "developer"] } permit(principal in THVGroup::"mcp-admin", action, resource in MCP::"<server>"); call_tool "echo" -> 200 (role extracted, THVGroup parent set) Okta MCPServer (GroupClaimName: "groups"): JWT: { "groups": ["Everyone", "engineering"] } permit(principal in THVGroup::"engineering", action, resource in MCP::"<server>"); call_tool "echo" -> 200 Wrong-group denial (same Okta OIDC, different policy): permit(principal in THVGroup::"platform-ops", ...); user in "engineering" only -> 403 Dot-notation traversal (Keycloak) is unit-tested; no Keycloak server was deployed for E2E. Fixes #4768 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> --- pkg/authz/authorizers/cedar/core.go | 154 +++++-- pkg/authz/authorizers/cedar/core_test.go | 528 +++++++++++++++++++---- 2 files changed, 555 insertions(+), 127 deletions(-) diff --git a/pkg/authz/authorizers/cedar/core.go b/pkg/authz/authorizers/cedar/core.go index 7c3504d73b..5194d9714b 100644 --- a/pkg/authz/authorizers/cedar/core.go +++ b/pkg/authz/authorizers/cedar/core.go @@ -754,11 +754,29 @@ func (a *Authorizer) AuthorizeWithJWTClaims( return false, ErrMissingPrincipal } - // Extract groups from the resolved claims and pass them into the entity - // factory to build THVGroup parent entities for Cedar evaluation. + // Extract groups from the group claim (or well-known defaults) and the + // role claim, merge, and dedup. Both claim sources map to Cedar THVGroup + // entities. Extraction runs BEFORE preprocessClaims so the raw claim + // values are available. // The identity pointer is not mutated here because Identity MUST NOT be // modified after it is placed in the request context (concurrent reads). - groups := extractGroupsFromClaims(resolvedClaims, a.groupClaimName) + groupClaims := extractGroups(resolvedClaims, a.groupClaimName) + if groupClaims == nil { + // Fall back to well-known claim names. This covers two cases: + // 1. No GroupClaimName configured — backward compatible default. + // 2. GroupClaimName configured but absent from the token — the + // documented contract says the custom name takes *priority* + // over defaults, not that it replaces them. + for _, name := range defaultGroupClaimNames { + if groupClaims = extractGroups(resolvedClaims, name); groupClaims != nil { + break + } + } + } + groups := dedup(append( + groupClaims, + extractGroups(resolvedClaims, a.roleClaimName)..., + )) // Preprocess claims and arguments processedClaims := preprocessClaims(resolvedClaims) @@ -787,51 +805,109 @@ func (a *Authorizer) AuthorizeWithJWTClaims( // providers. They are checked in order; the first non-empty match is returned. // // Sources: -// - "groups" — Microsoft Entra ID, Okta, Auth0, PingIdentity (the de-facto standard). -// https://learn.microsoft.com/en-us/security/zero-trust/develop/configure-tokens-group-claims-app-roles -// https://developer.okta.com/docs/guides/customize-tokens-groups-claim/main/ -// - "roles" — Keycloak (when a protocol mapper flattens realm_access.roles to a top-level claim). -// https://www.keycloak.org/docs/latest/authorization_services/index.html -// - "cognito:groups" — AWS Cognito user pools (included in both ID and access tokens). -// https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-access-token.html +// - "groups" — Microsoft Entra ID, Okta, Auth0, PingIdentity. +// - "roles" — Keycloak (realm_access.roles flattened to top-level). +// - "cognito:groups" — AWS Cognito user pools. var defaultGroupClaimNames = []string{"groups", "roles", "cognito:groups"} -// extractGroupsFromClaims looks for group membership claims in the provided -// claims map. It checks customClaimName first (if non-empty), then falls back to -// the well-known names "groups", "roles", and "cognito:groups". Returns the -// string-slice value of the first matching claim key (which may be empty), or nil -// when no group claim key is found. +// resolveNestedClaim resolves a claim value from JWT claims, supporting both +// top-level keys and dot-separated nested paths. +// +// Resolution order: +// 1. Exact top-level match — handles Auth0 / Okta URL-style claim names +// (e.g. "https://myapp.example.com/roles") that contain dots but are +// top-level keys in the JWT. +// 2. Dot-notation traversal — handles Keycloak-style nested claims +// (e.g. "realm_access.roles" → claims["realm_access"]["roles"]). // -// Passing a non-empty customClaimName allows callers to support IDPs that use -// URI-style claim names (e.g. "https://example.com/groups" used by Auth0/Okta). -func extractGroupsFromClaims(claims map[string]any, customClaimName string) []string { - names := defaultGroupClaimNames - if customClaimName != "" { - // Prepend the custom name so it takes priority over well-known names. - names = append([]string{customClaimName}, defaultGroupClaimNames...) +// Returns nil when the claim is absent or traversal hits a non-map value. +func resolveNestedClaim(claims jwt.MapClaims, path string) interface{} { + if path == "" { + return nil + } + + // 1. Exact top-level match (handles Auth0 URL claims with dots). + if val, ok := claims[path]; ok { + return val + } + + // 2. Dot-notation traversal. + parts := strings.Split(path, ".") + if len(parts) < 2 { + return nil // single segment already tried above } - for _, name := range names { - val, ok := claims[name] + var current interface{} = map[string]interface{}(claims) + for _, segment := range parts { + m, ok := current.(map[string]interface{}) if !ok { - continue + return nil + } + current, ok = m[segment] + if !ok { + return nil } - switch v := val.(type) { - case []interface{}: - groups := make([]string, 0, len(v)) - for _, g := range v { - if s, ok := g.(string); ok { - groups = append(groups, s) - } + } + return current +} + +// extractGroups extracts group/role names from a specific JWT claim. +// It resolves the claim via resolveNestedClaim (supporting both flat and +// dot-notation paths) and coerces the value to []string. +// +// Return value distinguishes "claim absent" from "claim present but empty" +// so callers can decide whether to fall back to defaults: +// - nil: claimName is empty, the claim is absent, or the value has an +// unsupported scalar/object type (e.g. string, number). +// - non-nil, possibly empty: the claim is an array. Non-string elements +// are silently dropped, so an array of all non-strings yields an empty +// slice (not nil). A genuinely empty array (`[]`) also yields an empty +// slice. Both cases mean "the IdP said this claim exists with no usable +// group names" and suppress fallback. +func extractGroups(claims jwt.MapClaims, claimName string) []string { + if claimName == "" { + return nil + } + + val := resolveNestedClaim(claims, claimName) + if val == nil { + return nil + } + + switch v := val.(type) { + case []interface{}: + groups := make([]string, 0, len(v)) + for _, g := range v { + if s, ok := g.(string); ok { + groups = append(groups, s) } - return groups - case []string: - return v } - // Claim key exists but has an unrecognized type; stop searching. - slog.Warn("group claim has unrecognized type, ignoring", - "claim", name, "type", fmt.Sprintf("%T", val)) + return groups + case []string: + return v + default: + slog.Warn("group/role claim has unrecognized type, ignoring", + "claim", claimName, "type", fmt.Sprintf("%T", val)) return nil } - return nil +} + +// dedup removes duplicate strings while preserving first-occurrence order. +// Returns nil when the input is nil (not an empty slice) so callers can +// distinguish "no groups" from "empty groups". +func dedup(groups []string) []string { + if groups == nil { + return nil + } + + seen := make(map[string]struct{}, len(groups)) + result := make([]string, 0, len(groups)) + for _, g := range groups { + if _, exists := seen[g]; exists { + continue + } + seen[g] = struct{}{} + result = append(result, g) + } + return result } diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index a0c7aa0a5c..17c07c97f2 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -1318,8 +1318,9 @@ func TestAuthorizeWithJWTClaims_GroupMembership(t *testing.T) { ` authorizer, err := NewCedarAuthorizer(ConfigOptions{ - Policies: []string{policy}, - EntitiesJSON: `[]`, + Policies: []string{policy}, + EntitiesJSON: `[]`, + GroupClaimName: "groups", }, "") require.NoError(t, err) @@ -1413,8 +1414,9 @@ func TestAuthorizeWithJWTClaims_TransitiveHierarchyPreserved(t *testing.T) { ]` authorizer, err := NewCedarAuthorizer(ConfigOptions{ - Policies: []string{policy}, - EntitiesJSON: entitiesJSON, + Policies: []string{policy}, + EntitiesJSON: entitiesJSON, + GroupClaimName: "groups", }, "") require.NoError(t, err) @@ -1453,8 +1455,9 @@ func TestAuthorizeWithJWTClaims_DoesNotMutateIdentity(t *testing.T) { policy := `permit(principal, action, resource);` authorizer, err := NewCedarAuthorizer(ConfigOptions{ - Policies: []string{policy}, - EntitiesJSON: `[]`, + Policies: []string{policy}, + EntitiesJSON: `[]`, + GroupClaimName: "groups", }, "") require.NoError(t, err) @@ -1552,6 +1555,7 @@ func TestAuthorizeWithJWTClaims_UpstreamProviderWithGroups(t *testing.T) { Policies: []string{policy}, EntitiesJSON: `[]`, PrimaryUpstreamProvider: providerName, + GroupClaimName: "groups", }, "") require.NoError(t, err) @@ -1805,148 +1809,496 @@ func TestInjectUpstreamProvider_NonCedarPassThrough(t *testing.T) { "non-Cedar config must be returned as the same pointer — InjectUpstreamProvider must be a no-op for unknown types") } -// TestExtractGroupsFromClaims tests the extractGroupsFromClaims function. -func TestExtractGroupsFromClaims(t *testing.T) { +// TestResolveNestedClaim tests the resolveNestedClaim function. +func TestResolveNestedClaim(t *testing.T) { t.Parallel() tests := []struct { - name string - claims map[string]any - customClaimName string - wantGroups []string + name string + claims jwt.MapClaims + path string + want interface{} }{ { - name: "groups_claim_string_slice", - claims: map[string]any{ - "sub": "user1", - "groups": []string{"admin", "developers"}, + name: "exact_top_level_match", + claims: jwt.MapClaims{"groups": []interface{}{"eng", "platform"}}, + path: "groups", + want: []interface{}{"eng", "platform"}, + }, + { + name: "dot_notation_traversal", + claims: jwt.MapClaims{ + "realm_access": map[string]interface{}{ + "roles": []interface{}{"admin", "user"}, + }, }, - wantGroups: []string{"admin", "developers"}, + path: "realm_access.roles", + want: []interface{}{"admin", "user"}, }, { - name: "groups_claim_interface_slice", - claims: map[string]any{ - "sub": "user1", - "groups": []interface{}{"reader", "writer"}, + name: "auth0_url_claim_with_dots_matches_exact_first", + claims: jwt.MapClaims{ + "https://myapp.example.com/roles": []interface{}{"editor"}, }, - wantGroups: []string{"reader", "writer"}, + path: "https://myapp.example.com/roles", + want: []interface{}{"editor"}, }, { - name: "roles_claim_string_slice", - claims: map[string]any{ - "sub": "user1", - "roles": []string{"viewer"}, + name: "missing_claim_returns_nil", + claims: jwt.MapClaims{"sub": "user1"}, + path: "nonexistent", + want: nil, + }, + { + name: "nested_traversal_hits_non_object", + claims: jwt.MapClaims{ + "foo": "a-string-not-a-map", }, - wantGroups: []string{"viewer"}, + path: "foo.bar", + want: nil, }, { - name: "cognito_groups_claim", - claims: map[string]any{ - "sub": "user1", - "cognito:groups": []string{"pool-admins"}, + name: "three_level_nesting", + claims: jwt.MapClaims{ + "resource_access": map[string]interface{}{ + "my-app": map[string]interface{}{ + "roles": []interface{}{"viewer", "contributor"}, + }, + }, }, - wantGroups: []string{"pool-admins"}, + path: "resource_access.my-app.roles", + want: []interface{}{"viewer", "contributor"}, }, { - name: "custom_claim_name_takes_priority", - claims: map[string]any{ - "sub": "user1", - "https://example.com/groups": []string{"eng", "platform"}, - "groups": []string{"other"}, + name: "empty_path_returns_nil", + claims: jwt.MapClaims{"groups": []interface{}{"eng"}}, + path: "", + want: nil, + }, + { + name: "empty_claims_returns_nil", + claims: jwt.MapClaims{}, + path: "groups", + want: nil, + }, + { + name: "partial_nested_path_missing_leaf", + claims: jwt.MapClaims{ + "realm_access": map[string]interface{}{ + "other": "value", + }, }, - customClaimName: "https://example.com/groups", - wantGroups: []string{"eng", "platform"}, + path: "realm_access.roles", + want: nil, }, { - name: "custom_claim_name_falls_back_to_well_known", - claims: map[string]any{ - "sub": "user1", - "groups": []string{"fallback-group"}, + // Pathological path shapes. Each produces at least one empty + // segment after Split, which the traversal loop treats as a + // missing key. Pinned as tests so a future refactor that tries + // to "normalize" paths by skipping empty segments cannot silently + // change resolution behavior. + name: "trailing_dot_returns_nil", + claims: jwt.MapClaims{ + "realm_access": map[string]interface{}{ + "roles": []interface{}{"admin"}, + }, }, - customClaimName: "https://example.com/nonexistent", - wantGroups: []string{"fallback-group"}, + path: "realm_access.", + want: nil, }, { - name: "no_group_claim_present", - claims: map[string]any{ - "sub": "user1", - "name": "Alice", + name: "leading_dot_returns_nil", + claims: jwt.MapClaims{ + "roles": []interface{}{"admin"}, }, - wantGroups: nil, + path: ".roles", + want: nil, }, { - name: "empty_groups_claim_returns_empty", - claims: map[string]any{ - "sub": "user1", - "groups": []string{}, + name: "consecutive_dots_return_nil", + claims: jwt.MapClaims{ + "a": map[string]interface{}{ + "b": []interface{}{"x"}, + }, + }, + path: "a..b", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := resolveNestedClaim(tt.claims, tt.path) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestExtractGroups tests the extractGroups function. +func TestExtractGroups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + claims jwt.MapClaims + claimName string + wantGroups []string + }{ + { + name: "flat_claim_string_slice", + claims: jwt.MapClaims{"groups": []string{"admin", "developers"}}, + claimName: "groups", + wantGroups: []string{"admin", "developers"}, + }, + { + name: "flat_claim_interface_slice", + claims: jwt.MapClaims{"groups": []interface{}{"reader", "writer"}}, + claimName: "groups", + wantGroups: []string{"reader", "writer"}, + }, + { + name: "nested_keycloak_claim", + claims: jwt.MapClaims{ + "realm_access": map[string]interface{}{ + "roles": []interface{}{"admin", "user"}, + }, }, + claimName: "realm_access.roles", + wantGroups: []string{"admin", "user"}, + }, + { + name: "empty_claim_name_returns_nil", + claims: jwt.MapClaims{"groups": []interface{}{"eng"}}, + claimName: "", + wantGroups: nil, + }, + { + name: "missing_claim_returns_nil", + claims: jwt.MapClaims{"sub": "user1"}, + claimName: "groups", + wantGroups: nil, + }, + { + name: "non_array_claim_returns_nil", + claims: jwt.MapClaims{"groups": "not-a-slice"}, + claimName: "groups", + wantGroups: nil, + }, + { + name: "non_string_elements_skipped", + claims: jwt.MapClaims{"groups": []interface{}{"valid", 42, true, "also-valid"}}, + claimName: "groups", + wantGroups: []string{"valid", "also-valid"}, + }, + { + name: "empty_array_returns_empty", + claims: jwt.MapClaims{"groups": []interface{}{}}, + claimName: "groups", wantGroups: []string{}, }, { - name: "empty_interface_slice_returns_empty", - claims: map[string]any{ + name: "auth0_url_claim_name", + claims: jwt.MapClaims{ + "https://example.com/groups": []interface{}{"platform"}, + }, + claimName: "https://example.com/groups", + wantGroups: []string{"platform"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := extractGroups(tt.claims, tt.claimName) + assert.Equal(t, tt.wantGroups, got) + }) + } +} + +// TestDedup tests the dedup function. +func TestDedup(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []string + want []string + }{ + { + name: "nil_returns_nil", + input: nil, + want: nil, + }, + { + name: "empty_returns_empty", + input: []string{}, + want: []string{}, + }, + { + name: "no_duplicates", + input: []string{"a", "b", "c"}, + want: []string{"a", "b", "c"}, + }, + { + name: "with_duplicates_preserves_order", + input: []string{"a", "b", "a", "c", "b"}, + want: []string{"a", "b", "c"}, + }, + { + name: "all_duplicates", + input: []string{"x", "x", "x"}, + want: []string{"x"}, + }, + { + name: "single_element", + input: []string{"only"}, + want: []string{"only"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := dedup(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestAuthorizeWithJWTClaims_DualClaim verifies that groups from both +// GroupClaimName and RoleClaimName are merged and deduplicated for Cedar +// evaluation. This is the core dual-claim extraction behavior from #4768. +func TestAuthorizeWithJWTClaims_DualClaim(t *testing.T) { + t.Parallel() + + // Policy: only members of "platform" may call the deploy tool. + policy := ` + permit( + principal in THVGroup::"platform", + action == Action::"call_tool", + resource == Tool::"deploy" + ); + ` + + tests := []struct { + name string + groupClaim string + roleClaim string + claims jwt.MapClaims + wantAuthorize bool + }{ + { + name: "group_claim_only", + groupClaim: "groups", + claims: jwt.MapClaims{ "sub": "user1", - "groups": []interface{}{}, + "groups": []interface{}{"platform", "devs"}, }, - wantGroups: []string{}, + wantAuthorize: true, + }, + { + name: "role_claim_only", + roleClaim: "roles", + claims: jwt.MapClaims{ + "sub": "user1", + "roles": []interface{}{"platform"}, + }, + wantAuthorize: true, }, { - name: "empty_groups_does_not_fall_through_to_roles", - claims: map[string]any{ + name: "both_claims_merged", + groupClaim: "groups", + roleClaim: "roles", + claims: jwt.MapClaims{ "sub": "user1", - "groups": []string{}, - "roles": []string{"should-not-match"}, + "groups": []interface{}{"devs"}, + "roles": []interface{}{"platform"}, }, - wantGroups: []string{}, + wantAuthorize: true, }, { - name: "non_string_interface_elements_skipped", - claims: map[string]any{ + name: "duplicates_across_claims_are_deduplicated", + groupClaim: "groups", + roleClaim: "roles", + claims: jwt.MapClaims{ "sub": "user1", - "groups": []interface{}{"valid", 42, true, "also-valid"}, + "groups": []interface{}{"platform", "devs"}, + "roles": []interface{}{"platform", "ops"}, }, - wantGroups: []string{"valid", "also-valid"}, + wantAuthorize: true, }, { - name: "groups_claim_wrong_type_returns_nil", - claims: map[string]any{ + name: "neither_claim_has_matching_group", + groupClaim: "groups", + roleClaim: "roles", + claims: jwt.MapClaims{ "sub": "user1", - "groups": "not-a-slice", + "groups": []interface{}{"marketing"}, + "roles": []interface{}{"sales"}, }, - wantGroups: nil, + wantAuthorize: false, }, { - name: "wrong_type_does_not_fall_through_to_roles", - claims: map[string]any{ + name: "both_claims_empty_falls_back_to_well_known", + groupClaim: "", + roleClaim: "", + claims: jwt.MapClaims{ "sub": "user1", - "groups": "not-a-slice", - "roles": []string{"should-not-match"}, + "groups": []interface{}{"platform"}, }, - wantGroups: nil, + wantAuthorize: true, // well-known "groups" claim is checked when GroupClaimName is empty }, { - name: "empty_claims_map", - claims: map[string]any{}, - wantGroups: nil, + name: "custom_group_claim_absent_falls_back_to_well_known", + groupClaim: "https://example.com/groups", + roleClaim: "", + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"platform"}, + }, + wantAuthorize: true, // custom claim missing, well-known "groups" used as fallback + }, + { + // Pins the "present but empty" semantic: if the configured custom + // claim exists as an empty array, the IdP has explicitly said + // "no groups" — fallback to well-known names MUST NOT fire. Without + // this test, a refactor of extractGroups that returns nil on empty + // arrays would silently flip the semantic and allow well-known + // claims like "roles" to grant access. + name: "custom_group_claim_present_but_empty_does_not_fall_back", + groupClaim: "https://example.com/groups", + roleClaim: "", + claims: jwt.MapClaims{ + "sub": "user1", + "https://example.com/groups": []interface{}{}, // present, empty + "roles": []interface{}{"platform"}, + }, + wantAuthorize: false, // explicit empty suppresses fallback; "roles" is NOT consulted }, { - name: "groups_claim_prioritised_over_roles", - claims: map[string]any{ + name: "nested_role_claim_with_dot_notation", + groupClaim: "groups", + roleClaim: "realm_access.roles", + claims: jwt.MapClaims{ "sub": "user1", - "groups": []string{"grp-a"}, - "roles": []string{"role-b"}, + "groups": []interface{}{"devs"}, + "realm_access": map[string]interface{}{ + "roles": []interface{}{"platform"}, + }, }, - // defaultGroupClaimNames checks "groups" first; "groups" is found, so - // "roles" should not be returned. - wantGroups: []string{"grp-a"}, + wantAuthorize: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := extractGroupsFromClaims(tt.claims, tt.customClaimName) - assert.Equal(t, tt.wantGroups, got) + + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{policy}, + EntitiesJSON: `[]`, + GroupClaimName: tt.groupClaim, + RoleClaimName: tt.roleClaim, + }, "") + require.NoError(t, err) + + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: tt.claims["sub"].(string), + Claims: map[string]any(tt.claims), + }, + } + ctx := auth.WithIdentity(context.Background(), identity) + + authorized, err := authorizer.AuthorizeWithJWTClaims( + ctx, + authorizers.MCPFeatureTool, + authorizers.MCPOperationCall, + "deploy", + nil, + ) + require.NoError(t, err) + assert.Equal(t, tt.wantAuthorize, authorized) + }) + } +} + +// TestAuthorizeWithJWTClaims_BackwardCompat verifies that when both GroupClaimName +// and RoleClaimName are empty (pre-dual-claim configuration), the well-known +// fallback claim names ("groups", "roles", "cognito:groups") are still checked. +// This prevents a behavior regression for existing configs that rely on implicit +// group extraction without setting GroupClaimName. +func TestAuthorizeWithJWTClaims_BackwardCompat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + claimKey string + claimValue []interface{} + wantAuth bool + }{ + { + name: "well-known groups claim extracted", + claimKey: "groups", + claimValue: []interface{}{"eng"}, + wantAuth: true, + }, + { + name: "well-known roles claim extracted", + claimKey: "roles", + claimValue: []interface{}{"eng"}, + wantAuth: true, + }, + { + name: "well-known cognito:groups claim extracted", + claimKey: "cognito:groups", + claimValue: []interface{}{"eng"}, + wantAuth: true, + }, + { + name: "no well-known claim present denies", + claimKey: "custom_groups", + claimValue: []interface{}{"eng"}, + wantAuth: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Group-based policy: only permits if the principal is in THVGroup::"eng". + // This will fail unless groups are actually extracted from claims. + policy := `permit(principal in THVGroup::"eng", action, resource);` + + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{policy}, + EntitiesJSON: `[]`, + // Both claim names empty — backward compatible mode. + }, "") + require.NoError(t, err) + + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "user1", + Claims: map[string]any{ + "sub": "user1", + tt.claimKey: tt.claimValue, + }, + }, + } + ctx := auth.WithIdentity(context.Background(), identity) + + authorized, err := authorizer.AuthorizeWithJWTClaims( + ctx, + authorizers.MCPFeatureTool, + authorizers.MCPOperationCall, + "any-tool", + nil, + ) + require.NoError(t, err) + assert.Equal(t, tt.wantAuth, authorized) }) } } From c334edf5f692dc6bdd254dcd8fc855a97fd8b2ca Mon Sep 17 00:00:00 2001 From: Sai Asish Y <say.apm35@gmail.com> Date: Tue, 21 Apr 2026 00:35:31 -0700 Subject: [PATCH 089/117] vmcp: stop the TEI manager on Start failure and apply image default in Serve (#4948) injectOptimizerConfig calls mgr.Start(ctx) to bring the TEI embedding container up, but if Start returns an error after the container has already been created (e.g. health poll timed out) we never call mgr.Stop. The container leaks until the next cleanup path hits it. Make the Stop best-effort before returning the wrapped error. Also lift the TEI image default to the Serve() layer. ServeConfig .EmbeddingModel is already defaulted there via DefaultEmbeddingModel, but EmbeddingImage was passed straight through and defaulted deep inside NewEmbeddingServiceManager. Export the constant as DefaultEmbeddingImage (was lowercase defaultTEIImage) and apply it in Serve() alongside DefaultEmbeddingModel so the two fields are defaulted consistently at one place. The manager still falls back to the same constant if a direct caller leaves Image empty. Both fixes were raised by Copilot on #4941 and dropped when that PR scoped down to e2e tests only (#4945). Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com> Fixes #4945 Co-authored-by: SAY-5 <SAY-5@users.noreply.github.com> --- pkg/vmcp/cli/embedding_manager.go | 9 +++++---- pkg/vmcp/cli/serve.go | 9 ++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/pkg/vmcp/cli/embedding_manager.go b/pkg/vmcp/cli/embedding_manager.go index 9171397856..293b488c2e 100644 --- a/pkg/vmcp/cli/embedding_manager.go +++ b/pkg/vmcp/cli/embedding_manager.go @@ -28,8 +28,9 @@ import ( ) const ( - // defaultTEIImage is the default HuggingFace Text Embeddings Inference image. - defaultTEIImage = "ghcr.io/huggingface/text-embeddings-inference:cpu-latest" + // DefaultEmbeddingImage is the default HuggingFace Text Embeddings + // Inference image used when ServeConfig.EmbeddingImage is empty. + DefaultEmbeddingImage = "ghcr.io/huggingface/text-embeddings-inference:cpu-latest" // DefaultEmbeddingModel is the HuggingFace model used when EmbeddingModel is empty. DefaultEmbeddingModel = "BAAI/bge-small-en-v1.5" @@ -89,7 +90,7 @@ type EmbeddingServiceManagerConfig struct { Model string // Image is the TEI container image to run. - // Defaults to defaultTEIImage when empty. + // Defaults to DefaultEmbeddingImage when empty. Image string } @@ -142,7 +143,7 @@ func NewEmbeddingServiceManager(factory ContainerFactory, cfg EmbeddingServiceMa return nil, fmt.Errorf("model must not be empty") } if cfg.Image == "" { - cfg.Image = defaultTEIImage + cfg.Image = DefaultEmbeddingImage } containerName := containerNameForModel(cfg.Model) diff --git a/pkg/vmcp/cli/serve.go b/pkg/vmcp/cli/serve.go index 34b7fb28ce..ffb501115c 100644 --- a/pkg/vmcp/cli/serve.go +++ b/pkg/vmcp/cli/serve.go @@ -302,9 +302,13 @@ func Serve(ctx context.Context, cfg ServeConfig) error { if model == "" { model = DefaultEmbeddingModel } + image := cfg.EmbeddingImage + if image == "" { + image = DefaultEmbeddingImage + } m, err := NewEmbeddingServiceManager(container.NewFactory(), EmbeddingServiceManagerConfig{ Model: model, - Image: cfg.EmbeddingImage, + Image: image, }) if err != nil { return fmt.Errorf("failed to create embedding service manager: %w", err) @@ -437,6 +441,9 @@ func injectOptimizerConfig(ctx context.Context, cfg ServeConfig, vmcpCfg *config } teiURL, err := mgr.Start(ctx) if err != nil { + // Best-effort cleanup: a Start failure can still leave a partial + // container behind (created but health poll timed out, etc.). + _ = mgr.Stop(context.Background()) return nil, fmt.Errorf("failed to start TEI embedding service: %w", err) } vmcpCfg.Optimizer.EmbeddingService = teiURL From ee01104b557b0e13c723f9335926373d36507835 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:35:22 +0200 Subject: [PATCH 090/117] Update alpine Docker tag to v3.23.4 (#4927) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- containers/egress-proxy/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containers/egress-proxy/Dockerfile b/containers/egress-proxy/Dockerfile index 7b34dc8eb0..f056c6a372 100644 --- a/containers/egress-proxy/Dockerfile +++ b/containers/egress-proxy/Dockerfile @@ -1,5 +1,5 @@ # Use Alpine Linux 3.22.0 for minimal footprint -FROM alpine:3.23.3 +FROM alpine:3.23.4 # Install squid from edge repository and create necessary directories RUN echo "https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories \ From e0c11d6eaf05a9222830e2b3dd23af7ee0ac5237 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:35:54 +0200 Subject: [PATCH 091/117] Update github/codeql-action digest to 95e58e9 (#4926) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/security-scan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index e89bccad05..a162e35ef4 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -32,7 +32,7 @@ jobs: fail-build: false - name: Upload Grype scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 if: always() with: sarif_file: ${{ steps.grype-scan.outputs.sarif }} From 9f021b9d88fb15d79fce254542b68548b1738351 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:43:29 +0200 Subject: [PATCH 092/117] Update anthropics/claude-code-action digest to 5d5c10a (#4925) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/claude.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index a707eea5cc..5d126a8525 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -59,7 +59,7 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@b47fd721da662d48c5680e154ad16a73ed74d2e0 # v1 + uses: anthropics/claude-code-action@5d5c10a4f389689f992ea10bb14dcb6fcc83146d # v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Security: Restrict tools to prevent arbitrary code execution. From c2b41861fedbee0c701584300decf2d7baf6c9c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:44:10 +0200 Subject: [PATCH 093/117] Bump github.com/sigstore/timestamp-authority/v2 from 2.0.3 to 2.0.6 (#4793) * Bump github.com/sigstore/timestamp-authority/v2 from 2.0.3 to 2.0.6 Bumps [github.com/sigstore/timestamp-authority/v2](https://github.com/sigstore/timestamp-authority) from 2.0.3 to 2.0.6. - [Release notes](https://github.com/sigstore/timestamp-authority/releases) - [Changelog](https://github.com/sigstore/timestamp-authority/blob/main/CHANGELOG.md) - [Commits](https://github.com/sigstore/timestamp-authority/compare/v2.0.3...v2.0.6) --- updated-dependencies: - dependency-name: github.com/sigstore/timestamp-authority/v2 dependency-version: 2.0.6 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> * Remove stale GO-2025-4192 govulncheck suppression The timestamp-authority/v2 bump to v2.0.6 resolves CVE-2025-66564 (excessive memory allocation). Verified locally with govulncheck that the vulnerability is no longer flagged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jakub Hrozek <jakub@stacklok.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .github/workflows/security-scan.yml | 8 +- go.mod | 52 +++++----- go.sum | 155 ++++++++++++++-------------- 3 files changed, 104 insertions(+), 111 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index a162e35ef4..dcbf430fef 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -57,12 +57,6 @@ jobs: - name: Check for vulnerabilities (with exclusions) run: | # Ignored vulnerabilities with justification: - # GO-2025-4192: sigstore/timestamp-authority excessive memory allocation (CVE-2025-66564) - # Indirect dependency via sigstore-go (used for container signature verification). - # The vulnerability affects timestamp-authority server request parsing endpoints. - # ToolHive only uses sigstore-go as a client to verify signatures, it does not - # expose any timestamp-authority server endpoints. Fix requires sigstore-go to - # upgrade to timestamp-authority/v2 which hasn't been released yet. # GO-2026-4514: buger/jsonparser Delete function DoS via malformed JSON (CVE-2025-54410) # Indirect dependency via mcp-go, invopop/jsonschema, wk8/go-ordered-map. # The vulnerability is in the Delete function which is not called by ToolHive @@ -78,7 +72,7 @@ jobs: # Docker client SDK and does not run or configure AuthZ plugins. No fixed version # exists for github.com/docker/docker; fix is only in github.com/moby/moby/v2 # v2.0.0-beta.8+ which is not yet available as a docker/docker release. - IGNORED_VULNS="GO-2025-4192 GO-2026-4514 GO-2026-4883 GO-2026-4887" + IGNORED_VULNS="GO-2026-4514 GO-2026-4883 GO-2026-4887" # Show the raw output for debugging echo "::group::govulncheck raw output" diff --git a/go.mod b/go.mod index 92c9916a5c..814c1c71e5 100644 --- a/go.mod +++ b/go.mod @@ -83,6 +83,8 @@ require github.com/hashicorp/golang-lru/v2 v2.0.7 require go.starlark.net v0.0.0-20260326113308-fadfc96def35 +require github.com/oklog/ulid/v2 v2.1.1 // indirect + require ( cel.dev/expr v0.25.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect @@ -144,29 +146,29 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-openapi/analysis v0.24.1 // indirect - github.com/go-openapi/errors v0.22.6 // indirect - github.com/go-openapi/jsonpointer v0.22.4 // indirect - github.com/go-openapi/jsonreference v0.21.4 // indirect - github.com/go-openapi/loads v0.23.2 // indirect - github.com/go-openapi/runtime v0.29.2 // indirect - github.com/go-openapi/spec v0.22.3 // indirect - github.com/go-openapi/strfmt v0.25.0 // indirect - github.com/go-openapi/swag v0.25.4 // indirect - github.com/go-openapi/swag/cmdutils v0.25.4 // indirect - github.com/go-openapi/swag/conv v0.25.4 // indirect - github.com/go-openapi/swag/fileutils v0.25.4 // indirect - github.com/go-openapi/swag/jsonname v0.25.4 // indirect - github.com/go-openapi/swag/jsonutils v0.25.4 // indirect - github.com/go-openapi/swag/loading v0.25.4 // indirect - github.com/go-openapi/swag/mangling v0.25.4 // indirect - github.com/go-openapi/swag/netutils v0.25.4 // indirect - github.com/go-openapi/swag/stringutils v0.25.4 // indirect - github.com/go-openapi/swag/typeutils v0.25.4 // indirect - github.com/go-openapi/swag/yamlutils v0.25.4 // indirect - github.com/go-openapi/validate v0.25.1 // indirect + github.com/go-openapi/analysis v0.24.3 // indirect + github.com/go-openapi/errors v0.22.7 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/loads v0.23.3 // indirect + github.com/go-openapi/runtime v0.29.3 // indirect + github.com/go-openapi/spec v0.22.4 // indirect + github.com/go-openapi/strfmt v0.26.1 // indirect + github.com/go-openapi/swag v0.25.5 // indirect + github.com/go-openapi/swag/cmdutils v0.25.5 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/fileutils v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/mangling v0.25.5 // indirect + github.com/go-openapi/swag/netutils v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect + github.com/go-openapi/validate v0.25.2 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobuffalo/pop/v6 v6.1.1 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect @@ -219,7 +221,6 @@ require ( github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/nyaruka/phonenumbers v1.6.12 // indirect - github.com/oklog/ulid v1.3.1 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.2.0 // indirect github.com/olekukonko/ll v0.1.6 // indirect @@ -245,9 +246,9 @@ require ( github.com/sigstore/protobuf-specs v0.5.1 // indirect github.com/sigstore/rekor v1.5.0 // indirect github.com/sigstore/rekor-tiles/v2 v2.0.1 // indirect - github.com/sigstore/sigstore v1.10.4 // indirect + github.com/sigstore/sigstore v1.10.5 // indirect github.com/sigstore/sigstore-go v1.1.4 // indirect - github.com/sigstore/timestamp-authority/v2 v2.0.3 // indirect + github.com/sigstore/timestamp-authority/v2 v2.0.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -274,7 +275,6 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.mongodb.org/mongo-driver v1.17.6 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect go.opentelemetry.io/contrib/propagators/b3 v1.21.0 // indirect go.opentelemetry.io/contrib/propagators/jaeger v1.21.1 // indirect diff --git a/go.sum b/go.sum index 5001d83960..17216637bd 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= @@ -81,8 +81,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhL github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= -github.com/aws/aws-sdk-go-v2/service/kms v1.49.1 h1:U0asSZ3ifpuIehDPkRI2rxHbmFUMplDA2VeR9Uogrmw= -github.com/aws/aws-sdk-go-v2/service/kms v1.49.1/go.mod h1:NZo9WJqQ0sxQ1Yqu1IwCHQFQunTms2MlVgejg16S1rY= +github.com/aws/aws-sdk-go-v2/service/kms v1.50.3 h1:s/zDSG/a/Su9aX+v0Ld9cimUCdkr5FWPmBV8owaEbZY= +github.com/aws/aws-sdk-go-v2/service/kms v1.50.3/go.mod h1:/iSgiUor15ZuxFGQSTf3lA2FmKxFsQoc2tADOarQBSw= github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= @@ -264,54 +264,54 @@ github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-openapi/analysis v0.24.1 h1:Xp+7Yn/KOnVWYG8d+hPksOYnCYImE3TieBa7rBOesYM= -github.com/go-openapi/analysis v0.24.1/go.mod h1:dU+qxX7QGU1rl7IYhBC8bIfmWQdX4Buoea4TGtxXY84= -github.com/go-openapi/errors v0.22.6 h1:eDxcf89O8odEnohIXwEjY1IB4ph5vmbUsBMsFNwXWPo= -github.com/go-openapi/errors v0.22.6/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk= -github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= -github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= -github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= -github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= -github.com/go-openapi/loads v0.23.2 h1:rJXAcP7g1+lWyBHC7iTY+WAF0rprtM+pm8Jxv1uQJp4= -github.com/go-openapi/loads v0.23.2/go.mod h1:IEVw1GfRt/P2Pplkelxzj9BYFajiWOtY2nHZNj4UnWY= -github.com/go-openapi/runtime v0.29.2 h1:UmwSGWNmWQqKm1c2MGgXVpC2FTGwPDQeUsBMufc5Yj0= -github.com/go-openapi/runtime v0.29.2/go.mod h1:biq5kJXRJKBJxTDJXAa00DOTa/anflQPhT0/wmjuy+0= -github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc= -github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs= -github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ= -github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8= -github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= -github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= -github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= -github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= -github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= -github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= -github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= -github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= -github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= -github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= -github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= -github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= -github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= -github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= -github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= -github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= -github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= -github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= -github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= -github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= -github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= -github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= -github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= -github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= -github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= -github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= -github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw= -github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc= +github.com/go-openapi/analysis v0.24.3 h1:a1hrvMr8X0Xt69KP5uVTu5jH62DscmDifrLzNglAayk= +github.com/go-openapi/analysis v0.24.3/go.mod h1:Nc+dWJ/FxZbhSow5Yh3ozg5CLJioB+XXT6MdLvJUsUw= +github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= +github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= +github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= +github.com/go-openapi/runtime v0.29.3 h1:h5twGaEqxtQg40ePiYm9vFFH1q06Czd7Ot6ufdK0w/Y= +github.com/go-openapi/runtime v0.29.3/go.mod h1:8A1W0/L5eyNJvKciqZtvIVQvYO66NlB7INMSZ9bw/oI= +github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= +github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= +github.com/go-openapi/strfmt v0.26.1 h1:7zGCHji7zSYDC2tCXIusoxYQz/48jAf2q+sF6wXTG+c= +github.com/go-openapi/strfmt v0.26.1/go.mod h1:Zslk5VZPOISLwmWTMBIS7oiVFem1o1EI6zULY8Uer7Y= +github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= +github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= +github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= +github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= +github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= +github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= +github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= +github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.1 h1:NZOrZmIb6PTv5LTFxr5/mKV/FjbUzGE7E6gLz7vFoOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.1/go.mod h1:r7dwsujEHawapMsxA69i+XMGZrQ5tRauhLAjV/sxg3Q= +github.com/go-openapi/testify/v2 v2.4.1 h1:zB34HDKj4tHwyUQHrUkpV0Q0iXQ6dUCOQtIqn8hE6Iw= +github.com/go-openapi/testify/v2 v2.4.1/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7xDJZz0= +github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= @@ -319,8 +319,8 @@ github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI6 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobuffalo/attrs v1.0.3/go.mod h1:KvDJCE0avbufqS0Bw3UV7RQynESY0jjod+572ctX4t8= github.com/gobuffalo/envy v1.10.2/go.mod h1:qGAGwdvDsaEtPhfBzb3o0SfDea8ByGn9j8bKmVft9z8= github.com/gobuffalo/fizz v1.14.4/go.mod h1:9/2fGNXNeIFOXEEgTPJwiK63e44RjG+Nc4hfMm1ArGM= @@ -544,8 +544,8 @@ github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0 github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= -github.com/letsencrypt/boulder v0.20251110.0 h1:J8MnKICeilO91dyQ2n5eBbab24neHzUpYMUIOdOtbjc= -github.com/letsencrypt/boulder v0.20251110.0/go.mod h1:ogKCJQwll82m7OVHWyTuf8eeFCjuzdRQlgnZcCl0V+8= +github.com/letsencrypt/boulder v0.20260223.0 h1:xdS2OnJNUasR6TgVIOpqqcvdkOu47+PQQMBk9ThuWBw= +github.com/letsencrypt/boulder v0.20260223.0/go.mod h1:r3aTSA7UZ7dbDfiGK+HLHJz0bWNbHk6YSPiXgzl23sA= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= @@ -647,8 +647,8 @@ github.com/nyaruka/phonenumbers v1.6.12 h1:aeGHjGQnfLhdN5/mZPevhoYMs13FWcQ0Vus0Y github.com/nyaruka/phonenumbers v1.6.12/go.mod h1:IUu45lj2bSeYXQuxDyyuzOrdV10tyRa1YSsfH8EKN5c= github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 h1:9bCMuD3TcnjeqjPT2gSlha4asp8NvgcFRYExCaikCxk= github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25/go.mod h1:eDjgYHYDJbPLBLsyZ6qRaugP0mX8vePOhZ5id1fdzJw= -github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/oleiade/reflections v1.0.1 h1:D1XO3LVEYroYskEsoSiGItp9RUxG6jWnCVvrqH0HHQM= github.com/oleiade/reflections v1.0.1/go.mod h1:rdFxbxq4QXVZWj0F+e9jqjDkc7dbp97vkRixKo2JR60= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= @@ -681,6 +681,7 @@ github.com/ory/jsonschema/v3 v3.0.8 h1:Ssdb3eJ4lDZ/+XnGkvQS/te0p+EkolqwTsDOCxr/F github.com/ory/jsonschema/v3 v3.0.8/go.mod h1:ZPzqjDkwd3QTnb2Z6PAS+OTvBE2x5i6m25wCGx54W/0= github.com/ory/x v0.0.665 h1:61vv0ObCDSX1vOQYbxBeqDiv4YiPmMT91lYxDaaKX08= github.com/ory/x v0.0.665/go.mod h1:7SCTki3N0De3ZpqlxhxU/94ZrOCfNEnXwVtd0xVt+L8= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -757,20 +758,20 @@ github.com/sigstore/rekor v1.5.0 h1:rL7SghHd5HLCtsCrxw0yQg+NczGvM75EjSPPWuGjaiQ= github.com/sigstore/rekor v1.5.0/go.mod h1:D7JoVCUkxwQOpPDNYeu+CE8zeBC18Y5uDo6tF8s2rcQ= github.com/sigstore/rekor-tiles/v2 v2.0.1 h1:1Wfz15oSRNGF5Dzb0lWn5W8+lfO50ork4PGIfEKjZeo= github.com/sigstore/rekor-tiles/v2 v2.0.1/go.mod h1:Pjsbhzj5hc3MKY8FfVTYHBUHQEnP0ozC4huatu4x7OU= -github.com/sigstore/sigstore v1.10.4 h1:ytOmxMgLdcUed3w1SbbZOgcxqwMG61lh1TmZLN+WeZE= -github.com/sigstore/sigstore v1.10.4/go.mod h1:tDiyrdOref3q6qJxm2G+JHghqfmvifB7hw+EReAfnbI= +github.com/sigstore/sigstore v1.10.5 h1:KqrOjDhNOVY+uOzQFat2FrGLClPPCb3uz8pK3wuI+ow= +github.com/sigstore/sigstore v1.10.5/go.mod h1:k/mcVVXw3I87dYG/iCVTSW2xTrW7vPzxxGic4KqsqXs= github.com/sigstore/sigstore-go v1.1.4 h1:wTTsgCHOfqiEzVyBYA6mDczGtBkN7cM8mPpjJj5QvMg= github.com/sigstore/sigstore-go v1.1.4/go.mod h1:2U/mQOT9cjjxrtIUeKDVhL+sHBKsnWddn8URlswdBsg= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.3 h1:D/FRl5J9UYAJPGZRAJbP0dH78pfwWnKsyCSBwFBU8CI= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.3/go.mod h1:2GIWuNvTRMvrzd0Nl8RNqxrt9H7X0OBStwOSzGYRjYw= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.3 h1:k5VMLf/ms7hh6MLgVoorM0K+hSMwZLXoywlxh4CXqP8= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.3/go.mod h1:S1Bp3dmP7jYlXcGLAxG81wRbE01NIZING8ZIy0dJlAI= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.0 h1:iUEf5MZYOuXGnXxdF/WrarJrk0DTVHqeIOjYdtpVXtc= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.0/go.mod h1:i6vg5JfEQix46R1rhQlrKmUtJoeH91drltyYOJEk1T4= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.3 h1:lJSdaC/aOlFHlvqmmV696n1HdXLMLEKGwpNZMV0sKts= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.3/go.mod h1:b2rV9qPbt/jv/Yy75AIOZThP8j+pe1ZdLEjOwmjPdoA= -github.com/sigstore/timestamp-authority/v2 v2.0.3 h1:sRyYNtdED/ttLCMdaYnwpf0zre1A9chvjTnCmWWxN8Y= -github.com/sigstore/timestamp-authority/v2 v2.0.3/go.mod h1:mDaHxkt3HmZYoIlwYj4QWo0RUr7VjYU52aVO5f5Qb3I= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.5 h1:aqHRubTITULckG9JAcq2FEhtKkT/RRE8oErfuV3smSI= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.5/go.mod h1:h9eK9QyPqpFskF/ewFkRLtwh4/Q3FLc2/DXbym4IHN8= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.5 h1:+9C6CUkv+J4iT67Lx+H1EGBfAdoAHqXumHadeIj9jA4= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.5/go.mod h1:myZsg7wRiy/vf102g5uUAitYhtXCwepmAGxgHG1VHuE= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.5 h1:BpQx6AhjwIN9LmlO4ypkcMcHiWiepgZQGSw5U69frHU= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.5/go.mod h1:ejMD/17lMJ4HykQRPdj5NNr+OQYIEZto8HjDKghVMOA= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.5 h1:OFwQZgWkB/6J6W5sy3SkXE4pJnhNRnE2cJd8ySXmHpo= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.5/go.mod h1:Ee/enmyxi/RFLVlajbnjgH2wOWQwlJ0wY8qZrk43hEw= +github.com/sigstore/timestamp-authority/v2 v2.0.6 h1:1Vh7/SdmLsVLG6Br6/bisd1SnlicfDm0MJYiA+D7Ppw= +github.com/sigstore/timestamp-authority/v2 v2.0.6/go.mod h1:Nk5ucGBDyH0tXAIMZ0prf6xn8qfTnbJhSq+CDabYcfc= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -848,10 +849,10 @@ github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuX github.com/tink-crypto/tink-go-awskms/v2 v2.1.0/go.mod h1:PxSp9GlOkKL9rlybW804uspnHuO9nbD98V/fDX4uSis= github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= -github.com/tink-crypto/tink-go-hcvault/v2 v2.3.0 h1:6nAX1aRGnkg2SEUMwO5toB2tQkP0Jd6cbmZ/K5Le1V0= -github.com/tink-crypto/tink-go-hcvault/v2 v2.3.0/go.mod h1:HOC5NWW1wBI2Vke1FGcRBvDATkEYE7AUDiYbXqi2sBw= -github.com/tink-crypto/tink-go/v2 v2.5.0 h1:B8KLF6AofxdBIE4UJIaFbmoj5/1ehEtt7/MmzfI4Zpw= -github.com/tink-crypto/tink-go/v2 v2.5.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= +github.com/tink-crypto/tink-go-hcvault/v2 v2.4.0 h1:j+S+WKBQ5ya26A5EM/uXoVe+a2IaPQN8KgBJZ22cJ+4= +github.com/tink-crypto/tink-go-hcvault/v2 v2.4.0/go.mod h1:OCKJIujnTzDq7f+73NhVs99oA2c1TR6nsOpuasYM6Yo= +github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4= +github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= @@ -896,8 +897,6 @@ github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cma github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= -go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= -go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= @@ -938,8 +937,8 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.starlark.net v0.0.0-20260326113308-fadfc96def35 h1:VYAqieSOJNxBDX8KJneTAwvdf4J4zRDE2u+UFXtt9h4= go.starlark.net v0.0.0-20260326113308-fadfc96def35/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= -go.step.sm/crypto v0.74.0 h1:/APBEv45yYR4qQFg47HA8w1nesIGcxh44pGyQNw6JRA= -go.step.sm/crypto v0.74.0/go.mod h1:UoXqCAJjjRgzPte0Llaqen7O9P7XjPmgjgTHQGkKCDk= +go.step.sm/crypto v0.77.2 h1:qFjjei+RHc5kP5R7NW9OUWT7SqWIuAOvOkXqg4fNWj8= +go.step.sm/crypto v0.77.2/go.mod h1:W0YJb9onM5l78qgkXIJ2Up6grnwW8EtpCKIza/NCg0o= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= From 109815bf98fc0035dfdb966d270a63ff2dbb2e02 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio <ozz@stacklok.com> Date: Tue, 21 Apr 2026 11:58:10 +0300 Subject: [PATCH 094/117] Account for embedded auth env in deployment drift checks (#4878) * Fix embedded auth deployment drift checks * Break long line to satisfy lll linter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Remove SSA reordering tests that only exercise fake client These tests validated behavior of fake.NewClientset(), not any production code. The fake client does not implement real SSA merge semantics, so the assertions were meaningless. The actual drift check regression is covered by the controller-level tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../controllers/mcpremoteproxy_controller.go | 12 ++ .../mcpremoteproxy_deployment_test.go | 180 ++++++++++++++++ .../controllers/mcpserver_controller.go | 14 ++ .../mcpserver_resource_overrides_test.go | 194 +++++++++++++++++- pkg/container/kubernetes/client_test.go | 14 +- 5 files changed, 401 insertions(+), 13 deletions(-) diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go index c73e902f41..84b503678f 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go @@ -1274,6 +1274,18 @@ func (r *MCPRemoteProxyReconciler) containerNeedsUpdate( // Check if environment variables have changed expectedEnv := r.buildEnvVarsForProxy(ctx, proxy) + configName := ctrlutil.EmbeddedAuthServerConfigName( + proxy.Spec.ExternalAuthConfigRef, proxy.Spec.AuthServerRef, + ) + if configName != "" { + _, _, authServerEnvVars, err := ctrlutil.GenerateAuthServerConfigByName( + ctx, r.Client, proxy.Namespace, configName, + ) + if err != nil { + return true + } + expectedEnv = append(expectedEnv, authServerEnvVars...) + } if !reflect.DeepEqual(container.Env, expectedEnv) { return true } diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go index fdbf86583b..6686f30d76 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go @@ -700,6 +700,186 @@ func TestEnsureService(t *testing.T) { } } +func TestMCPRemoteProxyDeploymentNeedsUpdate_EmbeddedAuthLegacyEnvStable(t *testing.T) { + t.Parallel() + + scheme := createRunConfigTestScheme() + + authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "embedded-auth", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + { + Name: "google", + Type: mcpv1alpha1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + IssuerURL: "https://accounts.google.com", + ClientID: "client-id", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: "upstream-secret", + Key: "client-secret", + }, + }, + }, + }, + }, + }, + } + + proxy := &mcpv1alpha1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-proxy", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + ProxyPort: 8080, + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: authConfig.Name, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(authConfig). + Build() + + reconciler := &MCPRemoteProxyReconciler{ + Client: fakeClient, + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + deployment := reconciler.deploymentForMCPRemoteProxy(t.Context(), proxy, "test-checksum") + require.NotNil(t, deployment) + + assert.False(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, proxy, "test-checksum")) +} + +func TestMCPRemoteProxyDeploymentNeedsUpdate_EmbeddedAuthAuthServerRefEnvStable(t *testing.T) { + t.Parallel() + + scheme := createRunConfigTestScheme() + + authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "embedded-auth", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + { + Name: "google", + Type: mcpv1alpha1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + IssuerURL: "https://accounts.google.com", + ClientID: "client-id", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: "upstream-secret", + Key: "client-secret", + }, + }, + }, + }, + }, + }, + } + + proxy := &mcpv1alpha1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-proxy", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + ProxyPort: 8080, + AuthServerRef: &mcpv1alpha1.AuthServerRef{ + Kind: "MCPExternalAuthConfig", + Name: authConfig.Name, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(authConfig). + Build() + + reconciler := &MCPRemoteProxyReconciler{ + Client: fakeClient, + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + deployment := reconciler.deploymentForMCPRemoteProxy(t.Context(), proxy, "test-checksum") + require.NotNil(t, deployment) + + assert.False(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, proxy, "test-checksum")) +} + +func TestMCPRemoteProxyDeploymentNeedsUpdate_TokenExchangeDoesNotDrift(t *testing.T) { + t.Parallel() + + scheme := createRunConfigTestScheme() + + authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "exchange-config", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "client-id", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: "token-secret", + Key: "client-secret", + }, + Audience: "api", + }, + }, + } + + proxy := &mcpv1alpha1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-proxy", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + ProxyPort: 8080, + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: authConfig.Name, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(authConfig). + Build() + + reconciler := &MCPRemoteProxyReconciler{ + Client: fakeClient, + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + deployment := reconciler.deploymentForMCPRemoteProxy(t.Context(), proxy, "test-checksum") + require.NotNil(t, deployment) + + assert.False(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, proxy, "test-checksum")) +} + // TestBuildEnvVarsForProxy tests environment variable building func TestBuildEnvVarsForProxy(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/controllers/mcpserver_controller.go b/cmd/thv-operator/controllers/mcpserver_controller.go index 44068ccf43..a3f955ada8 100644 --- a/cmd/thv-operator/controllers/mcpserver_controller.go +++ b/cmd/thv-operator/controllers/mcpserver_controller.go @@ -1654,6 +1654,20 @@ func (r *MCPServerReconciler) deploymentNeedsUpdate( }) } } + + // Add embedded auth server environment variables. AuthServerRef takes precedence; + // externalAuthConfigRef is used as a fallback (legacy path). + if configName := ctrlutil.EmbeddedAuthServerConfigName( + mcpServer.Spec.ExternalAuthConfigRef, mcpServer.Spec.AuthServerRef, + ); configName != "" { + _, _, authServerEnvVars, err := ctrlutil.GenerateAuthServerConfigByName( + ctx, r.Client, mcpServer.Namespace, configName, + ) + if err != nil { + return true + } + expectedProxyEnv = append(expectedProxyEnv, authServerEnvVars...) + } // Add default environment variables that are always injected expectedProxyEnv = ctrlutil.EnsureRequiredEnvVars(ctx, expectedProxyEnv) if !equality.Semantic.DeepEqual(container.Env, expectedProxyEnv) { diff --git a/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go b/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go index ba6e516752..9adfedfb9f 100644 --- a/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go +++ b/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go @@ -15,7 +15,6 @@ package controllers import ( - "context" "testing" "github.com/stretchr/testify/assert" @@ -30,6 +29,189 @@ import ( "github.com/stacklok/toolhive/pkg/container/kubernetes" ) +func TestMCPServerDeploymentNeedsUpdate_EmbeddedAuthLegacyEnvStable(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + + externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "embedded-auth", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + { + Name: "google", + Type: mcpv1alpha1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + IssuerURL: "https://accounts.google.com", + ClientID: "client-id", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: "upstream-secret", + Key: "client-secret", + }, + }, + }, + }, + }, + }, + } + + mcpServer := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ProxyPort: 8080, + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: externalAuthConfig.Name, + }, + }, + } + + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(externalAuthConfig). + Build() + r := newTestMCPServerReconciler(client, scheme, kubernetes.PlatformKubernetes) + + deployment := r.deploymentForMCPServer(t.Context(), mcpServer, "test-checksum") + require.NotNil(t, deployment) + require.Len(t, deployment.Spec.Template.Spec.Containers, 1) + require.Contains(t, deployment.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{ + Name: "TOOLHIVE_UPSTREAM_CLIENT_SECRET_GOOGLE", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "upstream-secret"}, + Key: "client-secret", + }, + }, + }) + + assert.False(t, r.deploymentNeedsUpdate(t.Context(), deployment, mcpServer, "test-checksum")) +} + +func TestMCPServerDeploymentNeedsUpdate_EmbeddedAuthAuthServerRefEnvStable(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + + authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "embedded-auth", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + { + Name: "google", + Type: mcpv1alpha1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + IssuerURL: "https://accounts.google.com", + ClientID: "client-id", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: "upstream-secret", + Key: "client-secret", + }, + }, + }, + }, + }, + }, + } + + mcpServer := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ProxyPort: 8080, + AuthServerRef: &mcpv1alpha1.AuthServerRef{ + Kind: "MCPExternalAuthConfig", + Name: authConfig.Name, + }, + }, + } + + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(authConfig). + Build() + r := newTestMCPServerReconciler(client, scheme, kubernetes.PlatformKubernetes) + + deployment := r.deploymentForMCPServer(t.Context(), mcpServer, "test-checksum") + require.NotNil(t, deployment) + require.Len(t, deployment.Spec.Template.Spec.Containers, 1) + + assert.False(t, r.deploymentNeedsUpdate(t.Context(), deployment, mcpServer, "test-checksum")) +} + +func TestMCPServerDeploymentNeedsUpdate_TokenExchangeDoesNotDrift(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + + authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "exchange-config", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "client-id", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: "token-secret", + Key: "client-secret", + }, + Audience: "api", + }, + }, + } + + mcpServer := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image", + ProxyPort: 8080, + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: authConfig.Name, + }, + }, + } + + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(authConfig). + Build() + r := newTestMCPServerReconciler(client, scheme, kubernetes.PlatformKubernetes) + + deployment := r.deploymentForMCPServer(t.Context(), mcpServer, "test-checksum") + require.NotNil(t, deployment) + require.Len(t, deployment.Spec.Template.Spec.Containers, 1) + + assert.False(t, r.deploymentNeedsUpdate(t.Context(), deployment, mcpServer, "test-checksum")) +} + func TestResourceOverrides(t *testing.T) { t.Parallel() @@ -341,7 +523,7 @@ func TestResourceOverrides(t *testing.T) { r := newTestMCPServerReconciler(client, scheme, kubernetes.PlatformKubernetes) // Test deployment creation - ctx := context.Background() + ctx := t.Context() deployment := r.deploymentForMCPServer(ctx, tt.mcpServer, "test-checksum") require.NotNil(t, deployment) @@ -349,7 +531,7 @@ func TestResourceOverrides(t *testing.T) { assert.Equal(t, tt.expectedDeploymentAnns, deployment.Annotations) // Test service creation - service := r.serviceForMCPServer(context.Background(), tt.mcpServer) + service := r.serviceForMCPServer(t.Context(), tt.mcpServer) require.NotNil(t, service) assert.Equal(t, tt.expectedServiceLabels, service.Labels) @@ -615,7 +797,7 @@ func TestDeploymentNeedsUpdateProxyEnv(t *testing.T) { t.Parallel() // Create a deployment and manually set up its state to isolate proxy env testing - ctx := context.Background() + ctx := t.Context() deployment := r.deploymentForMCPServer(ctx, tt.mcpServer, "test-checksum") require.NotNil(t, deployment) require.Len(t, deployment.Spec.Template.Spec.Containers, 1) @@ -627,7 +809,7 @@ func TestDeploymentNeedsUpdateProxyEnv(t *testing.T) { deployment.Spec.Template.Spec.Containers[0].Image = getToolhiveRunnerImage() // Test if deployment needs update - should correlate with env change expectation - needsUpdate := r.deploymentNeedsUpdate(context.Background(), deployment, tt.mcpServer, "test-checksum") + needsUpdate := r.deploymentNeedsUpdate(t.Context(), deployment, tt.mcpServer, "test-checksum") if tt.expectEnvChange { assert.True(t, needsUpdate, "Expected deployment update due to proxy env change") @@ -663,7 +845,7 @@ func TestMCPServerSessionAffinityNone(t *testing.T) { client := fake.NewClientBuilder().WithScheme(scheme).Build() r := newTestMCPServerReconciler(client, scheme, kubernetes.PlatformKubernetes) - service := r.serviceForMCPServer(context.Background(), mcpServer) + service := r.serviceForMCPServer(t.Context(), mcpServer) require.NotNil(t, service) assert.Equal(t, corev1.ServiceAffinityNone, service.Spec.SessionAffinity) } diff --git a/pkg/container/kubernetes/client_test.go b/pkg/container/kubernetes/client_test.go index e68a65812f..6d12be60ee 100644 --- a/pkg/container/kubernetes/client_test.go +++ b/pkg/container/kubernetes/client_test.go @@ -193,7 +193,7 @@ func TestCreateContainerWithPodTemplatePatch(t *testing.T) { // Deploy the workload _, err := client.DeployWorkload( - context.Background(), + t.Context(), "test-image", "test-container", []string{"test-command"}, @@ -215,7 +215,7 @@ func TestCreateContainerWithPodTemplatePatch(t *testing.T) { // Get the created StatefulSet statefulSet, err := clientset.AppsV1().StatefulSets("default").Get( - context.Background(), + t.Context(), "test-container", metav1.GetOptions{}, ) @@ -729,7 +729,7 @@ func TestCreateContainerWithMCP(t *testing.T) { // Deploy the workload _, err := client.DeployWorkload( - context.Background(), + t.Context(), tc.image, "test-container", tc.command, @@ -751,7 +751,7 @@ func TestCreateContainerWithMCP(t *testing.T) { // Get the created StatefulSet statefulSet, err := clientset.AppsV1().StatefulSets("default").Get( - context.Background(), + t.Context(), "test-container", metav1.GetOptions{}, ) @@ -959,7 +959,7 @@ func TestAttachToWorkloadNoPodFound(t *testing.T) { client.namespaceFunc = func() string { return defaultNamespace } // Call AttachToWorkload with a workload that has no pods - _, _, err := client.AttachToWorkload(context.Background(), "nonexistent-workload") + _, _, err := client.AttachToWorkload(t.Context(), "nonexistent-workload") // Should return error immediately (no pods found) require.Error(t, err) @@ -1302,7 +1302,7 @@ func TestDeployWorkloadBackendReplicas(t *testing.T) { client.namespaceFunc = func() string { return defaultNamespace } _, err := client.DeployWorkload( - context.Background(), + t.Context(), "test-image", "test-container", nil, map[string]string{}, map[string]string{}, nil, "streamable-http", options, false, @@ -1310,7 +1310,7 @@ func TestDeployWorkloadBackendReplicas(t *testing.T) { require.NoError(t, err) sts, err := clientset.AppsV1().StatefulSets(defaultNamespace).Get( - context.Background(), "test-container", metav1.GetOptions{}, + t.Context(), "test-container", metav1.GetOptions{}, ) require.NoError(t, err) return sts From ea4cfbce034cf92c143d4328fe6509ed84e101d2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:58:22 +0200 Subject: [PATCH 095/117] Update actions/cache digest to 27d5ce7 (#4924) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 4 ++-- .github/workflows/verify-gen.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1188e900b3..4af6224022 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,7 +28,7 @@ jobs: # Cache Go build cache for faster compilation during linting - name: Cache Go build cache - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/.cache/go-build key: ${{ runner.os }}-go-build-lint-${{ hashFiles('**/go.sum') }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1571eb7652..6428e35230 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: # Cache Go build cache for faster compilation # Note: ~/go/pkg/mod is already cached by actions/setup-go with cache: true - name: Cache Go build cache - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/.cache/go-build key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} @@ -43,7 +43,7 @@ jobs: # Cache Go tools (gotestfmt only for tests) - name: Cache Go tools id: cache-go-tools - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/go/bin key: ${{ runner.os }}-go-tools-${{ hashFiles('go.mod') }}-gotestfmt-v2 diff --git a/.github/workflows/verify-gen.yml b/.github/workflows/verify-gen.yml index 246e196f3f..90ced72761 100644 --- a/.github/workflows/verify-gen.yml +++ b/.github/workflows/verify-gen.yml @@ -21,7 +21,7 @@ jobs: # Cache Go tools (mockgen) - name: Cache Go tools id: cache-go-tools - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/go/bin key: ${{ runner.os }}-go-codegen-tools-${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('go.mod') }}-mockgen From 7a9d65909b1b942d7358bcd70312cb8b78cc1d04 Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:04:01 +0200 Subject: [PATCH 096/117] Split skillsvc into per-concern files (#4947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Split skillsvc.go into per-concern files skillsvc.go had grown to ~2000 lines mixing ten concerns (service construction, OCI install, git install, extraction, client resolution, registry lookup, content fetching, build/push, uninstall, list/info). Split it into focused files within the same skillsvc package: service, install, install_oci, install_git, install_extraction, clients, oci, registry, content, list, uninstall, build, scope. All symbols stay in the same package, so there are no circular-import risks and no exported API changes. No behavioural changes, no renames, no signature changes. * Split skillsvc_test.go into per-concern test files skillsvc_test.go had grown to ~3900 lines. Split it into per-area test files mirroring the production layout, with shared fixtures extracted into testhelpers_test.go. Each Test* function is moved verbatim — no behavioural changes to any test. * Remove original skillsvc.go and skillsvc_test.go The previous two commits added the split files but left the originals in place. Delete them now that all symbols have been moved to per-concern files. --- pkg/skills/skillsvc/build.go | 226 + pkg/skills/skillsvc/build_test.go | 590 +++ pkg/skills/skillsvc/clients.go | 224 + pkg/skills/skillsvc/content.go | 212 + pkg/skills/skillsvc/content_test.go | 291 ++ pkg/skills/skillsvc/info_test.go | 121 + pkg/skills/skillsvc/install.go | 219 + pkg/skills/skillsvc/install_extraction.go | 210 + pkg/skills/skillsvc/install_git.go | 235 ++ pkg/skills/skillsvc/install_git_test.go | 419 ++ pkg/skills/skillsvc/install_oci.go | 158 + pkg/skills/skillsvc/install_oci_test.go | 724 ++++ pkg/skills/skillsvc/install_registry_test.go | 404 ++ pkg/skills/skillsvc/install_test.go | 818 ++++ pkg/skills/skillsvc/list.go | 84 + pkg/skills/skillsvc/oci.go | 185 + pkg/skills/skillsvc/oci_test.go | 47 + pkg/skills/skillsvc/registry.go | 170 + pkg/skills/skillsvc/scope.go | 27 + pkg/skills/skillsvc/service.go | 139 + pkg/skills/skillsvc/service_test.go | 235 ++ pkg/skills/skillsvc/skillsvc.go | 1982 --------- pkg/skills/skillsvc/skillsvc_test.go | 3912 ------------------ pkg/skills/skillsvc/testhelpers_test.go | 119 + pkg/skills/skillsvc/uninstall.go | 81 + pkg/skills/skillsvc/uninstall_test.go | 359 ++ 26 files changed, 6297 insertions(+), 5894 deletions(-) create mode 100644 pkg/skills/skillsvc/build.go create mode 100644 pkg/skills/skillsvc/build_test.go create mode 100644 pkg/skills/skillsvc/clients.go create mode 100644 pkg/skills/skillsvc/content.go create mode 100644 pkg/skills/skillsvc/content_test.go create mode 100644 pkg/skills/skillsvc/info_test.go create mode 100644 pkg/skills/skillsvc/install.go create mode 100644 pkg/skills/skillsvc/install_extraction.go create mode 100644 pkg/skills/skillsvc/install_git.go create mode 100644 pkg/skills/skillsvc/install_git_test.go create mode 100644 pkg/skills/skillsvc/install_oci.go create mode 100644 pkg/skills/skillsvc/install_oci_test.go create mode 100644 pkg/skills/skillsvc/install_registry_test.go create mode 100644 pkg/skills/skillsvc/install_test.go create mode 100644 pkg/skills/skillsvc/list.go create mode 100644 pkg/skills/skillsvc/oci.go create mode 100644 pkg/skills/skillsvc/oci_test.go create mode 100644 pkg/skills/skillsvc/registry.go create mode 100644 pkg/skills/skillsvc/scope.go create mode 100644 pkg/skills/skillsvc/service.go create mode 100644 pkg/skills/skillsvc/service_test.go delete mode 100644 pkg/skills/skillsvc/skillsvc.go delete mode 100644 pkg/skills/skillsvc/skillsvc_test.go create mode 100644 pkg/skills/skillsvc/testhelpers_test.go create mode 100644 pkg/skills/skillsvc/uninstall.go create mode 100644 pkg/skills/skillsvc/uninstall_test.go diff --git a/pkg/skills/skillsvc/build.go b/pkg/skills/skillsvc/build.go new file mode 100644 index 0000000000..c5a762c58a --- /dev/null +++ b/pkg/skills/skillsvc/build.go @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "path/filepath" + "strings" + + nameref "github.com/google/go-containerregistry/pkg/name" + + "github.com/stacklok/toolhive-core/httperr" + ociskills "github.com/stacklok/toolhive-core/oci/skills" + "github.com/stacklok/toolhive/pkg/skills" +) + +// Validate checks whether a skill definition is valid. +func (*service) Validate(_ context.Context, path string) (*skills.ValidationResult, error) { + if err := validateLocalPath(path); err != nil { + return nil, err + } + return skills.ValidateSkillDir(path) +} + +// Build packages a skill directory into a local OCI artifact. +func (s *service) Build(ctx context.Context, opts skills.BuildOptions) (*skills.BuildResult, error) { + if s.packager == nil || s.ociStore == nil { + return nil, httperr.WithCode( + errors.New("OCI packaging is not configured"), + http.StatusInternalServerError, + ) + } + if err := validateLocalPath(opts.Path); err != nil { + return nil, err + } + result, err := s.packager.Package(ctx, opts.Path, ociskills.DefaultPackageOptions()) + if err != nil { + return nil, fmt.Errorf("packaging skill: %w", err) + } + + // Tag resolution precedence: + // 1. Explicit tag from BuildOptions.Tag + // 2. Skill name from the parsed config (SKILL.md frontmatter) + // 3. No tag — use raw digest as the reference + tag := func() string { + if opts.Tag != "" { + return opts.Tag + } + if result.Config != nil && result.Config.Name != "" { + return result.Config.Name + } + return "" + }() + + if tag != "" { + if err := validateOCITagOrReference(tag); err != nil { + return nil, err + } + } + + if tag != "" { + if tagErr := s.ociStore.Tag(ctx, result.IndexDigest, tag); tagErr != nil { + return nil, fmt.Errorf("tagging artifact: %w", tagErr) + } + } + + ref := func() string { + if tag != "" { + return tag + } + return result.IndexDigest.String() + }() + + return &skills.BuildResult{Reference: ref}, nil +} + +// Push pushes a locally built skill artifact to a remote OCI registry. +func (s *service) Push(ctx context.Context, opts skills.PushOptions) error { + if s.registry == nil || s.ociStore == nil { + return httperr.WithCode( + errors.New("OCI registry is not configured"), + http.StatusInternalServerError, + ) + } + if opts.Reference == "" { + return httperr.WithCode( + errors.New("reference is required"), + http.StatusBadRequest, + ) + } + + d, err := s.ociStore.Resolve(ctx, opts.Reference) + if err != nil { + slog.Debug("failed to resolve OCI reference", "reference", opts.Reference, "error", err) + return httperr.WithCode( + fmt.Errorf("reference %q not found in local store", opts.Reference), + http.StatusNotFound, + ) + } + + if err := s.registry.Push(ctx, s.ociStore, d, opts.Reference); err != nil { + return fmt.Errorf("pushing to registry: %w", err) + } + + return nil +} + +// ListBuilds returns all locally-built OCI skill artifacts in the local store. +func (s *service) ListBuilds(ctx context.Context) ([]skills.LocalBuild, error) { + if s.ociStore == nil { + return nil, httperr.WithCode( + errors.New("OCI packaging is not configured"), + http.StatusInternalServerError, + ) + } + + tags, err := s.ociStore.ListTags(ctx) + if err != nil { + return nil, fmt.Errorf("listing local OCI builds: %w", err) + } + + builds := make([]skills.LocalBuild, 0, len(tags)) + for _, tag := range tags { + d, resolveErr := s.ociStore.Resolve(ctx, tag) + if resolveErr != nil { + slog.Debug("failed to resolve tag in local OCI store", "tag", tag, "error", resolveErr) + continue + } + + isSkill, typeErr := s.isSkillArtifact(ctx, d) + if typeErr != nil { + slog.Debug("failed to check artifact type in local OCI store", "tag", tag, "error", typeErr) + continue + } + if !isSkill { + continue + } + + build := skills.LocalBuild{ + Tag: tag, + Digest: d.String(), + } + + // Best-effort: enrich with skill metadata from the OCI config labels. + if _, cfg, extractErr := s.extractOCIContent(ctx, d); extractErr == nil && cfg != nil { + build.Name = cfg.Name + build.Description = cfg.Description + build.Version = cfg.Version + } else if extractErr != nil { + slog.Debug("failed to extract skill config from local build", "tag", tag, "error", extractErr) + } + + builds = append(builds, build) + } + + return builds, nil +} + +// DeleteBuild removes a locally-built OCI skill artifact from the local store. +// It deletes the tag and, when no other tag shares the same digest, also +// garbage-collects all associated blobs. +func (s *service) DeleteBuild(ctx context.Context, tag string) error { + if s.ociStore == nil { + return httperr.WithCode( + errors.New("OCI packaging is not configured"), + http.StatusInternalServerError, + ) + } + return s.ociStore.DeleteBuild(ctx, tag) +} + +// validateLocalPath checks that a path is non-empty, absolute, and does not +// contain ".." path traversal segments. This prevents API clients from +// accessing arbitrary directories on the host filesystem via traversal. +func validateLocalPath(path string) error { + if path == "" { + return httperr.WithCode(errors.New("path is required"), http.StatusBadRequest) + } + if strings.ContainsRune(path, 0) { + return httperr.WithCode(errors.New("path contains null bytes"), http.StatusBadRequest) + } + if !filepath.IsAbs(path) { + return httperr.WithCode( + fmt.Errorf("path must be absolute, got %q", path), + http.StatusBadRequest, + ) + } + // Check the raw path for ".." segments before cleaning resolves them. + // This catches traversal attempts like /safe/dir/../../../etc. + for _, segment := range strings.Split(filepath.ToSlash(path), "/") { + if segment == ".." { + return httperr.WithCode(errors.New("path must not contain '..' traversal segments"), http.StatusBadRequest) + } + } + return nil +} + +// validateOCITagOrReference accepts either a bare OCI tag ("v1.0.0") or a full +// OCI reference ("ghcr.io/org/repo:v1.0.0"). The --tag flag in `thv skill build` +// supports both forms (matching `docker build -t` semantics), so we route to +// the appropriate parser based on the presence of '/', ':', or '@'. +func validateOCITagOrReference(value string) error { + if strings.ContainsAny(value, "/:@") { + // Looks like a full OCI reference — validate as such. + if _, err := nameref.ParseReference(value, nameref.StrictValidation); err != nil { + return httperr.WithCode( + fmt.Errorf("invalid OCI reference or tag %q: %w", value, err), + http.StatusBadRequest, + ) + } + return nil + } + // Bare tag — construct a dummy reference to validate the tag portion. + if _, err := nameref.NewTag("dummy.invalid/repo:"+value, nameref.StrictValidation); err != nil { + return httperr.WithCode( + fmt.Errorf("invalid OCI reference or tag %q: %w", value, err), + http.StatusBadRequest, + ) + } + return nil +} diff --git a/pkg/skills/skillsvc/build_test.go b/pkg/skills/skillsvc/build_test.go new file mode 100644 index 0000000000..04569e8e7a --- /dev/null +++ b/pkg/skills/skillsvc/build_test.go @@ -0,0 +1,590 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + ociskills "github.com/stacklok/toolhive-core/oci/skills" + ocimocks "github.com/stacklok/toolhive-core/oci/skills/mocks" + "github.com/stacklok/toolhive/pkg/skills" + "github.com/stacklok/toolhive/pkg/storage" +) + +func TestValidate(t *testing.T) { + t.Parallel() + + t.Run("valid skill directory", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + skillDir := filepath.Join(dir, "test-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: test-skill\ndescription: A test skill\n---\n# Test Skill\n"), + 0o600, + )) + + svc := New(&storage.NoopSkillStore{}) + result, err := svc.Validate(t.Context(), skillDir) + require.NoError(t, err) + assert.True(t, result.Valid) + }) + + t.Run("missing SKILL.md", func(t *testing.T) { + t.Parallel() + svc := New(&storage.NoopSkillStore{}) + result, err := svc.Validate(t.Context(), t.TempDir()) + require.NoError(t, err) + assert.False(t, result.Valid) + assert.Contains(t, result.Errors, "SKILL.md not found in skill directory") + }) + + t.Run("empty path returns 400", func(t *testing.T) { + t.Parallel() + svc := New(&storage.NoopSkillStore{}) + _, err := svc.Validate(t.Context(), "") + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("relative path returns 400", func(t *testing.T) { + t.Parallel() + svc := New(&storage.NoopSkillStore{}) + _, err := svc.Validate(t.Context(), "relative/path") + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("path traversal returns 400", func(t *testing.T) { + t.Parallel() + svc := New(&storage.NoopSkillStore{}) + _, err := svc.Validate(t.Context(), "/foo/../../../etc") + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) +} + +// putTestManifest stores a minimal manifest in the OCI store and returns its digest. +func TestBuild(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts skills.BuildOptions + setup func(*gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) + wantCode int + wantRef string + wantErr string + }{ + { + name: "nil packager returns 500", + opts: skills.BuildOptions{Path: "/some/dir"}, + setup: func(_ *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + return nil, nil + }, + wantCode: http.StatusInternalServerError, + }, + { + name: "empty path returns 400", + opts: skills.BuildOptions{Path: ""}, + setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + return ocimocks.NewMockSkillPackager(ctrl), ociStore + }, + wantCode: http.StatusBadRequest, + }, + { + name: "relative path returns 400", + opts: skills.BuildOptions{Path: "relative/path"}, + setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + return ocimocks.NewMockSkillPackager(ctrl), ociStore + }, + wantCode: http.StatusBadRequest, + }, + { + name: "path traversal returns 400", + opts: skills.BuildOptions{Path: "/some/dir/../../../etc"}, + setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + return ocimocks.NewMockSkillPackager(ctrl), ociStore + }, + wantCode: http.StatusBadRequest, + }, + { + name: "invalid tag returns 400", + opts: skills.BuildOptions{Path: "/some/dir", Tag: "invalid tag!@#"}, + setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + d := putTestManifest(t, ociStore) + p := ocimocks.NewMockSkillPackager(ctrl) + p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). + Return(&ociskills.PackageResult{ + IndexDigest: d, + Config: &ociskills.SkillConfig{}, + }, nil) + return p, ociStore + }, + wantCode: http.StatusBadRequest, + }, + { + name: "packager error propagates", + opts: skills.BuildOptions{Path: "/some/dir"}, + setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + p := ocimocks.NewMockSkillPackager(ctrl) + p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). + Return(nil, fmt.Errorf("packaging failed")) + return p, ociStore + }, + wantErr: "packaging skill", + }, + { + name: "successful build with explicit tag", + opts: skills.BuildOptions{Path: "/some/dir", Tag: "v1.0.0"}, + setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + d := putTestManifest(t, ociStore) + p := ocimocks.NewMockSkillPackager(ctrl) + p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). + Return(&ociskills.PackageResult{ + IndexDigest: d, + Config: &ociskills.SkillConfig{Name: "my-skill"}, + }, nil) + return p, ociStore + }, + wantRef: "v1.0.0", + }, + { + name: "build without tag uses config name", + opts: skills.BuildOptions{Path: "/some/dir"}, + setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + d := putTestManifest(t, ociStore) + p := ocimocks.NewMockSkillPackager(ctrl) + p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). + Return(&ociskills.PackageResult{ + IndexDigest: d, + Config: &ociskills.SkillConfig{Name: "my-skill"}, + }, nil) + return p, ociStore + }, + wantRef: "my-skill", + }, + { + name: "build without tag or config name returns digest", + opts: skills.BuildOptions{Path: "/some/dir"}, + setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + d := putTestManifest(t, ociStore) + p := ocimocks.NewMockSkillPackager(ctrl) + p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). + Return(&ociskills.PackageResult{ + IndexDigest: d, + Config: &ociskills.SkillConfig{}, + }, nil) + return p, ociStore + }, + // wantRef is set dynamically below since the digest depends on store content + }, + { + name: "invalid fallback config name returns 400", + opts: skills.BuildOptions{Path: "/some/dir"}, + setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + d := putTestManifest(t, ociStore) + p := ocimocks.NewMockSkillPackager(ctrl) + p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). + Return(&ociskills.PackageResult{ + IndexDigest: d, + Config: &ociskills.SkillConfig{Name: "invalid name!@#"}, + }, nil) + return p, ociStore + }, + wantCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + packager, ociStore := tt.setup(ctrl) + + svc := New(&storage.NoopSkillStore{}, + WithPackager(packager), + WithOCIStore(ociStore), + ) + + result, err := svc.Build(t.Context(), tt.opts) + if tt.wantCode != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantRef != "" { + assert.Equal(t, tt.wantRef, result.Reference) + } else { + // Fallback case returns a digest string + assert.Contains(t, result.Reference, "sha256:") + } + }) + } +} + +func TestPush(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts skills.PushOptions + setup func(*gomock.Controller) (ociskills.RegistryClient, *ociskills.Store) + wantCode int + wantErr string + }{ + { + name: "nil registry returns 500", + opts: skills.PushOptions{Reference: "ghcr.io/test/skill:v1"}, + setup: func(_ *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store) { + return nil, nil + }, + wantCode: http.StatusInternalServerError, + }, + { + name: "empty reference returns 400", + opts: skills.PushOptions{Reference: ""}, + setup: func(ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + return ocimocks.NewMockRegistryClient(ctrl), ociStore + }, + wantCode: http.StatusBadRequest, + }, + { + name: "resolve not found returns 404", + opts: skills.PushOptions{Reference: "nonexistent"}, + setup: func(ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + return ocimocks.NewMockRegistryClient(ctrl), ociStore + }, + wantCode: http.StatusNotFound, + }, + { + name: "registry push error propagates", + opts: skills.PushOptions{Reference: "my-tag"}, + setup: func(ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + // Create a manifest so Resolve succeeds. + d, tagErr := ociStore.PutManifest(t.Context(), []byte(`{"schemaVersion":2}`)) + require.NoError(t, tagErr) + require.NoError(t, ociStore.Tag(t.Context(), d, "my-tag")) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Push(gomock.Any(), ociStore, d, "my-tag"). + Return(fmt.Errorf("auth failed")) + return reg, ociStore + }, + wantErr: "pushing to registry", + }, + { + name: "successful push", + opts: skills.PushOptions{Reference: "my-tag"}, + setup: func(ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store) { + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + d, tagErr := ociStore.PutManifest(t.Context(), []byte(`{"schemaVersion":2}`)) + require.NoError(t, tagErr) + require.NoError(t, ociStore.Tag(t.Context(), d, "my-tag")) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Push(gomock.Any(), ociStore, d, "my-tag").Return(nil) + return reg, ociStore + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + registry, ociStore := tt.setup(ctrl) + + svc := New(&storage.NoopSkillStore{}, + WithRegistryClient(registry), + WithOCIStore(ociStore), + ) + + err := svc.Push(t.Context(), tt.opts) + if tt.wantCode != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestValidateOCITagOrReference(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tag string + wantErr bool + }{ + // Valid bare tags + {name: "simple version", tag: "v1.0.0", wantErr: false}, + {name: "latest", tag: "latest", wantErr: false}, + {name: "numeric", tag: "123", wantErr: false}, + {name: "with dots", tag: "1.2.3", wantErr: false}, + {name: "with hyphens", tag: "my-skill", wantErr: false}, + {name: "with underscores", tag: "my_skill", wantErr: false}, + {name: "mixed alphanumeric", tag: "v1.0.0-rc.1", wantErr: false}, + {name: "uppercase", tag: "MyTag", wantErr: false}, + {name: "single char", tag: "a", wantErr: false}, + {name: "max length 128 chars", tag: strings.Repeat("a", 128), wantErr: false}, + {name: "exceeds max length 129 chars", tag: strings.Repeat("a", 129), wantErr: true}, + + // Valid full OCI references + {name: "ghcr tagged reference", tag: "ghcr.io/stacklok/toolhive-skills/my-skill:v1.0.0", wantErr: false}, + {name: "CI format tag", tag: "ghcr.io/stacklok/toolhive-skills/my-skill:0.0.1-dev.123_abc1234", wantErr: false}, + {name: "docker hub reference", tag: "docker.io/library/nginx:1.25", wantErr: false}, + {name: "localhost with port", tag: "localhost:5000/my-skill:v1", wantErr: false}, + {name: "digest reference", tag: "ghcr.io/org/repo@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", wantErr: false}, + + // Invalid bare tags + {name: "empty string", tag: "", wantErr: true}, + {name: "contains space", tag: "invalid tag", wantErr: true}, + {name: "contains exclamation", tag: "invalid!", wantErr: true}, + {name: "contains hash", tag: "invalid#tag", wantErr: true}, + + // Invalid full references + {name: "space in tag of reference", tag: "ghcr.io/org/repo:invalid tag", wantErr: true}, + {name: "empty tag after colon", tag: "ghcr.io/org/repo:", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateOCITagOrReference(tt.tag) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid OCI reference or tag") + // Verify it returns a proper HTTP status code. + var coded *httperr.CodedError + require.ErrorAs(t, err, &coded) + assert.Equal(t, http.StatusBadRequest, coded.HTTPCode()) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestListBuilds(t *testing.T) { + t.Parallel() + + t.Run("nil oci store returns 500", func(t *testing.T) { + t.Parallel() + svc := New(&storage.NoopSkillStore{}) + _, err := svc.ListBuilds(t.Context()) + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) + }) + + t.Run("empty store returns empty list", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + artifacts, err := svc.ListBuilds(t.Context()) + require.NoError(t, err) + assert.Empty(t, artifacts) + }) + + t.Run("lists tagged artifacts with metadata", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + // Build a real artifact via the packager so extractOCIContent works. + d := buildTestArtifact(t, ociStore, "my-skill", "1.2.3") + require.NoError(t, ociStore.Tag(t.Context(), d, "my-skill")) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + artifacts, err := svc.ListBuilds(t.Context()) + require.NoError(t, err) + require.Len(t, artifacts, 1) + + assert.Equal(t, "my-skill", artifacts[0].Tag) + assert.Contains(t, artifacts[0].Digest, "sha256:") + assert.Equal(t, "my-skill", artifacts[0].Name) + assert.Equal(t, "1.2.3", artifacts[0].Version) + }) + + t.Run("lists multiple tagged artifacts", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + d1 := buildTestArtifact(t, ociStore, "skill-a", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), d1, "skill-a")) + d2 := buildTestArtifact(t, ociStore, "skill-b", "2.0.0") + require.NoError(t, ociStore.Tag(t.Context(), d2, "skill-b")) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + artifacts, err := svc.ListBuilds(t.Context()) + require.NoError(t, err) + assert.Len(t, artifacts, 2) + + // Collect names for assertion (order may vary). + names := make(map[string]string) + for _, a := range artifacts { + names[a.Tag] = a.Version + } + assert.Equal(t, "1.0.0", names["skill-a"]) + assert.Equal(t, "2.0.0", names["skill-b"]) + }) + + t.Run("skill artifact with no extractable metadata still appears", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + // Store an index with ArtifactType set to the skill type but no child manifests — + // extractOCIContent will fail but the artifact should still appear with empty metadata fields. + skillIndex := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","artifactType":"dev.toolhive.skills.v1","manifests":[]}` + d, putErr := ociStore.PutManifest(t.Context(), []byte(skillIndex)) + require.NoError(t, putErr) + require.NoError(t, ociStore.Tag(t.Context(), d, "bare-skill-tag")) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + artifacts, err := svc.ListBuilds(t.Context()) + require.NoError(t, err) + require.Len(t, artifacts, 1) + + assert.Equal(t, "bare-skill-tag", artifacts[0].Tag) + assert.Contains(t, artifacts[0].Digest, "sha256:") + assert.Empty(t, artifacts[0].Name) + assert.Empty(t, artifacts[0].Version) + }) + + t.Run("non-skill artifact is excluded", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + // Store a valid skill artifact that should be returned. + skillDigest := buildTestArtifact(t, ociStore, "real-skill", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), skillDigest, "real-skill")) + + // Store an index whose ArtifactType is not the skill type — simulates a + // remotely-pulled non-skill OCI artifact sharing the same store. + otherIndex := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","artifactType":"application/vnd.docker.distribution.manifest.v2","manifests":[]}` + otherDigest, putErr := ociStore.PutManifest(t.Context(), []byte(otherIndex)) + require.NoError(t, putErr) + require.NoError(t, ociStore.Tag(t.Context(), otherDigest, "non-skill-tag")) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + artifacts, err := svc.ListBuilds(t.Context()) + require.NoError(t, err) + require.Len(t, artifacts, 1) + assert.Equal(t, "real-skill", artifacts[0].Tag) + }) +} + +func TestDeleteBuild(t *testing.T) { + t.Parallel() + + t.Run("nil oci store returns 500", func(t *testing.T) { + t.Parallel() + svc := New(&storage.NoopSkillStore{}) + err := svc.DeleteBuild(t.Context(), "my-skill") + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) + }) + + t.Run("removes tag and blobs", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + d := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), d, "my-skill")) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + require.NoError(t, svc.DeleteBuild(t.Context(), "my-skill")) + + // Tag should be gone — ListBuilds should return empty. + builds, listErr := svc.ListBuilds(t.Context()) + require.NoError(t, listErr) + assert.Empty(t, builds) + }) + + t.Run("tag does not exist returns 404", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + err = svc.DeleteBuild(t.Context(), "nonexistent") + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, httperr.Code(err)) + }) + + t.Run("blobs retained when another tag shares the same digest", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + d := buildTestArtifact(t, ociStore, "shared-skill", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), d, "tag-a")) + require.NoError(t, ociStore.Tag(t.Context(), d, "tag-b")) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + require.NoError(t, svc.DeleteBuild(t.Context(), "tag-a")) + + // tag-b still exists and the shared artifact is accessible. + builds, listErr := svc.ListBuilds(t.Context()) + require.NoError(t, listErr) + require.Len(t, builds, 1) + assert.Equal(t, "tag-b", builds[0].Tag) + }) +} diff --git a/pkg/skills/skillsvc/clients.go b/pkg/skills/skillsvc/clients.go new file mode 100644 index 0000000000..64d2ff43d0 --- /dev/null +++ b/pkg/skills/skillsvc/clients.go @@ -0,0 +1,224 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "errors" + "fmt" + "net/http" + "path/filepath" + "slices" + "strings" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/client" + "github.com/stacklok/toolhive/pkg/skills" +) + +// clientsAllSentinel is the reserved value that expands to all skill-supporting clients. +const clientsAllSentinel = "all" + +// resolveAndValidateClients returns the deduplicated client list and a map of +// client identifier to install directory. Empty opts.Clients (or the sentinel +// value "all") expands to every skill-supporting client returned by the path resolver. +func (s *service) resolveAndValidateClients( + opts skills.InstallOptions, + skillName string, + scope skills.Scope, + projectRoot string, +) ([]string, map[string]string, error) { + if s.pathResolver == nil { + return nil, nil, httperr.WithCode( + fmt.Errorf("path resolver is required for skill installs"), + http.StatusInternalServerError, + ) + } + + var requested []string + switch { + case len(opts.Clients) == 0 || (len(opts.Clients) == 1 && strings.EqualFold(opts.Clients[0], clientsAllSentinel)): + clients := s.pathResolver.ListSkillSupportingClients() + if len(clients) == 0 { + return nil, nil, httperr.WithCode( + errors.New("no supported clients detected on this system; "+ + "use --clients to target a specific client explicitly"), + http.StatusBadRequest, + ) + } + requested = clients + default: + for _, c := range opts.Clients { + if c == "" { + return nil, nil, httperr.WithCode( + errors.New("clients entries must be non-empty strings"), + http.StatusBadRequest, + ) + } + if strings.EqualFold(c, clientsAllSentinel) { + return nil, nil, httperr.WithCode( + fmt.Errorf("%q cannot be combined with other client names", clientsAllSentinel), + http.StatusBadRequest, + ) + } + } + requested = dedupeStringsPreserveOrder(opts.Clients) + } + + paths := make(map[string]string, len(requested)) + for _, ct := range requested { + dir, err := s.pathResolver.GetSkillPath(ct, skillName, scope, projectRoot) + if err != nil { + if errors.Is(err, client.ErrUnsupportedClientType) || errors.Is(err, client.ErrSkillsNotSupported) { + return nil, nil, httperr.WithCode( + fmt.Errorf("invalid client %q: %w", ct, err), + http.StatusBadRequest, + ) + } + return nil, nil, fmt.Errorf("resolving skill path for client %q: %w", ct, err) + } + dir = filepath.Clean(dir) + if err := validateResolvedDir(dir); err != nil { + return nil, nil, fmt.Errorf("resolved path for client %q is unsafe: %w", ct, err) + } + paths[ct] = dir + } + return requested, paths, nil +} + +// expandToExistingClients merges existingClients into requestedClients and +// resolves paths for any existing client not already in clientDirs. This +// ensures upgrades write new files to all clients, not just the requested set. +func (s *service) expandToExistingClients( + existingClients, requestedClients []string, + clientDirs map[string]string, + skillName string, scope skills.Scope, projectRoot string, +) ([]string, map[string]string, error) { + allClients := mergeClientLists(requestedClients, existingClients) + allDirs := make(map[string]string, len(allClients)) + for k, v := range clientDirs { + allDirs[k] = v + } + for _, ct := range allClients { + if _, ok := allDirs[ct]; ok { + continue + } + dir, err := s.pathResolver.GetSkillPath(ct, skillName, scope, projectRoot) + if err != nil { + return nil, nil, fmt.Errorf("resolving skill path for existing client %q: %w", ct, err) + } + dir = filepath.Clean(dir) + if err := validateResolvedDir(dir); err != nil { + return nil, nil, fmt.Errorf("resolved path for client %q is unsafe: %w", ct, err) + } + allDirs[ct] = dir + } + return allClients, allDirs, nil +} + +// validateResolvedDir ensures a directory path is absolute and free of +// path-traversal segments. Callers must pass a filepath.Clean'd value. +func validateResolvedDir(dir string) error { + if !filepath.IsAbs(dir) { + return fmt.Errorf("path must be absolute, got %q", dir) + } + for _, seg := range strings.Split(filepath.ToSlash(dir), "/") { + if seg == ".." { + return fmt.Errorf("path contains traversal segment: %q", dir) + } + } + return nil +} + +func dedupeStringsPreserveOrder(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, s := range in { + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + +// clientsContainAll reports whether every value in requested appears in existing. +func clientsContainAll(existing, requested []string) bool { + for _, r := range requested { + if !slices.Contains(existing, r) { + return false + } + } + return true +} + +// mergeClientLists returns existing followed by any requested entries not already present. +func mergeClientLists(existing, requested []string) []string { + out := make([]string, len(existing)) + copy(out, existing) + seen := make(map[string]struct{}, len(existing)+len(requested)) + for _, c := range existing { + seen[c] = struct{}{} + } + for _, c := range requested { + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + out = append(out, c) + } + if len(out) == 0 { + return nil + } + return out +} + +func missingClients(existing, requested []string) []string { + var out []string + for _, ct := range requested { + if !slices.Contains(existing, ct) { + out = append(out, ct) + } + } + return out +} + +// uniqueDirClients returns the subset of clients whose resolved directory is +// unique. When multiple clients share the same path (e.g. vscode and +// vscode-insider both using ~/.copilot/skills), only the first is returned. +// This prevents double-extraction while still recording all clients in the DB. +// +// occupiedDirs is pre-seeded into the seen set so that new clients whose +// directory is already owned by an existing installed client are also skipped. +// Pass nil when there are no pre-existing directories to exclude. +func uniqueDirClients(clients []string, clientDirs map[string]string, occupiedDirs map[string]struct{}) []string { + seen := make(map[string]struct{}, len(clients)+len(occupiedDirs)) + for dir := range occupiedDirs { + seen[dir] = struct{}{} + } + out := make([]string, 0, len(clients)) + for _, ct := range clients { + dir := filepath.Clean(clientDirs[ct]) + if _, ok := seen[dir]; ok { + continue + } + seen[dir] = struct{}{} + out = append(out, ct) + } + return out +} + +// existingClientDirs builds the set of directories already occupied by the +// given installed clients. Used to seed uniqueDirClients so that new clients +// sharing a directory with an existing client are skipped rather than +// triggering a false "directory exists" conflict. +func existingClientDirs(existing []string, clientDirs map[string]string) map[string]struct{} { + dirs := make(map[string]struct{}, len(existing)) + for _, ct := range existing { + if dir, ok := clientDirs[ct]; ok { + dirs[filepath.Clean(dir)] = struct{}{} + } + } + return dirs +} diff --git a/pkg/skills/skillsvc/content.go b/pkg/skills/skillsvc/content.go new file mode 100644 index 0000000000..06f0c64e28 --- /dev/null +++ b/pkg/skills/skillsvc/content.go @@ -0,0 +1,212 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "errors" + "fmt" + "net/http" + "path/filepath" + "strings" + + "github.com/stacklok/toolhive-core/httperr" + ociskills "github.com/stacklok/toolhive-core/oci/skills" + "github.com/stacklok/toolhive/pkg/skills" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" +) + +// GetContent retrieves the SKILL.md body and file listing from a skill artifact +// without installing it. The reference may be: +// - A local build tag (e.g. "my-skill") +// - A fully-qualified OCI reference (e.g. "ghcr.io/org/skill:v1") +// - A git:// reference (e.g. "git://github.com/org/repo#path/to/skill") +// - An https:// URL (converted to git:// internally) +// +// Resolution order: git (git:// and https://) → OCI (local store, then remote +// pull) → registry catalog lookup. +func (s *service) GetContent(ctx context.Context, opts skills.ContentOptions) (*skills.SkillContent, error) { + ref := opts.Reference + if ref == "" { + return nil, httperr.WithCode( + errors.New("reference is required"), + http.StatusBadRequest, + ) + } + + // Git references (git:// or https://) are dispatched first since their + // scheme prefix is unambiguous and cannot collide with OCI references. + if gitresolver.IsGitReference(ref) { + return s.getContentFromGit(ctx, ref) + } + if isHTTPURL(ref) { + gitURL, err := buildGitReferenceFromRegistryURL(ref) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("invalid URL %q: %w", ref, err), + http.StatusBadRequest, + ) + } + return s.getContentFromGit(ctx, gitURL) + } + + // Try OCI resolution (local store + remote pull). If this succeeds, return. + content, ociErr := s.getContentFromOCI(ctx, ref) + if ociErr == nil { + return content, nil + } + + // OCI failed — try resolving via registry name lookup (e.g. "skill-creator" + // or "io.github.stacklok/skill-creator" from the catalog index). Skip for + // refs that are unambiguously OCI (digest, explicit tag, or multi-segment + // path) to avoid a wasted network round-trip. + if parsedRef, isOCI, parseErr := parseOCIReference(ref); parseErr == nil && isOCI && + isUnambiguousOCIRef(ref, parsedRef) { + return nil, ociErr + } + resolved, regErr := s.resolveFromRegistry(ref) + if regErr != nil { + return nil, regErr + } + if resolved != nil { + switch { + case resolved.OCIRef != nil: + return s.getContentFromOCI(ctx, resolved.OCIRef.String()) + case resolved.GitURL != "": + return s.getContentFromGit(ctx, resolved.GitURL) + } + } + + // Nothing matched — return the original OCI error. + return nil, ociErr +} + +// getContentFromGit clones a git repository and extracts the SKILL.md content. +func (s *service) getContentFromGit(ctx context.Context, ref string) (*skills.SkillContent, error) { + if s.gitResolver == nil { + return nil, httperr.WithCode( + errors.New("git resolver is not configured"), + http.StatusInternalServerError, + ) + } + + gitRef, err := gitresolver.ParseGitReference(ref) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("invalid git reference: %w", err), + http.StatusBadRequest, + ) + } + + resolved, err := s.gitResolver.Resolve(ctx, gitRef) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("resolving git skill: %w", err), + http.StatusBadGateway, + ) + } + + content := &skills.SkillContent{ + Name: resolved.SkillConfig.Name, + Description: resolved.SkillConfig.Description, + Version: resolved.SkillConfig.Version, + License: resolved.SkillConfig.License, + Body: string(resolved.SkillConfig.Body), + Files: make([]skills.SkillFileEntry, 0, len(resolved.Files)), + } + + for _, f := range resolved.Files { + content.Files = append(content.Files, skills.SkillFileEntry{ + Path: f.Path, + Size: len(f.Content), + }) + } + + return content, nil +} + +// getContentFromOCI resolves a reference from the local OCI store or pulls it +// from a remote registry, then extracts the SKILL.md content. +func (s *service) getContentFromOCI(ctx context.Context, ref string) (*skills.SkillContent, error) { + if s.ociStore == nil { + return nil, httperr.WithCode( + errors.New("OCI store is not configured"), + http.StatusInternalServerError, + ) + } + + // Try the local store first (covers local builds by tag name and + // previously pulled remote refs tagged by Pull). + d, resolveErr := s.ociStore.Resolve(ctx, ref) + if resolveErr != nil { + if s.registry == nil { + return nil, httperr.WithCode( + fmt.Errorf("reference %q not found in local store and OCI registry is not configured", ref), + http.StatusBadRequest, + ) + } + + ociRef, isOCI, parseErr := parseOCIReference(ref) + if parseErr != nil { + return nil, httperr.WithCode( + fmt.Errorf("invalid reference %q: %w", ref, parseErr), + http.StatusBadRequest, + ) + } + if !isOCI { + return nil, httperr.WithCode( + fmt.Errorf("reference %q not found in local store and is not a valid OCI reference", ref), + http.StatusBadRequest, + ) + } + + qualifiedRef := qualifiedOCIRef(ociRef) + pullCtx, cancel := context.WithTimeout(ctx, ociPullTimeout) + defer cancel() + + var pullErr error + d, pullErr = s.registry.Pull(pullCtx, s.ociStore, qualifiedRef) + if pullErr != nil { + return nil, httperr.WithCode( + fmt.Errorf("pulling OCI artifact %q: %w", qualifiedRef, pullErr), + http.StatusBadRequest, + ) + } + } + + layerData, skillConfig, err := s.extractOCIContent(ctx, d) + if err != nil { + return nil, err + } + + entries, err := ociskills.DecompressTar(layerData) + if err != nil { + return nil, fmt.Errorf("decompressing skill layer: %w", err) + } + + content := &skills.SkillContent{ + Name: skillConfig.Name, + Description: skillConfig.Description, + Version: skillConfig.Version, + License: skillConfig.License, + Files: make([]skills.SkillFileEntry, 0, len(entries)), + } + + for _, entry := range entries { + content.Files = append(content.Files, skills.SkillFileEntry{ + Path: entry.Path, + Size: len(entry.Content), + }) + if strings.EqualFold(filepath.Base(entry.Path), "SKILL.md") { + content.Body = string(entry.Content) + } + } + + return content, nil +} + +// isHTTPURL returns true if the reference starts with http:// or https://. +func isHTTPURL(ref string) bool { + return strings.HasPrefix(ref, "https://") || strings.HasPrefix(ref, "http://") +} diff --git a/pkg/skills/skillsvc/content_test.go b/pkg/skills/skillsvc/content_test.go new file mode 100644 index 0000000000..c68406424e --- /dev/null +++ b/pkg/skills/skillsvc/content_test.go @@ -0,0 +1,291 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "strings" + "testing" + + godigest "github.com/opencontainers/go-digest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + ociskills "github.com/stacklok/toolhive-core/oci/skills" + ocimocks "github.com/stacklok/toolhive-core/oci/skills/mocks" + regtypes "github.com/stacklok/toolhive-core/registry/types" + regmocks "github.com/stacklok/toolhive/pkg/registry/mocks" + "github.com/stacklok/toolhive/pkg/skills" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" + gitmocks "github.com/stacklok/toolhive/pkg/skills/gitresolver/mocks" + "github.com/stacklok/toolhive/pkg/storage" +) + +func TestGetContent(t *testing.T) { + t.Parallel() + + t.Run("nil oci store returns 500", func(t *testing.T) { + t.Parallel() + svc := New(&storage.NoopSkillStore{}) + _, err := svc.GetContent(t.Context(), skills.ContentOptions{Reference: "my-skill"}) + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) + }) + + t.Run("empty reference returns 400", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: ""}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("local build tag resolves without registry", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + d := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), d, "my-skill")) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + content, err := svc.GetContent(t.Context(), skills.ContentOptions{Reference: "my-skill"}) + require.NoError(t, err) + + assert.Equal(t, "my-skill", content.Name) + assert.Equal(t, "1.0.0", content.Version) + assert.NotEmpty(t, content.Body) + assert.NotEmpty(t, content.Files) + + // SKILL.md must appear in the file list. + var skillMDFound bool + for _, f := range content.Files { + if strings.EqualFold(filepath.Base(f.Path), "SKILL.md") { + skillMDFound = true + assert.Greater(t, f.Size, 0) + } + } + assert.True(t, skillMDFound, "SKILL.md should be listed in Files") + }) + + t.Run("remote OCI reference triggers pull", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + indexDigest := buildTestArtifact(t, ociStore, "my-skill", "2.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v2"). + Return(indexDigest, nil) + + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithRegistryClient(reg), + ) + content, err := svc.GetContent(t.Context(), skills.ContentOptions{ + Reference: "ghcr.io/org/my-skill:v2", + }) + require.NoError(t, err) + + assert.Equal(t, "my-skill", content.Name) + assert.Equal(t, "2.0.0", content.Version) + assert.NotEmpty(t, content.Body) + }) + + t.Run("unqualified name not in store without registry returns 400", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: "nonexistent"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("nil registry with unresolvable remote ref returns 400", func(t *testing.T) { + t.Parallel() + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + // "ghcr.io/org/skill:v1" is a valid OCI ref but registry is nil. + svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) + _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: "ghcr.io/org/skill:v1"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("pull failure propagates as 400", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). + Return(godigest.Digest(""), fmt.Errorf("registry unreachable")) + + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithRegistryClient(reg), + ) + _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: "ghcr.io/org/my-skill:v1"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + assert.Contains(t, err.Error(), "registry unreachable") + }) + + t.Run("git reference resolves via git resolver", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{ + Name: "my-skill", + Description: "a git skill", + Version: "1.0.0", + Body: []byte("# My Skill\nHello from git"), + }, + Files: []gitresolver.FileEntry{ + {Path: "SKILL.md", Content: []byte("# My Skill\nHello from git"), Mode: 0644}, + {Path: "hooks.sh", Content: []byte("#!/bin/sh"), Mode: 0644}, + }, + CommitHash: testCommitHash, + }, nil) + + svc := New(&storage.NoopSkillStore{}, WithGitResolver(gr)) + content, err := svc.GetContent(t.Context(), skills.ContentOptions{ + Reference: "git://github.com/test/my-skill#skills/my-skill", + }) + require.NoError(t, err) + assert.Equal(t, "my-skill", content.Name) + assert.Equal(t, "a git skill", content.Description) + assert.Equal(t, "1.0.0", content.Version) + assert.Contains(t, content.Body, "Hello from git") + assert.Len(t, content.Files, 2) + }) + + t.Run("git resolve failure returns 502", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("clone failed")) + + svc := New(&storage.NoopSkillStore{}, WithGitResolver(gr)) + _, err := svc.GetContent(t.Context(), skills.ContentOptions{ + Reference: "git://github.com/test/my-skill", + }) + require.Error(t, err) + assert.Equal(t, http.StatusBadGateway, httperr.Code(err)) + assert.Contains(t, err.Error(), "resolving git skill") + }) + + t.Run("nil git resolver returns 500", func(t *testing.T) { + t.Parallel() + svc := &service{} + _, err := svc.GetContent(t.Context(), skills.ContentOptions{ + Reference: "git://github.com/test/my-skill", + }) + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) + }) + + t.Run("registry name falls back to git resolver", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("skill-creator").Return([]regtypes.Skill{ + { + Namespace: "io.github.stacklok", + Name: "skill-creator", + Packages: []regtypes.SkillPackage{ + { + RegistryType: "git", + URL: "https://github.com/stacklok/toolhive-catalog", + Subfolder: "registries/toolhive/skills/skill-creator", + }, + }, + }, + }, nil) + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, ref *gitresolver.GitReference) (*gitresolver.ResolveResult, error) { + assert.Equal(t, "registries/toolhive/skills/skill-creator", ref.Path) + return &gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{ + Name: "skill-creator", + Description: "creates skills", + Body: []byte("# Skill Creator"), + }, + Files: []gitresolver.FileEntry{ + {Path: "SKILL.md", Content: []byte("# Skill Creator"), Mode: 0644}, + }, + CommitHash: testCommitHash, + }, nil + }) + + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithSkillLookup(lookup), + WithGitResolver(gr), + ) + content, err := svc.GetContent(t.Context(), skills.ContentOptions{ + Reference: "io.github.stacklok/skill-creator", + }) + require.NoError(t, err) + assert.Equal(t, "skill-creator", content.Name) + assert.Contains(t, content.Body, "Skill Creator") + }) + + t.Run("qualified namespace/name filters registry matches", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + ociStore, err := ociskills.NewStore(t.TempDir()) + require.NoError(t, err) + + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + {Namespace: "io.github.alice", Name: "my-skill", + Packages: []regtypes.SkillPackage{{RegistryType: "git", URL: "https://github.com/alice/repo"}}}, + {Namespace: "io.github.bob", Name: "my-skill", + Packages: []regtypes.SkillPackage{{RegistryType: "git", URL: "https://github.com/bob/repo"}}}, + }, nil) + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{Name: "my-skill", Body: []byte("# Bob's skill")}, + Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("# Bob"), Mode: 0644}}, + CommitHash: testCommitHash, + }, nil) + + svc := New(&storage.NoopSkillStore{}, + WithOCIStore(ociStore), + WithSkillLookup(lookup), + WithGitResolver(gr), + ) + content, err := svc.GetContent(t.Context(), skills.ContentOptions{ + Reference: "io.github.bob/my-skill", + }) + require.NoError(t, err) + assert.Equal(t, "my-skill", content.Name) + }) +} diff --git a/pkg/skills/skillsvc/info_test.go b/pkg/skills/skillsvc/info_test.go new file mode 100644 index 0000000000..171cdffb91 --- /dev/null +++ b/pkg/skills/skillsvc/info_test.go @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/skills" + "github.com/stacklok/toolhive/pkg/storage" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestInfo(t *testing.T) { + t.Parallel() + + projectRoot := makeProjectRoot(t) + + installed := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill", Version: "1.0.0"}, + Scope: skills.ScopeUser, + Status: skills.InstallStatusInstalled, + } + + tests := []struct { + name string + opts skills.InfoOptions + setupMock func(*storemocks.MockSkillStore) + wantCode int + wantErr string + }{ + { + name: "found skill", + opts: skills.InfoOptions{Name: "my-skill"}, + setupMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(installed, nil) + }, + }, + { + name: "not found returns 404", + opts: skills.InfoOptions{Name: "unknown"}, + setupMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().Get(gomock.Any(), "unknown", skills.ScopeUser, ""). + Return(skills.InstalledSkill{}, storage.ErrNotFound) + }, + wantCode: http.StatusNotFound, + }, + { + name: "propagates store errors", + opts: skills.InfoOptions{Name: "my-skill"}, + setupMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, ""). + Return(skills.InstalledSkill{}, fmt.Errorf("db error")) + }, + wantErr: "db error", + }, + { + name: "rejects invalid name", + opts: skills.InfoOptions{Name: "X"}, + setupMock: func(_ *storemocks.MockSkillStore) {}, + wantCode: http.StatusBadRequest, + }, + { + name: "rejects empty name", + opts: skills.InfoOptions{Name: ""}, + setupMock: func(_ *storemocks.MockSkillStore) {}, + wantCode: http.StatusBadRequest, + }, + { + name: "respects project scope", + opts: skills.InfoOptions{Name: "my-skill", Scope: skills.ScopeProject, ProjectRoot: projectRoot}, + setupMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeProject, projectRoot).Return(installed, nil) + }, + }, + { + name: "project scope missing project root", + opts: skills.InfoOptions{Name: "my-skill", Scope: skills.ScopeProject}, + setupMock: func(_ *storemocks.MockSkillStore) {}, + wantCode: http.StatusBadRequest, + }, + { + name: "defaults to user scope when empty", + opts: skills.InfoOptions{Name: "my-skill", Scope: ""}, + setupMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(installed, nil) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + tt.setupMock(store) + + info, err := New(store).Info(t.Context(), tt.opts) + if tt.wantCode != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + require.NotNil(t, info.InstalledSkill) + assert.Equal(t, "my-skill", info.InstalledSkill.Metadata.Name) + }) + } +} diff --git a/pkg/skills/skillsvc/install.go b/pkg/skills/skillsvc/install.go new file mode 100644 index 0000000000..7abb19e741 --- /dev/null +++ b/pkg/skills/skillsvc/install.go @@ -0,0 +1,219 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "fmt" + "log/slog" + "net/http" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/skills" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" +) + +// Install installs a skill. When the Name field contains an OCI reference +// (detected by the presence of '/', ':', or '@'), the artifact is pulled from +// the registry and extracted. When LayerData is provided, the skill is extracted +// to disk and a full installation record is created. Without LayerData, a +// pending record is created. +func (s *service) Install(ctx context.Context, opts skills.InstallOptions) (*skills.InstallResult, error) { + scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) + if err != nil { + return nil, err + } + scope = defaultScope(scope) + // Canonicalize the project root so that equivalent paths produce + // the same lock key and DB record. + opts.ProjectRoot = projectRoot + + // Git references (git://host/owner/repo[@ref][#path]) are dispatched first; + // the prefix is unambiguous and cannot collide with OCI references. + if gitresolver.IsGitReference(opts.Name) { + result, err := s.installFromGit(ctx, opts, scope) + if err != nil { + return nil, err + } + return s.installAndRegister(ctx, result, opts.Group, result.Skill.Metadata.Name, scope, opts.ProjectRoot) + } + + ref, isOCI, err := parseOCIReference(opts.Name) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("invalid OCI reference %q: %w", opts.Name, err), + http.StatusBadRequest, + ) + } + if isOCI { + result, ociErr := s.installFromOCI(ctx, opts, scope, ref) + if ociErr == nil { + return s.installAndRegister(ctx, result, opts.Group, opts.Name, scope, opts.ProjectRoot) + } + // OCI pull failed — fall back to registry lookup for names that look + // like a qualified "namespace/name". Names that are unambiguously OCI + // (digest, explicit tag, or multi-segment path) must not trigger a + // registry search. See isUnambiguousOCIRef for the full rule set. + if isUnambiguousOCIRef(opts.Name, ref) { + return nil, ociErr + } + slog.Debug("OCI pull failed, attempting registry fallback", "name", opts.Name, "error", ociErr) + resolved, regErr := s.resolveFromRegistry(opts.Name) + if regErr != nil { + return nil, regErr + } + if resolved != nil { + return s.installFromResolvedRegistry(ctx, opts, scope, resolved) + } + return nil, ociErr + } + + // Plain skill name — validate and proceed with existing flow. + if err := skills.ValidateSkillName(opts.Name); err != nil { + return nil, httperr.WithCode(err, http.StatusBadRequest) + } + + return s.installByName(ctx, opts, scope) +} + +// installByName handles installation for a validated plain skill name. It +// checks the local OCI store and registry before falling back to an error. +func (s *service) installByName( + ctx context.Context, + opts skills.InstallOptions, + scope skills.Scope, +) (*skills.InstallResult, error) { + unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) + locked := true + defer func() { + if locked { + unlock() + } + }() + + // Without layer data, check the local OCI store for a matching tag, + // then the registry/index, before returning an error. + if len(opts.LayerData) == 0 { + resolved := false + if s.ociStore != nil { + var resolveErr error + // Pass pointer to hydrate opts with layer data, digest, and version. + resolved, resolveErr = s.resolveFromLocalStore(ctx, &opts) + if resolveErr != nil { + return nil, resolveErr + } + } + if !resolved { + // Release lock before registry lookup -- installFromOCI + // acquires its own lock on the artifact's skill name, which + // could be the same key, causing deadlock since sync.Mutex + // is not re-entrant. + unlock() + locked = false + + return s.installFromRegistryLookup(ctx, opts, scope) + } + // resolved: opts hydrated, fall through to installWithExtraction + } + + result, err := s.installWithExtraction(ctx, opts, scope) + if err != nil { + return nil, err + } + return s.installAndRegister(ctx, result, opts.Group, opts.Name, scope, opts.ProjectRoot) +} + +// installFromRegistryLookup resolves a plain skill name via the registry and +// dispatches to the appropriate installer (OCI or git). +func (s *service) installFromRegistryLookup( + ctx context.Context, + opts skills.InstallOptions, + scope skills.Scope, +) (*skills.InstallResult, error) { + resolved, regErr := s.resolveFromRegistry(opts.Name) + if regErr != nil { + return nil, regErr + } + if resolved != nil { + return s.installFromResolvedRegistry(ctx, opts, scope, resolved) + } + + return nil, httperr.WithCode( + fmt.Errorf("skill %q not found in local store or registry;"+ + " install by OCI reference:\n thv skill install ghcr.io/<namespace>/%s:<version>", + opts.Name, opts.Name), + http.StatusNotFound, + ) +} + +// installFromResolvedRegistry dispatches an install to the appropriate +// backend (OCI or git) based on the result of a registry lookup. +func (s *service) installFromResolvedRegistry( + ctx context.Context, + opts skills.InstallOptions, + scope skills.Scope, + resolved *registryResolveResult, +) (*skills.InstallResult, error) { + switch { + case resolved.OCIRef != nil: + slog.Info("resolved skill from registry (OCI)", "name", opts.Name, "oci_reference", resolved.OCIRef.String()) + opts.Name = resolved.OCIRef.String() + result, ociErr := s.installFromOCI(ctx, opts, scope, resolved.OCIRef) + if ociErr != nil { + return nil, ociErr + } + // Use the skill name extracted from the artifact, not opts.Name which + // holds the OCI ref string. installFromOCI mutates its own copy of opts + // (Go pass-by-value), so the caller never sees the updated name. + return s.installAndRegister(ctx, result, opts.Group, result.Skill.Metadata.Name, scope, opts.ProjectRoot) + case resolved.GitURL != "": + slog.Info("resolved skill from registry (git)", "name", opts.Name, "git_url", resolved.GitURL) + opts.Name = resolved.GitURL + result, gitErr := s.installFromGit(ctx, opts, scope) + if gitErr != nil { + return nil, gitErr + } + return s.installAndRegister(ctx, result, opts.Group, result.Skill.Metadata.Name, scope, opts.ProjectRoot) + } + return nil, httperr.WithCode( + fmt.Errorf("skill %q resolved from registry but has no installable package", opts.Name), + http.StatusUnprocessableEntity, + ) +} + +// registerSkillInGroup adds the skill to the requested group when a group +// manager is configured. When groupName is empty it defaults to the +// "default" group, matching workload behavior. +func (s *service) registerSkillInGroup(ctx context.Context, groupName string, skillName string) error { + if s.groupManager == nil { + return nil + } + if groupName == "" { + groupName = groups.DefaultGroup + } + return groups.AddSkillToGroup(ctx, s.groupManager, groupName, skillName) +} + +// installAndRegister registers the just-installed skill in the target group. +// If group registration fails, the DB record is rolled back so that a retry +// starts fresh rather than leaving the system in an inconsistent state (skill +// installed but not in the expected group). +func (s *service) installAndRegister( + ctx context.Context, + result *skills.InstallResult, + groupName string, + skillName string, + scope skills.Scope, + projectRoot string, +) (*skills.InstallResult, error) { + if err := s.registerSkillInGroup(ctx, groupName, skillName); err != nil { + // Best-effort rollback: remove the DB record so retries start fresh. + // Files on disk are left in place; a fresh install will detect them + // and either overwrite (force) or return a conflict. + _ = s.store.Delete(ctx, skillName, scope, projectRoot) + return nil, fmt.Errorf("registering skill in group: %w", err) + } + return result, nil +} diff --git a/pkg/skills/skillsvc/install_extraction.go b/pkg/skills/skillsvc/install_extraction.go new file mode 100644 index 0000000000..60a4da681b --- /dev/null +++ b/pkg/skills/skillsvc/install_extraction.go @@ -0,0 +1,210 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/skills" + "github.com/stacklok/toolhive/pkg/storage" +) + +// installWithExtraction handles the full install flow: managed/unmanaged +// detection, extraction, and DB record creation or update. +func (s *service) installWithExtraction( + ctx context.Context, opts skills.InstallOptions, scope skills.Scope, +) (*skills.InstallResult, error) { + clientTypes, clientDirs, err := s.resolveAndValidateClients(opts, opts.Name, scope, opts.ProjectRoot) + if err != nil { + return nil, err + } + + existing, storeErr := s.store.Get(ctx, opts.Name, scope, opts.ProjectRoot) + isNotFound := errors.Is(storeErr, storage.ErrNotFound) + if storeErr != nil && !isNotFound { + return nil, fmt.Errorf("checking existing skill: %w", storeErr) + } + + if isExtractionNoOp(existing, storeErr, opts, clientTypes) { + return &skills.InstallResult{Skill: existing}, nil + } + + digestMatches := storeErr == nil && existing.Digest == opts.Digest + if digestMatches && storeErr == nil { + return s.installExtractionSameDigestNewClients(ctx, opts, scope, existing, clientTypes, clientDirs) + } + + if storeErr == nil { + return s.installExtractionUpgradeDigest(ctx, opts, scope, existing, clientTypes, clientDirs) + } + + return s.installExtractionFresh(ctx, opts, scope, clientTypes, clientDirs) +} + +// isExtractionNoOp reports whether the install can be short-circuited because +// the same digest and all requested clients are already present. Legacy store +// rows (empty Clients slice) are treated as satisfied only when the user did +// not explicitly specify --clients. +func isExtractionNoOp(existing skills.InstalledSkill, storeErr error, opts skills.InstallOptions, clientTypes []string) bool { + if storeErr != nil || existing.Digest != opts.Digest { + return false + } + if clientsContainAll(existing.Clients, clientTypes) { + return true + } + return len(existing.Clients) == 0 && len(clientTypes) <= 1 && len(opts.Clients) == 0 +} + +func (s *service) installExtractionSameDigestNewClients( + ctx context.Context, + opts skills.InstallOptions, + scope skills.Scope, + existing skills.InstalledSkill, + clientTypes []string, + clientDirs map[string]string, +) (*skills.InstallResult, error) { + toWrite := missingClients(existing.Clients, clientTypes) + if len(toWrite) == 0 { + return &skills.InstallResult{Skill: existing}, nil + } + // Deduplicate and skip directories already owned by existing clients. + dirsToWrite := uniqueDirClients(toWrite, clientDirs, existingClientDirs(existing.Clients, clientDirs)) + if len(dirsToWrite) == 0 { + // All new clients share directories with existing ones — no-op. + sk := buildInstalledSkill(opts, scope, clientTypes, existing.Clients) + if err := s.store.Update(ctx, sk); err != nil { + return nil, err + } + return &skills.InstallResult{Skill: sk}, nil + } + var written []string + for _, ct := range dirsToWrite { + dir := filepath.Clean(clientDirs[ct]) + if _, statErr := os.Stat(dir); statErr == nil && !opts.Force { // lgtm[go/path-injection] + removeSkillDirs(s.installer, clientDirs, written) + return nil, httperr.WithCode( + fmt.Errorf("directory %q exists but is not managed by ToolHive; use force to overwrite", dir), + http.StatusConflict, + ) + } + if _, exErr := s.installer.Extract(opts.LayerData, dir, opts.Force); exErr != nil { + removeSkillDirs(s.installer, clientDirs, written) + return nil, fmt.Errorf("extracting skill: %w", exErr) + } + written = append(written, ct) + } + sk := buildInstalledSkill(opts, scope, clientTypes, existing.Clients) + if err := s.store.Update(ctx, sk); err != nil { + removeSkillDirs(s.installer, clientDirs, written) + return nil, err + } + return &skills.InstallResult{Skill: sk}, nil +} + +func removeSkillDirs(inst skills.Installer, clientDirs map[string]string, clients []string) { + for _, ct := range clients { + _ = inst.Remove(filepath.Clean(clientDirs[ct])) + } +} + +func (s *service) installExtractionUpgradeDigest( + ctx context.Context, + opts skills.InstallOptions, + scope skills.Scope, + existing skills.InstalledSkill, + clientTypes []string, + clientDirs map[string]string, +) (*skills.InstallResult, error) { + allClients, allDirs, err := s.expandToExistingClients( + existing.Clients, clientTypes, clientDirs, opts.Name, scope, opts.ProjectRoot) + if err != nil { + return nil, err + } + // Deduplicate so clients sharing the same directory don't conflict. + dirsToWrite := uniqueDirClients(allClients, allDirs, nil) + var written []string + for _, ct := range dirsToWrite { + dir := filepath.Clean(allDirs[ct]) + if _, exErr := s.installer.Extract(opts.LayerData, dir, true); exErr != nil { + removeSkillDirs(s.installer, allDirs, written) + return nil, fmt.Errorf("extracting skill upgrade: %w", exErr) + } + written = append(written, ct) + } + sk := buildInstalledSkill(opts, scope, allClients, nil) + if err := s.store.Update(ctx, sk); err != nil { + removeSkillDirs(s.installer, allDirs, dirsToWrite) + return nil, err + } + return &skills.InstallResult{Skill: sk}, nil +} + +func (s *service) installExtractionFresh( + ctx context.Context, + opts skills.InstallOptions, + scope skills.Scope, + clientTypes []string, + clientDirs map[string]string, +) (*skills.InstallResult, error) { + // Deduplicate so clients sharing the same directory don't conflict. + dirsToWrite := uniqueDirClients(clientTypes, clientDirs, nil) + + for _, ct := range dirsToWrite { + dir := filepath.Clean(clientDirs[ct]) + if _, statErr := os.Stat(dir); statErr == nil && !opts.Force { // lgtm[go/path-injection] + return nil, httperr.WithCode( + fmt.Errorf("directory %q exists but is not managed by ToolHive; use force to overwrite", dir), + http.StatusConflict, + ) + } + } + var written []string + for _, ct := range dirsToWrite { + dir := filepath.Clean(clientDirs[ct]) + if _, exErr := s.installer.Extract(opts.LayerData, dir, opts.Force); exErr != nil { + removeSkillDirs(s.installer, clientDirs, written) + return nil, fmt.Errorf("extracting skill: %w", exErr) + } + written = append(written, ct) + } + sk := buildInstalledSkill(opts, scope, clientTypes, nil) + if err := s.store.Create(ctx, sk); err != nil { + removeSkillDirs(s.installer, clientDirs, dirsToWrite) + return nil, err + } + return &skills.InstallResult{Skill: sk}, nil +} + +// buildInstalledSkill constructs an InstalledSkill from install options. +// requestedClientTypes is the set of clients targeted by this install; they +// are merged with existingClients for the persisted Clients field. +func buildInstalledSkill( + opts skills.InstallOptions, + scope skills.Scope, + requestedClientTypes []string, + existingClients []string, +) skills.InstalledSkill { + clients := mergeClientLists(existingClients, requestedClientTypes) + + return skills.InstalledSkill{ + Metadata: skills.SkillMetadata{ + Name: opts.Name, + Version: opts.Version, + }, + Scope: scope, + ProjectRoot: opts.ProjectRoot, + Reference: opts.Reference, + Digest: opts.Digest, + Status: skills.InstallStatusInstalled, + InstalledAt: time.Now().UTC(), + Clients: clients, + } +} diff --git a/pkg/skills/skillsvc/install_git.go b/pkg/skills/skillsvc/install_git.go new file mode 100644 index 0000000000..9a9bb672bc --- /dev/null +++ b/pkg/skills/skillsvc/install_git.go @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/skills" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" + "github.com/stacklok/toolhive/pkg/storage" +) + +// installFromGit clones a git repository, extracts the skill, writes files to +// disk, and creates a DB record. The digest is the git commit hash, enabling +// same-commit no-op and upgrade detection. +func (s *service) installFromGit( + ctx context.Context, + opts skills.InstallOptions, + scope skills.Scope, +) (*skills.InstallResult, error) { + if s.gitResolver == nil { + return nil, httperr.WithCode( + errors.New("git resolver is not configured"), + http.StatusInternalServerError, + ) + } + if s.pathResolver == nil { + return nil, httperr.WithCode( + errors.New("path resolver is required for git installs"), + http.StatusInternalServerError, + ) + } + + // Parse the git:// reference. + gitRef, err := gitresolver.ParseGitReference(opts.Name) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("invalid git reference: %w", err), + http.StatusBadRequest, + ) + } + + // Preserve the original git:// URL for provenance tracking. + gitURL := opts.Name + + // Clone, read SKILL.md, collect files. + resolved, err := s.gitResolver.Resolve(ctx, gitRef) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("resolving git skill: %w", err), + http.StatusBadGateway, + ) + } + + if err := skills.ValidateSkillName(resolved.SkillConfig.Name); err != nil { + return nil, httperr.WithCode( + fmt.Errorf("skill contains invalid name: %w", err), + http.StatusUnprocessableEntity, + ) + } + + // Hydrate install options from the git result. + opts.Name = resolved.SkillConfig.Name + opts.Reference = gitURL + opts.Digest = resolved.CommitHash + if opts.Version == "" && resolved.SkillConfig.Version != "" { + opts.Version = resolved.SkillConfig.Version + } + + unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) + defer unlock() + + clientTypes, clientDirs, err := s.resolveAndValidateClients(opts, opts.Name, scope, opts.ProjectRoot) + if err != nil { + return nil, err + } + + return s.applyGitInstall(ctx, opts, scope, clientTypes, clientDirs, resolved.Files) +} + +// applyGitInstall handles the create/upgrade/no-op logic for a git-based skill +// install. It checks the store for an existing record, writes files, and +// persists the result. +func (s *service) applyGitInstall( + ctx context.Context, + opts skills.InstallOptions, + scope skills.Scope, + clientTypes []string, + clientDirs map[string]string, + files []gitresolver.FileEntry, +) (*skills.InstallResult, error) { + existing, storeErr := s.store.Get(ctx, opts.Name, scope, opts.ProjectRoot) + isNotFound := errors.Is(storeErr, storage.ErrNotFound) + if storeErr != nil && !isNotFound { + return nil, fmt.Errorf("checking existing skill: %w", storeErr) + } + if !isNotFound { + return s.applyGitInstallExisting(ctx, opts, scope, existing, clientTypes, clientDirs, files) + } + return s.applyGitInstallFresh(ctx, opts, scope, clientTypes, clientDirs, files) +} + +func (s *service) applyGitInstallExisting( + ctx context.Context, + opts skills.InstallOptions, + scope skills.Scope, + existing skills.InstalledSkill, + clientTypes []string, + clientDirs map[string]string, + files []gitresolver.FileEntry, +) (*skills.InstallResult, error) { + if existing.Digest != opts.Digest { + allClients, allDirs, err := s.expandToExistingClients( + existing.Clients, clientTypes, clientDirs, opts.Name, scope, opts.ProjectRoot) + if err != nil { + return nil, err + } + // Deduplicate so clients sharing the same directory don't conflict. + dirsToWrite := uniqueDirClients(allClients, allDirs, nil) + return s.gitWriteMultiAndPersist(ctx, opts, scope, allClients, allDirs, files, + dirsToWrite, nil, true, true) + } + clientsExplicit := len(opts.Clients) > 0 + if clientsContainAll(existing.Clients, clientTypes) || + (len(existing.Clients) == 0 && len(clientTypes) <= 1 && !clientsExplicit) { + return &skills.InstallResult{Skill: existing}, nil + } + toWrite := missingClients(existing.Clients, clientTypes) + if len(toWrite) == 0 { + return &skills.InstallResult{Skill: existing}, nil + } + // Deduplicate and skip directories already owned by existing clients. + dirsToWrite := uniqueDirClients(toWrite, clientDirs, existingClientDirs(existing.Clients, clientDirs)) + if len(dirsToWrite) == 0 { + return s.gitWriteMultiAndPersist(ctx, opts, scope, clientTypes, clientDirs, files, + nil, existing.Clients, true, false) + } + for _, ct := range dirsToWrite { + dir := filepath.Clean(clientDirs[ct]) + if _, statErr := os.Stat(dir); statErr == nil && !opts.Force { // lgtm[go/path-injection] + return nil, httperr.WithCode( + fmt.Errorf("directory %q exists but is not managed by ToolHive; use force to overwrite", dir), + http.StatusConflict, + ) + } + } + return s.gitWriteMultiAndPersist(ctx, opts, scope, clientTypes, clientDirs, files, + dirsToWrite, existing.Clients, true, false) +} + +func (s *service) applyGitInstallFresh( + ctx context.Context, + opts skills.InstallOptions, + scope skills.Scope, + clientTypes []string, + clientDirs map[string]string, + files []gitresolver.FileEntry, +) (*skills.InstallResult, error) { + // Deduplicate so clients sharing the same directory don't conflict. + dirsToCheck := uniqueDirClients(clientTypes, clientDirs, nil) + for _, ct := range dirsToCheck { + dir := filepath.Clean(clientDirs[ct]) + if _, statErr := os.Stat(dir); statErr == nil && !opts.Force { // lgtm[go/path-injection] + return nil, httperr.WithCode( + fmt.Errorf("directory %q exists but is not managed by ToolHive; use force to overwrite", dir), + http.StatusConflict, + ) + } + } + return s.gitWriteMultiAndPersist(ctx, opts, scope, clientTypes, clientDirs, files, + dirsToCheck, nil, false, false) +} + +// gitWriteMultiAndPersist writes git files to the given client directories, +// verifies each tree, then creates or updates the store record. On failure +// after any write, previously written directories in this call are removed. +func (s *service) gitWriteMultiAndPersist( + ctx context.Context, + opts skills.InstallOptions, + scope skills.Scope, + allRequested []string, + clientDirs map[string]string, + files []gitresolver.FileEntry, + dirsToWrite []string, + existingClients []string, + isUpgrade, writeAggressive bool, +) (*skills.InstallResult, error) { + var written []string + for _, ct := range dirsToWrite { + dir := filepath.Clean(clientDirs[ct]) + writeMode := opts.Force + if writeAggressive { + writeMode = true + } + if writeErr := gitresolver.WriteFiles(files, dir, writeMode); writeErr != nil { + for _, wct := range written { + _ = s.installer.Remove(filepath.Clean(clientDirs[wct])) + } + return nil, fmt.Errorf("writing git skill: %w", writeErr) + } + if checkErr := skills.CheckFilesystem(dir); checkErr != nil { + _ = s.installer.Remove(dir) + for _, wct := range written { + _ = s.installer.Remove(filepath.Clean(clientDirs[wct])) + } + return nil, fmt.Errorf("post-extraction verification failed: %w", checkErr) + } + written = append(written, ct) + } + + sk := buildInstalledSkill(opts, scope, allRequested, existingClients) + if isUpgrade { + if err := s.store.Update(ctx, sk); err != nil { + for _, wct := range written { + _ = s.installer.Remove(filepath.Clean(clientDirs[wct])) + } + return nil, err + } + } else { + if err := s.store.Create(ctx, sk); err != nil { + for _, wct := range written { + _ = s.installer.Remove(filepath.Clean(clientDirs[wct])) + } + return nil, err + } + } + return &skills.InstallResult{Skill: sk}, nil +} diff --git a/pkg/skills/skillsvc/install_git_test.go b/pkg/skills/skillsvc/install_git_test.go new file mode 100644 index 0000000000..a59c992f41 --- /dev/null +++ b/pkg/skills/skillsvc/install_git_test.go @@ -0,0 +1,419 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + regtypes "github.com/stacklok/toolhive-core/registry/types" + groupmocks "github.com/stacklok/toolhive/pkg/groups/mocks" + regmocks "github.com/stacklok/toolhive/pkg/registry/mocks" + "github.com/stacklok/toolhive/pkg/skills" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" + gitmocks "github.com/stacklok/toolhive/pkg/skills/gitresolver/mocks" + skillsmocks "github.com/stacklok/toolhive/pkg/skills/mocks" + "github.com/stacklok/toolhive/pkg/storage" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestInstallFromGit(t *testing.T) { + t.Parallel() + + commitHash := testCommitHash + + tests := []struct { + name string + opts skills.InstallOptions + setup func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) + wantCode int + wantErr string + wantName string + wantDigest string + wantVersion string + }{ + { + name: "git reference installs via git resolver", + opts: skills.InstallOptions{Name: "git://github.com/test/my-skill@v1.0.0"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "1.0.0"}, + Files: []gitresolver.FileEntry{ + {Path: "SKILL.md", Content: []byte("---\nname: my-skill\n---\n# Skill"), Mode: 0644}, + }, + CommitHash: commitHash, + }, nil) + + installBase := filepath.Join(tempDir(t), "installed") + require.NoError(t, os.MkdirAll(installBase, 0o755)) + + store := storemocks.NewMockSkillStore(ctrl) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + + pr := skillsmocks.NewMockPathResolver(ctrl) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(installBase, "my-skill"), nil) + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + + return gr, store, pr + }, + wantName: "my-skill", + wantDigest: commitHash, + wantVersion: "1.0.0", + }, + { + name: "git reference with nil resolver returns 500", + opts: skills.InstallOptions{Name: "git://github.com/test/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + store := storemocks.NewMockSkillStore(ctrl) + return nil, store, nil + }, + wantCode: http.StatusInternalServerError, + wantErr: "git resolver is not configured", + }, + { + name: "malformed git reference returns 400", + opts: skills.InstallOptions{Name: "git://"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + gr := gitmocks.NewMockResolver(ctrl) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + return gr, store, pr + }, + wantCode: http.StatusBadRequest, + wantErr: "invalid git reference", + }, + { + name: "git resolve failure returns 502", + opts: skills.InstallOptions{Name: "git://github.com/test/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("clone failed")) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + return gr, store, pr + }, + wantCode: http.StatusBadGateway, + wantErr: "resolving git skill", + }, + { + name: "same commit hash is no-op", + opts: skills.InstallOptions{Name: "git://github.com/test/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "1.0.0"}, + Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("test"), Mode: 0644}}, + CommitHash: commitHash, + }, nil) + + store := storemocks.NewMockSkillStore(ctrl) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill", Version: "1.0.0"}, + Digest: commitHash, + Status: skills.InstallStatusInstalled, + }, nil) + + pr := skillsmocks.NewMockPathResolver(ctrl) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(tempDir(t), "installed", "my-skill"), nil) + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + + return gr, store, pr + }, + wantName: "my-skill", + wantDigest: commitHash, + }, + { + name: "unmanaged directory without force returns conflict", + opts: skills.InstallOptions{Name: "git://github.com/test/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "1.0.0"}, + Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("test"), Mode: 0644}}, + CommitHash: commitHash, + }, nil) + + // Create the target dir to simulate an unmanaged directory. + installBase := filepath.Join(tempDir(t), "installed") + installDir := filepath.Join(installBase, "my-skill") + require.NoError(t, os.MkdirAll(installDir, 0o755)) + + store := storemocks.NewMockSkillStore(ctrl) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + + pr := skillsmocks.NewMockPathResolver(ctrl) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(installDir, nil) + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + + return gr, store, pr + }, + wantCode: http.StatusConflict, + wantErr: "not managed by ToolHive", + }, + { + name: "different commit hash triggers upgrade", + opts: skills.InstallOptions{Name: "git://github.com/test/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + newCommit := "1111111111111111111111111111111111111111" + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "2.0.0"}, + Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("---\nname: my-skill\n---\n# v2"), Mode: 0644}}, + CommitHash: newCommit, + }, nil) + + // Create the parent directory so the file lock can be created. + installBase := filepath.Join(tempDir(t), "installed") + require.NoError(t, os.MkdirAll(installBase, 0o755)) + installDir := filepath.Join(installBase, "my-skill") + + store := storemocks.NewMockSkillStore(ctrl) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill", Version: "1.0.0"}, + Digest: commitHash, + Clients: []string{"claude-code"}, + }, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) + + pr := skillsmocks.NewMockPathResolver(ctrl) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(installDir, nil) + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + + return gr, store, pr + }, + wantName: "my-skill", + wantDigest: "1111111111111111111111111111111111111111", + wantVersion: "2.0.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + gr, store, pr := tt.setup(t, ctrl) + + var opts []Option + if gr != nil { + opts = append(opts, WithGitResolver(gr)) + } + if pr != nil { + opts = append(opts, WithPathResolver(pr)) + } + + svc := New(store, opts...) + + // Override the default git resolver when nil is expected. + if gr == nil { + svc.(*service).gitResolver = nil + } + + result, err := svc.Install(t.Context(), tt.opts) + + if tt.wantCode != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + if tt.wantErr != "" { + assert.Contains(t, err.Error(), tt.wantErr) + } + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantName != "" { + assert.Equal(t, tt.wantName, result.Skill.Metadata.Name) + } + if tt.wantDigest != "" { + assert.Equal(t, tt.wantDigest, result.Skill.Digest) + } + if tt.wantVersion != "" { + assert.Equal(t, tt.wantVersion, result.Skill.Metadata.Version) + } + }) + } +} + +func TestInstallFromGitGroupRegistrationRollback(t *testing.T) { + t.Parallel() + + commitHash := testCommitHash + + ctrl := gomock.NewController(t) + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "1.0.0"}, + Files: []gitresolver.FileEntry{ + {Path: "SKILL.md", Content: []byte("---\nname: my-skill\n---\n# Skill"), Mode: 0644}, + }, + CommitHash: commitHash, + }, nil) + + installBase := filepath.Join(tempDir(t), "installed") + require.NoError(t, os.MkdirAll(installBase, 0o755)) + + store := storemocks.NewMockSkillStore(ctrl) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + // Rollback: DB record is removed when group registration fails. + store.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) + + pr := skillsmocks.NewMockPathResolver(ctrl) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(installBase, "my-skill"), nil) + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + + gm := groupmocks.NewMockManager(ctrl) + gm.EXPECT().Get(gomock.Any(), "badgroup").Return(nil, fmt.Errorf("group not found")) + + svc := New(store, WithGitResolver(gr), WithPathResolver(pr), WithGroupManager(gm)) + + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "git://github.com/test/my-skill@v1.0.0", + Group: "badgroup", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "registering skill in group") + assert.Contains(t, err.Error(), "group not found") +} + +func TestInstallFromRegistryGitFallback(t *testing.T) { + t.Parallel() + + commitHash := testCommitHash + + tests := []struct { + name string + opts skills.InstallOptions + setup func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) + wantCode int + wantErr string + wantName string + }{ + { + name: "registry git package triggers git install", + opts: skills.InstallOptions{Name: "my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + { + Namespace: "io.github.test", + Name: "my-skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "git", URL: "https://github.com/test/my-skill"}, + }, + }, + }, nil) + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "1.0.0"}, + Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("---\nname: my-skill\n---\n"), Mode: 0644}}, + CommitHash: commitHash, + }, nil) + + installBase := filepath.Join(tempDir(t), "installed") + require.NoError(t, os.MkdirAll(installBase, 0o755)) + + store := storemocks.NewMockSkillStore(ctrl) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + + pr := skillsmocks.NewMockPathResolver(ctrl) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(installBase, "my-skill"), nil) + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + + return lookup, gr, store, pr + }, + wantName: "my-skill", + }, + { + name: "registry git package with invalid URL returns unprocessable", + opts: skills.InstallOptions{Name: "my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + { + Namespace: "io.github.test", + Name: "my-skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "git", URL: "ftp://invalid/no-owner"}, + }, + }, + }, nil) + + store := storemocks.NewMockSkillStore(ctrl) + return lookup, nil, store, nil + }, + wantCode: http.StatusUnprocessableEntity, + wantErr: "invalid git URL", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + lookup, gr, store, pr := tt.setup(t, ctrl) + + var opts []Option + if lookup != nil { + opts = append(opts, WithSkillLookup(lookup)) + } + if gr != nil { + opts = append(opts, WithGitResolver(gr)) + } + if pr != nil { + opts = append(opts, WithPathResolver(pr)) + } + + svc := New(store, opts...) + result, err := svc.Install(t.Context(), tt.opts) + + if tt.wantCode != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + if tt.wantErr != "" { + assert.Contains(t, err.Error(), tt.wantErr) + } + return + } + require.NoError(t, err) + if tt.wantName != "" { + assert.Equal(t, tt.wantName, result.Skill.Metadata.Name) + } + }) + } +} + +// TestInstallQualifiedNameOCIFallback covers the scenario where Install +// receives a "namespace/name" string, which is parsed as an OCI reference but +// fails to pull because the namespace looks like a registry host. The service +// must fall back to a registry catalogue lookup and complete the install from +// the resolved package (OCI or git). Names that carry an explicit tag or digest +// (unambiguously OCI) must NOT trigger a fallback. diff --git a/pkg/skills/skillsvc/install_oci.go b/pkg/skills/skillsvc/install_oci.go new file mode 100644 index 0000000000..e081b04338 --- /dev/null +++ b/pkg/skills/skillsvc/install_oci.go @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + nameref "github.com/google/go-containerregistry/pkg/name" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/skills" +) + +// ociPullTimeout is the maximum time allowed for pulling an OCI artifact. +const ociPullTimeout = 5 * time.Minute + +// maxCompressedLayerSize is the maximum compressed layer size we'll load into +// memory. Skills are typically small (< 1MB compressed); this limit prevents a +// malicious artifact from causing OOM before the decompression limits kick in. +const maxCompressedLayerSize int64 = 50 * 1024 * 1024 // 50 MB + +// installFromOCI pulls a skill artifact from a remote registry, extracts +// metadata and layer data, then delegates to the standard extraction flow. +func (s *service) installFromOCI( + ctx context.Context, + opts skills.InstallOptions, + scope skills.Scope, + ref nameref.Reference, +) (*skills.InstallResult, error) { + if s.registry == nil || s.ociStore == nil { + return nil, httperr.WithCode( + errors.New("OCI registry is not configured"), + http.StatusInternalServerError, + ) + } + if s.pathResolver == nil { + return nil, httperr.WithCode( + errors.New("path resolver is required for OCI installs"), + http.StatusInternalServerError, + ) + } + + ociRef := qualifiedOCIRef(ref) + + pullCtx, cancel := context.WithTimeout(ctx, ociPullTimeout) + defer cancel() + + pulledDigest, err := s.registry.Pull(pullCtx, s.ociStore, ociRef) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("pulling OCI artifact %q: %w", ociRef, err), + http.StatusBadRequest, + ) + } + + layerData, skillConfig, err := s.extractOCIContent(ctx, pulledDigest) + if err != nil { + return nil, err + } + + if err := skills.ValidateSkillName(skillConfig.Name); err != nil { + return nil, httperr.WithCode( + fmt.Errorf("skill artifact contains invalid name: %w", err), + http.StatusUnprocessableEntity, + ) + } + + // Supply chain defense: the declared skill name must match the last path + // component of the OCI reference. The Agent Skills spec requires that the + // name field matches the parent directory name; by extension, it should + // match the repository name in the OCI reference. A mismatch could + // indicate a supply chain attack (e.g., a trusted reference pointing to + // an artifact that overwrites a different skill). + repo := ref.Context().RepositoryStr() + if idx := strings.LastIndex(repo, "/"); idx >= 0 { + repo = repo[idx+1:] + } + if repo != skillConfig.Name { + return nil, httperr.WithCode( + fmt.Errorf( + "skill name %q in artifact does not match OCI reference repository %q", + skillConfig.Name, repo, + ), + http.StatusUnprocessableEntity, + ) + } + + // Hydrate install options from the pulled artifact. + opts.Name = skillConfig.Name + opts.LayerData = layerData + opts.Reference = ociRef + opts.Digest = pulledDigest.String() + if opts.Version == "" && skillConfig.Version != "" { + opts.Version = skillConfig.Version + } + // Note: version is optional; if both are empty, install without a version. + + unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) + defer unlock() + + return s.installWithExtraction(ctx, opts, scope) +} + +// resolveFromLocalStore attempts to resolve a skill name as a tag in the local +// OCI store. On success it hydrates opts with layer data, digest, and version +// from the artifact. Returns (true, nil) when resolved, (false, nil) when the +// tag is not found, or (false, err) on validation/extraction failure. +func (s *service) resolveFromLocalStore(ctx context.Context, opts *skills.InstallOptions) (bool, error) { + d, err := s.ociStore.Resolve(ctx, opts.Name) + if err != nil { + // Tag not found in the local store — not an error, just unresolved. + slog.Debug("skill name not found in local OCI store", "name", opts.Name, "error", err) + return false, nil + } + + layerData, skillConfig, err := s.extractOCIContent(ctx, d) + if err != nil { + return false, err + } + + if err := skills.ValidateSkillName(skillConfig.Name); err != nil { + return false, httperr.WithCode( + fmt.Errorf("local artifact contains invalid skill name: %w", err), + http.StatusUnprocessableEntity, + ) + } + + // Supply-chain defense: the skill name declared inside the artifact must + // match the tag used to install it. A mismatch could indicate a + // tampered or mis-tagged artifact. + if skillConfig.Name != opts.Name { + return false, httperr.WithCode( + fmt.Errorf( + "skill name %q in local artifact does not match install name %q", + skillConfig.Name, opts.Name, + ), + http.StatusUnprocessableEntity, + ) + } + + opts.LayerData = layerData + opts.Digest = d.String() + if opts.Reference == "" { + opts.Reference = opts.Name + } + if opts.Version == "" && skillConfig.Version != "" { + opts.Version = skillConfig.Version + } + + return true, nil +} diff --git a/pkg/skills/skillsvc/install_oci_test.go b/pkg/skills/skillsvc/install_oci_test.go new file mode 100644 index 0000000000..3cefda2b9a --- /dev/null +++ b/pkg/skills/skillsvc/install_oci_test.go @@ -0,0 +1,724 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + godigest "github.com/opencontainers/go-digest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + ociskills "github.com/stacklok/toolhive-core/oci/skills" + ocimocks "github.com/stacklok/toolhive-core/oci/skills/mocks" + regtypes "github.com/stacklok/toolhive-core/registry/types" + regmocks "github.com/stacklok/toolhive/pkg/registry/mocks" + "github.com/stacklok/toolhive/pkg/skills" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" + gitmocks "github.com/stacklok/toolhive/pkg/skills/gitresolver/mocks" + skillsmocks "github.com/stacklok/toolhive/pkg/skills/mocks" + "github.com/stacklok/toolhive/pkg/storage" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestInstallFromOCI(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts skills.InstallOptions + setup func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) + wantCode int + wantErr string + wantName string + wantVersion string + wantDigest bool + wantRefSaved string + }{ + { + name: "registry not configured", + opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + return nil, nil, storemocks.NewMockSkillStore(ctrl), skillsmocks.NewMockPathResolver(ctrl) + }, + wantCode: http.StatusInternalServerError, + }, + { + name: "ociStore not configured", + opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + return ocimocks.NewMockRegistryClient(ctrl), nil, storemocks.NewMockSkillStore(ctrl), skillsmocks.NewMockPathResolver(ctrl) + }, + wantCode: http.StatusInternalServerError, + }, + { + name: "pathResolver not configured", + opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + return ocimocks.NewMockRegistryClient(ctrl), ociStore, storemocks.NewMockSkillStore(ctrl), nil + }, + wantCode: http.StatusInternalServerError, + }, + { + name: "pull error propagates", + opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). + Return(godigest.Digest(""), fmt.Errorf("auth required")) + pr := skillsmocks.NewMockPathResolver(ctrl) + return reg, ociStore, storemocks.NewMockSkillStore(ctrl), pr + }, + wantErr: "auth required", + }, + { + name: "invalid skill name in artifact", + opts: skills.InstallOptions{Name: "ghcr.io/org/bad-artifact:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + // Build an artifact with an invalid skill name (uppercase). + skillDir := filepath.Join(tempDir(t), "INVALID") + require.NoError(t, os.MkdirAll(skillDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: INVALID\ndescription: test\n---\n# Bad"), + 0o600, + )) + packager := ociskills.NewPackager(ociStore) + result, pkgErr := packager.Package(t.Context(), skillDir, ociskills.DefaultPackageOptions()) + require.NoError(t, pkgErr) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/bad-artifact:v1"). + Return(result.IndexDigest, nil) + pr := skillsmocks.NewMockPathResolver(ctrl) + return reg, ociStore, storemocks.NewMockSkillStore(ctrl), pr + }, + wantCode: http.StatusUnprocessableEntity, + }, + { + name: "oversized layer returns 422", + opts: skills.InstallOptions{Name: "ghcr.io/org/oversize-skill:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + manifestDigest := buildManifestWithLayerSize(t, ociStore, "oversize-skill", maxCompressedLayerSize+1) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/oversize-skill:v1"). + Return(manifestDigest, nil) + pr := skillsmocks.NewMockPathResolver(ctrl) + return reg, ociStore, storemocks.NewMockSkillStore(ctrl), pr + }, + wantCode: http.StatusUnprocessableEntity, + wantErr: "compressed layer size", + }, + { + name: "successful pull and install", + opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). + Return(indexDigest, nil) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + targetDir := filepath.Join(tempDir(t), "installed", "my-skill") + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.Equal(t, "my-skill", sk.Metadata.Name) + assert.Equal(t, "1.0.0", sk.Metadata.Version) + assert.Equal(t, "ghcr.io/org/my-skill:v1", sk.Reference) + assert.Contains(t, sk.Digest, "sha256:") + assert.Equal(t, skills.InstallStatusInstalled, sk.Status) + return nil + }) + return reg, ociStore, store, pr + }, + wantName: "my-skill", + wantVersion: "1.0.0", + wantDigest: true, + wantRefSaved: "ghcr.io/org/my-skill:v1", + }, + { + name: "name mismatch between artifact and reference is rejected", + opts: skills.InstallOptions{Name: "ghcr.io/org/some-repo:v1", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + // The artifact declares itself as "actual-skill", not "some-repo". + indexDigest := buildTestArtifact(t, ociStore, "actual-skill", "2.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/some-repo:v1"). + Return(indexDigest, nil) + + pr := skillsmocks.NewMockPathResolver(ctrl) + return reg, ociStore, storemocks.NewMockSkillStore(ctrl), pr + }, + wantCode: http.StatusUnprocessableEntity, + wantErr: "does not match OCI reference repository", + }, + { + name: "preserves caller version over config version", + opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1", Version: "override-version", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). + Return(indexDigest, nil) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + targetDir := filepath.Join(tempDir(t), "installed", "my-skill") + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.Equal(t, "override-version", sk.Metadata.Version) + return nil + }) + return reg, ociStore, store, pr + }, + wantName: "my-skill", + wantVersion: "override-version", + }, + { + name: "hydrates version from config when caller omits it", + opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + indexDigest := buildTestArtifact(t, ociStore, "my-skill", "3.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). + Return(indexDigest, nil) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + targetDir := filepath.Join(tempDir(t), "installed", "my-skill") + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.Equal(t, "3.0.0", sk.Metadata.Version) + return nil + }) + return reg, ociStore, store, pr + }, + wantName: "my-skill", + wantVersion: "3.0.0", + }, + { + name: "invalid OCI reference returns 400", + opts: skills.InstallOptions{Name: "not://valid:ref:extra"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + return nil, nil, storemocks.NewMockSkillStore(ctrl), nil + }, + wantCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + registry, ociStore, store, pr := tt.setup(t, ctrl) + + var opts []Option + if registry != nil { + opts = append(opts, WithRegistryClient(registry)) + } + if ociStore != nil { + opts = append(opts, WithOCIStore(ociStore)) + } + if pr != nil { + opts = append(opts, WithPathResolver(pr)) + } + + svc := New(store, opts...) + result, err := svc.Install(t.Context(), tt.opts) + + if tt.wantCode != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantName != "" { + assert.Equal(t, tt.wantName, result.Skill.Metadata.Name) + } + if tt.wantVersion != "" { + assert.Equal(t, tt.wantVersion, result.Skill.Metadata.Version) + } + if tt.wantDigest { + assert.Contains(t, result.Skill.Digest, "sha256:") + } + if tt.wantRefSaved != "" { + assert.Equal(t, tt.wantRefSaved, result.Skill.Reference) + } + }) + } +} + +func TestInstallFromLocalStore(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts skills.InstallOptions + setup func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) + wantCode int + wantErr string + wantStatus string + wantVersion string + wantDigest bool + }{ + { + name: "happy path: build then install", + opts: skills.InstallOptions{Name: "my-skill", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + // Build an artifact and tag it with the skill name. + indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), indexDigest, "my-skill")) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + targetDir := filepath.Join(tempDir(t), "installed", "my-skill") + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.Equal(t, "my-skill", sk.Metadata.Name) + assert.Equal(t, "1.0.0", sk.Metadata.Version) + assert.Contains(t, sk.Digest, "sha256:") + assert.Equal(t, skills.InstallStatusInstalled, sk.Status) + return nil + }) + return ociStore, store, pr + }, + wantStatus: string(skills.InstallStatusInstalled), + wantVersion: "1.0.0", + wantDigest: true, + }, + { + name: "name mismatch in local artifact", + opts: skills.InstallOptions{Name: "evil-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + // Build "real-skill" but tag it as "evil-skill". + indexDigest := buildTestArtifact(t, ociStore, "real-skill", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), indexDigest, "evil-skill")) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + return ociStore, store, pr + }, + wantCode: http.StatusUnprocessableEntity, + wantErr: "does not match install name", + }, + { + name: "tag not found returns not found error", + opts: skills.InstallOptions{Name: "no-such-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + // Empty store — no tags. + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + return ociStore, store, pr + }, + wantCode: http.StatusNotFound, + wantErr: "not found in local store or registry", + }, + { + name: "nil ociStore returns not found error", + opts: skills.InstallOptions{Name: "some-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + store := storemocks.NewMockSkillStore(ctrl) + return nil, store, nil + }, + wantCode: http.StatusNotFound, + wantErr: "not found in local store or registry", + }, + { + name: "corrupt manifest propagates error", + opts: skills.InstallOptions{Name: "corrupt-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + // Store raw bytes as a "manifest" and tag it — this will + // fail during extractOCIContent because it's not valid JSON. + badManifest := []byte(`not valid json`) + d, putErr := ociStore.PutManifest(t.Context(), badManifest) + require.NoError(t, putErr) + require.NoError(t, ociStore.Tag(t.Context(), d, "corrupt-skill")) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + return ociStore, store, pr + }, + wantErr: "checking OCI content type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + ociStore, store, pr := tt.setup(t, ctrl) + + var opts []Option + if ociStore != nil { + opts = append(opts, WithOCIStore(ociStore)) + } + if pr != nil { + opts = append(opts, WithPathResolver(pr)) + } + + svc := New(store, opts...) + result, err := svc.Install(t.Context(), tt.opts) + + if tt.wantCode != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantStatus != "" { + assert.Equal(t, tt.wantStatus, string(result.Skill.Status)) + } + if tt.wantVersion != "" { + assert.Equal(t, tt.wantVersion, result.Skill.Metadata.Version) + } + if tt.wantDigest { + assert.Contains(t, result.Skill.Digest, "sha256:") + } + }) + } +} + +func TestInstallQualifiedNameOCIFallback(t *testing.T) { + t.Parallel() + + commitHash := testCommitHash + + tests := []struct { + name string + opts skills.InstallOptions + setup func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) + wantCode int + wantErr string + wantName string + }{ + { + name: "qualified namespace/name falls back to registry OCI package", + opts: skills.InstallOptions{Name: "io.github.stacklok/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + // First Pull is for the raw "io.github.stacklok/my-skill:latest" — fails. + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "io.github.stacklok/my-skill:latest"). + Return(godigest.Digest(""), fmt.Errorf("no such host")). + Times(1) + // Second Pull is after registry lookup resolves the real OCI ref. + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/stacklok/my-skill:v1.0.0"). + Return(indexDigest, nil) + + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + { + Namespace: "io.github.stacklok", + Name: "my-skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "oci", Identifier: "ghcr.io/stacklok/my-skill:v1.0.0"}, + }, + }, + }, nil) + + installBase := filepath.Join(tempDir(t), "installed") + require.NoError(t, os.MkdirAll(installBase, 0o755)) + + store := storemocks.NewMockSkillStore(ctrl) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + + pr := skillsmocks.NewMockPathResolver(ctrl) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(installBase, "my-skill"), nil) + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + + return lookup, reg, ociStore, nil, store, pr + }, + wantName: "my-skill", + }, + { + name: "qualified namespace/name falls back to registry git package", + opts: skills.InstallOptions{Name: "io.github.stacklok/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "io.github.stacklok/my-skill:latest"). + Return(godigest.Digest(""), fmt.Errorf("no such host")) + + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + { + Namespace: "io.github.stacklok", + Name: "my-skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "git", URL: "https://github.com/stacklok/my-skill"}, + }, + }, + }, nil) + + gr := gitmocks.NewMockResolver(ctrl) + gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ + SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "1.0.0"}, + Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("---\nname: my-skill\n---\n"), Mode: 0644}}, + CommitHash: commitHash, + }, nil) + + installBase := filepath.Join(tempDir(t), "installed") + require.NoError(t, os.MkdirAll(installBase, 0o755)) + + store := storemocks.NewMockSkillStore(ctrl) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + + pr := skillsmocks.NewMockPathResolver(ctrl) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(installBase, "my-skill"), nil) + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + + return lookup, reg, ociStore, gr, store, pr + }, + wantName: "my-skill", + }, + { + name: "explicit OCI tag does not fall back to registry on pull failure", + opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/org/my-skill:v1"). + Return(godigest.Digest(""), fmt.Errorf("auth required")) + + // pathResolver must be non-nil so installFromOCI proceeds past its + // nil guard and reaches the Pull call. + pr := skillsmocks.NewMockPathResolver(ctrl) + + store := storemocks.NewMockSkillStore(ctrl) + + // No lookup mock — gomock will fail the test if SearchSkills is called. + return nil, reg, ociStore, nil, store, pr + }, + wantCode: http.StatusBadRequest, + wantErr: "auth required", + }, + { + name: "qualified name with no registry match returns original OCI error", + opts: skills.InstallOptions{Name: "io.github.stacklok/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "io.github.stacklok/my-skill:latest"). + Return(godigest.Digest(""), fmt.Errorf("no such host")) + + // pathResolver must be non-nil so installFromOCI proceeds past its + // nil guard and reaches the Pull call. + pr := skillsmocks.NewMockPathResolver(ctrl) + + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return(nil, nil) + + store := storemocks.NewMockSkillStore(ctrl) + return lookup, reg, ociStore, nil, store, pr + }, + wantCode: http.StatusBadRequest, + wantErr: "no such host", + }, + { + name: "digest ref does not fall back to registry on pull failure", + // A full 64-char SHA256 hex digest — required for nameref.ParseReference to accept it. + opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/org/my-skill@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"). + Return(godigest.Digest(""), fmt.Errorf("manifest unknown")) + + pr := skillsmocks.NewMockPathResolver(ctrl) + store := storemocks.NewMockSkillStore(ctrl) + // No lookup mock — gomock will fail the test if SearchSkills is called. + return nil, reg, ociStore, nil, store, pr + }, + wantCode: http.StatusBadRequest, + wantErr: "manifest unknown", + }, + { + name: "multi-segment OCI ref does not fall back to registry on pull failure", + opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/org/my-skill:latest"). + Return(godigest.Digest(""), fmt.Errorf("auth required")) + + pr := skillsmocks.NewMockPathResolver(ctrl) + store := storemocks.NewMockSkillStore(ctrl) + // No lookup mock — gomock will fail if SearchSkills is called. + return nil, reg, ociStore, nil, store, pr + }, + wantCode: http.StatusBadRequest, + wantErr: "auth required", + }, + { + name: "registry ambiguity error surfaced to caller", + // resolveFromRegistry returns a conflict error when multiple registry + // entries match the same name — the Install method must propagate it. + opts: skills.InstallOptions{Name: "io.github.stacklok/my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "io.github.stacklok/my-skill:latest"). + Return(godigest.Digest(""), fmt.Errorf("no such host")) + + pr := skillsmocks.NewMockPathResolver(ctrl) + + // Return two results with the same namespace/name so that + // resolveFromRegistry treats this as an ambiguous match and + // returns a conflict error rather than nil. + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + {Namespace: "io.github.stacklok", Name: "my-skill", Packages: []regtypes.SkillPackage{{RegistryType: "git", URL: "https://github.com/a/my-skill"}}}, + {Namespace: "io.github.stacklok", Name: "my-skill", Packages: []regtypes.SkillPackage{{RegistryType: "git", URL: "https://github.com/b/my-skill"}}}, + }, nil) + + store := storemocks.NewMockSkillStore(ctrl) + return lookup, reg, ociStore, nil, store, pr + }, + wantCode: http.StatusConflict, + wantErr: "ambiguous", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + lookup, reg, ociStore, gr, store, pr := tt.setup(t, ctrl) + + var opts []Option + if lookup != nil { + opts = append(opts, WithSkillLookup(lookup)) + } + if reg != nil { + opts = append(opts, WithRegistryClient(reg)) + } + if ociStore != nil { + opts = append(opts, WithOCIStore(ociStore)) + } + if gr != nil { + opts = append(opts, WithGitResolver(gr)) + } + if pr != nil { + opts = append(opts, WithPathResolver(pr)) + } + + svc := New(store, opts...) + result, err := svc.Install(t.Context(), tt.opts) + + if tt.wantCode != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + if tt.wantErr != "" { + assert.Contains(t, err.Error(), tt.wantErr) + } + return + } + require.NoError(t, err) + if tt.wantName != "" { + assert.Equal(t, tt.wantName, result.Skill.Metadata.Name) + } + }) + } +} diff --git a/pkg/skills/skillsvc/install_registry_test.go b/pkg/skills/skillsvc/install_registry_test.go new file mode 100644 index 0000000000..db461f0268 --- /dev/null +++ b/pkg/skills/skillsvc/install_registry_test.go @@ -0,0 +1,404 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "fmt" + "net/http" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + ociskills "github.com/stacklok/toolhive-core/oci/skills" + ocimocks "github.com/stacklok/toolhive-core/oci/skills/mocks" + regtypes "github.com/stacklok/toolhive-core/registry/types" + regmocks "github.com/stacklok/toolhive/pkg/registry/mocks" + "github.com/stacklok/toolhive/pkg/skills" + skillsmocks "github.com/stacklok/toolhive/pkg/skills/mocks" + "github.com/stacklok/toolhive/pkg/storage" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestInstallFromRegistry(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts skills.InstallOptions + setup func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) + wantCode int + wantErr string + wantName string + wantDigest bool + wantVersion string + }{ + { + name: "registry resolves skill with OCI package", + opts: skills.InstallOptions{Name: "my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + { + Namespace: "io.github.test", + Name: "my-skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "oci", Identifier: "ghcr.io/test/my-skill:v1.0.0"}, + }, + }, + }, nil) + + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + + // Build and tag an artifact with the skill name so the + // registry client can return it when Pull is called. + indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/test/my-skill:v1.0.0").Return(indexDigest, nil) + + store := storemocks.NewMockSkillStore(ctrl) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + + pr := skillsmocks.NewMockPathResolver(ctrl) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(tempDir(t), "installed", "my-skill"), nil) + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + + return lookup, reg, ociStore, store, pr + }, + wantName: "my-skill", + wantDigest: true, + wantVersion: "1.0.0", + }, + { + name: "multiple exact name matches returns conflict", + opts: skills.InstallOptions{Name: "my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + {Namespace: "io.github.alice", Name: "my-skill"}, + {Namespace: "io.github.bob", Name: "my-skill"}, + }, nil) + store := storemocks.NewMockSkillStore(ctrl) + return lookup, nil, nil, store, nil + }, + wantCode: http.StatusConflict, + wantErr: "ambiguous skill name", + }, + { + name: "skill with no installable packages returns unprocessable", + opts: skills.InstallOptions{Name: "my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + { + Namespace: "io.github.test", + Name: "my-skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "npm", URL: "https://npmjs.com/test/my-skill"}, + }, + }, + }, nil) + store := storemocks.NewMockSkillStore(ctrl) + return lookup, nil, nil, store, nil + }, + wantCode: http.StatusUnprocessableEntity, + wantErr: "no installable package", + }, + { + name: "registry lookup error degrades to not found", + opts: skills.InstallOptions{Name: "my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return(nil, fmt.Errorf("network error")) + store := storemocks.NewMockSkillStore(ctrl) + return lookup, nil, nil, store, nil + }, + wantCode: http.StatusNotFound, + wantErr: "not found in local store or registry", + }, + { + name: "nil skill lookup returns not found", + opts: skills.InstallOptions{Name: "my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + store := storemocks.NewMockSkillStore(ctrl) + return nil, nil, nil, store, nil + }, + wantCode: http.StatusNotFound, + wantErr: "not found in local store or registry", + }, + { + name: "partial name match only returns not found", + opts: skills.InstallOptions{Name: "my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + lookup := regmocks.NewMockProvider(ctrl) + // Search returns a skill with a different name (partial match only). + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + {Namespace: "io.github.test", Name: "my-skill-extended"}, + }, nil) + store := storemocks.NewMockSkillStore(ctrl) + return lookup, nil, nil, store, nil + }, + wantCode: http.StatusNotFound, + wantErr: "not found in local store or registry", + }, + { + name: "invalid OCI identifier in registry result returns unprocessable", + opts: skills.InstallOptions{Name: "my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + lookup := regmocks.NewMockProvider(ctrl) + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + { + Namespace: "io.github.test", + Name: "my-skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "oci", Identifier: "!!!invalid-ref!!!"}, + }, + }, + }, nil) + store := storemocks.NewMockSkillStore(ctrl) + return lookup, nil, nil, store, nil + }, + wantCode: http.StatusUnprocessableEntity, + wantErr: "invalid OCI identifier", + }, + { + name: "case-insensitive name match resolves correctly", + opts: skills.InstallOptions{Name: "my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + lookup := regmocks.NewMockProvider(ctrl) + // Registry returns mixed-case name; should still match. + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + { + Namespace: "io.github.test", + Name: "My-Skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "oci", Identifier: "ghcr.io/test/my-skill:v1.0.0"}, + }, + }, + }, nil) + + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/test/my-skill:v1.0.0").Return(indexDigest, nil) + + store := storemocks.NewMockSkillStore(ctrl) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + + pr := skillsmocks.NewMockPathResolver(ctrl) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(tempDir(t), "installed", "my-skill"), nil) + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + + return lookup, reg, ociStore, store, pr + }, + wantName: "my-skill", + }, + { + name: "supply chain: registry reference with wrong repo name is rejected", + opts: skills.InstallOptions{Name: "my-skill"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { + t.Helper() + lookup := regmocks.NewMockProvider(ctrl) + // Registry points to wrong-name repo, but artifact declares my-skill. + lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ + { + Namespace: "io.github.test", + Name: "my-skill", + Packages: []regtypes.SkillPackage{ + {RegistryType: "oci", Identifier: "ghcr.io/test/wrong-name:v1.0.0"}, + }, + }, + }, nil) + + ociStore, err := ociskills.NewStore(tempDir(t)) + require.NoError(t, err) + // Build artifact with name "my-skill" but the ref says "wrong-name". + indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/test/wrong-name:v1.0.0").Return(indexDigest, nil) + + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}).AnyTimes() + + return lookup, reg, ociStore, store, pr + }, + wantCode: http.StatusUnprocessableEntity, + wantErr: "does not match OCI reference repository", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + lookup, reg, ociStore, store, pr := tt.setup(t, ctrl) + + var opts []Option + if lookup != nil { + opts = append(opts, WithSkillLookup(lookup)) + } + if reg != nil { + opts = append(opts, WithRegistryClient(reg)) + } + if ociStore != nil { + opts = append(opts, WithOCIStore(ociStore)) + } + if pr != nil { + opts = append(opts, WithPathResolver(pr)) + } + + svc := New(store, opts...) + result, err := svc.Install(t.Context(), tt.opts) + + if tt.wantCode != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + if tt.wantErr != "" { + assert.Contains(t, err.Error(), tt.wantErr) + } + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantName != "" { + assert.Equal(t, tt.wantName, result.Skill.Metadata.Name) + } + if tt.wantDigest { + assert.Contains(t, result.Skill.Digest, "sha256:") + } + if tt.wantVersion != "" { + assert.Equal(t, tt.wantVersion, result.Skill.Metadata.Version) + } + }) + } +} + +func TestBuildGitReferenceFromRegistryURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rawURL string + want string + wantErr string + }{ + { + name: "https URL converts to git scheme", + rawURL: "https://github.com/org/repo", + want: "git://github.com/org/repo", + }, + { + name: "http URL silently promoted to git scheme", + rawURL: "http://github.com/org/repo", + want: "git://github.com/org/repo", + }, + { + name: "git URL passes through unchanged", + rawURL: "git://github.com/org/repo", + want: "git://github.com/org/repo", + }, + { + name: "https URL with nested path", + rawURL: "https://github.com/org/repo@v1.0#skills/foo", + want: "git://github.com/org/repo@v1.0#skills/foo", + }, + { + name: "empty git reference", + rawURL: "git://", + wantErr: "invalid git reference", + }, + { + name: "unsupported ftp scheme", + rawURL: "ftp://github.com/org/repo", + wantErr: "unsupported URL scheme", + }, + { + name: "bare string no scheme", + rawURL: "noscheme/org/repo", + wantErr: "unsupported URL scheme", + }, + { + name: "https URL missing repo path", + rawURL: "https://github.com", + wantErr: "no repository path after host", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := buildGitReferenceFromRegistryURL(tt.rawURL) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSplitQualifiedName(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + wantNS string + wantName string + }{ + {"skill-creator", "", "skill-creator"}, + {"io.github.stacklok/skill-creator", "io.github.stacklok", "skill-creator"}, + {"deep/nested/name", "deep/nested", "name"}, + {"", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + ns, name := splitQualifiedName(tt.input) + assert.Equal(t, tt.wantNS, ns) + assert.Equal(t, tt.wantName, name) + }) + } +} + +func TestResolveRegistryPackagesSubfolder(t *testing.T) { + t.Parallel() + + result, err := resolveRegistryPackages("my-skill", []regtypes.SkillPackage{ + { + RegistryType: "git", + URL: "https://github.com/org/repo", + Subfolder: "skills/my-skill", + }, + }) + require.NoError(t, err) + require.NotNil(t, result) + assert.Contains(t, result.GitURL, "#skills/my-skill") +} diff --git a/pkg/skills/skillsvc/install_test.go b/pkg/skills/skillsvc/install_test.go new file mode 100644 index 0000000000..2d57f1ac00 --- /dev/null +++ b/pkg/skills/skillsvc/install_test.go @@ -0,0 +1,818 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/client" + "github.com/stacklok/toolhive/pkg/groups" + groupmocks "github.com/stacklok/toolhive/pkg/groups/mocks" + "github.com/stacklok/toolhive/pkg/skills" + skillsmocks "github.com/stacklok/toolhive/pkg/skills/mocks" + "github.com/stacklok/toolhive/pkg/storage" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestInstallPlainNameNotFound(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts skills.InstallOptions + wantCode int + wantErr string + }{ + { + name: "plain name without store or lookup returns not found", + opts: skills.InstallOptions{Name: "my-skill"}, + wantCode: http.StatusNotFound, + wantErr: "not found in local store or registry", + }, + { + name: "rejects project scope without root", + opts: skills.InstallOptions{Name: "my-skill", Scope: skills.ScopeProject}, + wantCode: http.StatusBadRequest, + }, + { + name: "rejects invalid name", + opts: skills.InstallOptions{Name: "A"}, + wantCode: http.StatusBadRequest, + }, + { + name: "rejects empty name", + opts: skills.InstallOptions{Name: ""}, + wantCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + + _, err := New(store).Install(t.Context(), tt.opts) + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + if tt.wantErr != "" { + assert.Contains(t, err.Error(), tt.wantErr) + } + }) + } +} + +func TestInstallWithExtraction(t *testing.T) { + t.Parallel() + + layerData := makeLayerData(t) + + t.Run("fresh install creates record and extracts files", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + + targetDir := filepath.Join(tempDir(t), "my-skill") + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.Equal(t, skills.InstallStatusInstalled, sk.Status) + assert.Equal(t, "sha256:abc", sk.Digest) + assert.Equal(t, []string{"claude-code"}, sk.Clients) + return nil + }) + + svc := New(store, WithPathResolver(pr)) + result, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + }) + require.NoError(t, err) + assert.Equal(t, skills.InstallStatusInstalled, result.Skill.Status) + + // Verify files were extracted + _, statErr := os.Stat(filepath.Join(targetDir, "SKILL.md")) + assert.NoError(t, statErr) + }) + + t.Run("same digest is no-op", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + + targetDir := filepath.Join(tempDir(t), "my-skill") + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Digest: "sha256:abc", + Status: skills.InstallStatusInstalled, + } + + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + // No Create or Update should be called + + svc := New(store, WithPathResolver(pr)) + result, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + }) + require.NoError(t, err) + assert.Equal(t, "my-skill", result.Skill.Metadata.Name) + }) + + t.Run("different digest triggers upgrade", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + + targetDir := filepath.Join(tempDir(t), "my-skill") + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Digest: "sha256:old", + Status: skills.InstallStatusInstalled, + Clients: []string{"claude-code"}, + } + + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.Equal(t, "sha256:new", sk.Digest) + assert.Equal(t, skills.InstallStatusInstalled, sk.Status) + return nil + }) + + svc := New(store, WithPathResolver(pr)) + result, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:new", + }) + require.NoError(t, err) + assert.Equal(t, "sha256:new", result.Skill.Digest) + }) + + t.Run("unmanaged directory refused without force", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + + // Create an existing unmanaged directory + targetDir := filepath.Join(tempDir(t), "my-skill") + require.NoError(t, os.MkdirAll(targetDir, 0750)) + + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + + svc := New(store, WithPathResolver(pr)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + }) + require.Error(t, err) + assert.Equal(t, http.StatusConflict, httperr.Code(err)) + assert.Contains(t, err.Error(), "not managed by ToolHive") + }) + + t.Run("unmanaged directory overwritten with force", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + + // Create an existing unmanaged directory + targetDir := filepath.Join(tempDir(t), "my-skill") + require.NoError(t, os.MkdirAll(targetDir, 0750)) + + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + + svc := New(store, WithPathResolver(pr)) + result, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + Force: true, + }) + require.NoError(t, err) + assert.Equal(t, skills.InstallStatusInstalled, result.Skill.Status) + }) + + t.Run("explicit client used over default", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + + targetDir := filepath.Join(tempDir(t), "my-skill") + pr.EXPECT().GetSkillPath("custom-client", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.Equal(t, []string{"custom-client"}, sk.Clients) + return nil + }) + + svc := New(store, WithPathResolver(pr)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Clients: []string{"custom-client"}, + Digest: "sha256:abc", + }) + require.NoError(t, err) + }) + + t.Run("multiple clients fresh install", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + inst := skillsmocks.NewMockInstaller(ctrl) + + dirA := filepath.Join(t.TempDir(), "a", "my-skill") + dirB := filepath.Join(t.TempDir(), "b", "my-skill") + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) + pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + inst.EXPECT().Extract(layerData, dirA, false).Return(&skills.ExtractResult{SkillDir: dirA, Files: 1}, nil) + inst.EXPECT().Extract(layerData, dirB, false).Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.ElementsMatch(t, []string{"claude-code", "opencode"}, sk.Clients) + return nil + }) + + svc := New(store, WithPathResolver(pr), WithInstaller(inst)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", "opencode"}, + }) + require.NoError(t, err) + }) + + t.Run("same digest adds second client without re-extracting first", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + inst := skillsmocks.NewMockInstaller(ctrl) + + dirA := filepath.Join(t.TempDir(), "a") + dirB := filepath.Join(t.TempDir(), "b") + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Digest: "sha256:abc", + Clients: []string{"claude-code"}, + } + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) + pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + inst.EXPECT().Extract(layerData, dirB, false).Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.ElementsMatch(t, []string{"claude-code", "opencode"}, sk.Clients) + return nil + }) + + svc := New(store, WithPathResolver(pr), WithInstaller(inst)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", "opencode"}, + }) + require.NoError(t, err) + }) + + t.Run("invalid client returns bad request", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + + pr.EXPECT().GetSkillPath("not-a-real-client", "my-skill", skills.ScopeUser, "").Return("", fmt.Errorf("%w: not-a-real-client", client.ErrUnsupportedClientType)) + + svc := New(store, WithPathResolver(pr)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"not-a-real-client"}, + }) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("empty string in clients list returns bad request", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + + svc := New(store, WithPathResolver(pr)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", ""}, + }) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("clients all sentinel expands to every skill-supporting client", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + inst := skillsmocks.NewMockInstaller(ctrl) + + dirA := filepath.Join(t.TempDir(), "a", "my-skill") + dirB := filepath.Join(t.TempDir(), "b", "my-skill") + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code", "opencode"}) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) + pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + inst.EXPECT().Extract(layerData, dirA, false).Return(&skills.ExtractResult{SkillDir: dirA, Files: 1}, nil) + inst.EXPECT().Extract(layerData, dirB, false).Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.ElementsMatch(t, []string{"claude-code", "opencode"}, sk.Clients) + return nil + }) + + svc := New(store, WithPathResolver(pr), WithInstaller(inst)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"all"}, + }) + require.NoError(t, err) + }) + + t.Run("all sentinel mixed with named client returns bad request", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + + svc := New(store, WithPathResolver(pr)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"all", "opencode"}, + }) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("fresh install rolls back extraction on store.Create failure", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + inst := skillsmocks.NewMockInstaller(ctrl) + + targetDir := filepath.Join(t.TempDir(), "my-skill") + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + inst.EXPECT().Extract(layerData, targetDir, false).Return(&skills.ExtractResult{SkillDir: targetDir, Files: 1}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(fmt.Errorf("db write error")) + inst.EXPECT().Remove(targetDir).Return(nil) + + svc := New(store, WithPathResolver(pr), WithInstaller(inst)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "db write error") + }) + + t.Run("upgrade rolls back extraction on store.Update failure", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + inst := skillsmocks.NewMockInstaller(ctrl) + + targetDir := filepath.Join(t.TempDir(), "my-skill") + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Digest: "sha256:old", + Status: skills.InstallStatusInstalled, + Clients: []string{"claude-code"}, + } + + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + inst.EXPECT().Extract(layerData, targetDir, true).Return(&skills.ExtractResult{SkillDir: targetDir, Files: 1}, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any()).Return(fmt.Errorf("db update error")) + inst.EXPECT().Remove(targetDir).Return(nil) + + svc := New(store, WithPathResolver(pr), WithInstaller(inst)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:new", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "db update error") + }) + + t.Run("multi-client fresh install rolls back first client on second extract failure", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + inst := skillsmocks.NewMockInstaller(ctrl) + + dirA := filepath.Join(t.TempDir(), "a", "my-skill") + dirB := filepath.Join(t.TempDir(), "b", "my-skill") + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) + pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, ""). + Return(skills.InstalledSkill{}, storage.ErrNotFound) + inst.EXPECT().Extract(layerData, dirA, false). + Return(&skills.ExtractResult{SkillDir: dirA, Files: 1}, nil) + inst.EXPECT().Extract(layerData, dirB, false). + Return(nil, fmt.Errorf("disk full")) + inst.EXPECT().Remove(dirA).Return(nil) + + svc := New(store, WithPathResolver(pr), WithInstaller(inst)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", "opencode"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "disk full") + }) + + t.Run("upgrade digest rolls back written clients on second extract failure", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + inst := skillsmocks.NewMockInstaller(ctrl) + + dirA := filepath.Join(t.TempDir(), "a", "my-skill") + dirB := filepath.Join(t.TempDir(), "b", "my-skill") + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Digest: "sha256:old", + Clients: []string{"claude-code"}, + } + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) + pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + inst.EXPECT().Extract(layerData, dirA, true). + Return(&skills.ExtractResult{SkillDir: dirA, Files: 1}, nil) + inst.EXPECT().Extract(layerData, dirB, true). + Return(nil, fmt.Errorf("write error")) + inst.EXPECT().Remove(dirA).Return(nil) + + svc := New(store, WithPathResolver(pr), WithInstaller(inst)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:new", + Clients: []string{"claude-code", "opencode"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "write error") + }) + + t.Run("upgrade digest rolls back all clients on store.Update failure", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + inst := skillsmocks.NewMockInstaller(ctrl) + + dirA := filepath.Join(t.TempDir(), "a", "my-skill") + dirB := filepath.Join(t.TempDir(), "b", "my-skill") + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Digest: "sha256:old", + Clients: []string{"claude-code"}, + } + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) + pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + inst.EXPECT().Extract(layerData, dirA, true). + Return(&skills.ExtractResult{SkillDir: dirA, Files: 1}, nil) + inst.EXPECT().Extract(layerData, dirB, true). + Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any()).Return(fmt.Errorf("db failure")) + inst.EXPECT().Remove(dirA).Return(nil) + inst.EXPECT().Remove(dirB).Return(nil) + + svc := New(store, WithPathResolver(pr), WithInstaller(inst)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:new", + Clients: []string{"claude-code", "opencode"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "db failure") + }) + + t.Run("same digest new client rolls back on extract failure", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + inst := skillsmocks.NewMockInstaller(ctrl) + + dirA := filepath.Join(t.TempDir(), "a", "my-skill") + dirB := filepath.Join(t.TempDir(), "b", "my-skill") + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Digest: "sha256:abc", + Clients: []string{"claude-code"}, + } + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) + pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + inst.EXPECT().Extract(layerData, dirB, false).Return(nil, fmt.Errorf("extract boom")) + + svc := New(store, WithPathResolver(pr), WithInstaller(inst)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", "opencode"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "extract boom") + }) + + t.Run("same digest new client rolls back on store.Update failure", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + inst := skillsmocks.NewMockInstaller(ctrl) + + dirA := filepath.Join(t.TempDir(), "a", "my-skill") + dirB := filepath.Join(t.TempDir(), "b", "my-skill") + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Digest: "sha256:abc", + Clients: []string{"claude-code"}, + } + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) + pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + inst.EXPECT().Extract(layerData, dirB, false). + Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any()).Return(fmt.Errorf("db update boom")) + inst.EXPECT().Remove(dirB).Return(nil) + + svc := New(store, WithPathResolver(pr), WithInstaller(inst)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", "opencode"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "db update boom") + }) + + t.Run("same digest new client unmanaged dir conflict", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + inst := skillsmocks.NewMockInstaller(ctrl) + + dirA := filepath.Join(t.TempDir(), "a", "my-skill") + dirB := filepath.Join(t.TempDir(), "b", "my-skill") + require.NoError(t, os.MkdirAll(dirB, 0o750)) + + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Digest: "sha256:abc", + Clients: []string{"claude-code"}, + } + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) + pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + + svc := New(store, WithPathResolver(pr), WithInstaller(inst)) + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", "opencode"}, + }) + require.Error(t, err) + assert.Equal(t, http.StatusConflict, httperr.Code(err)) + assert.Contains(t, err.Error(), "not managed by ToolHive") + }) + + t.Run("legacy row with explicit client is not a no-op", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + inst := skillsmocks.NewMockInstaller(ctrl) + + dirB := filepath.Join(t.TempDir(), "b", "my-skill") + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Digest: "sha256:abc", + Clients: []string{}, + } + pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + inst.EXPECT().Extract(layerData, dirB, false). + Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.Contains(t, sk.Clients, "opencode") + return nil + }) + + svc := New(store, WithPathResolver(pr), WithInstaller(inst)) + result, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"opencode"}, + }) + require.NoError(t, err) + assert.Equal(t, "my-skill", result.Skill.Metadata.Name) + }) + + t.Run("upgrade extracts to all existing clients not just requested", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + inst := skillsmocks.NewMockInstaller(ctrl) + + dirA := filepath.Join(t.TempDir(), "a", "my-skill") + dirB := filepath.Join(t.TempDir(), "b", "my-skill") + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Digest: "sha256:old", + Clients: []string{"claude-code"}, + } + pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + inst.EXPECT().Extract(layerData, dirB, true). + Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil) + inst.EXPECT().Extract(layerData, dirA, true). + Return(&skills.ExtractResult{SkillDir: dirA, Files: 1}, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assert.ElementsMatch(t, []string{"opencode", "claude-code"}, sk.Clients) + assert.Equal(t, "sha256:new", sk.Digest) + return nil + }) + + svc := New(store, WithPathResolver(pr), WithInstaller(inst)) + result, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:new", + Clients: []string{"opencode"}, + }) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"opencode", "claude-code"}, result.Skill.Clients) + }) +} +func TestInstallAddsSkillToGroup(t *testing.T) { + t.Parallel() + + layerData := makeLayerData(t) + + // These tests provide LayerData so Install goes through installWithExtraction, + // which exercises group registration without needing OCI resolution. + tests := []struct { + name string + opts skills.InstallOptions + setupStoreMock func(*storemocks.MockSkillStore) + setupPR func(*skillsmocks.MockPathResolver) + setupGroupMock func(*groupmocks.MockManager) + wantErr string + }{ + { + name: "install with group registers skill", + opts: skills.InstallOptions{Name: "my-skill", Group: "mygroup", LayerData: layerData, Digest: "sha256:abc"}, + setupStoreMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + s.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + }, + setupPR: func(pr *skillsmocks.MockPathResolver) { + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(tempDir(t), "my-skill"), nil) + }, + setupGroupMock: func(gm *groupmocks.MockManager) { + gm.EXPECT().Get(gomock.Any(), "mygroup"). + Return(&groups.Group{Name: "mygroup", Skills: []string{}}, nil) + gm.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) + }, + }, + { + name: "install without group defaults to default group", + opts: skills.InstallOptions{Name: "my-skill", LayerData: layerData, Digest: "sha256:abc"}, + setupStoreMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + s.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + }, + setupPR: func(pr *skillsmocks.MockPathResolver) { + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(tempDir(t), "my-skill"), nil) + }, + setupGroupMock: func(gm *groupmocks.MockManager) { + gm.EXPECT().Get(gomock.Any(), groups.DefaultGroup). + Return(&groups.Group{Name: groups.DefaultGroup, Skills: []string{}}, nil) + gm.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) + }, + }, + { + name: "group registration error rolls back DB record", + opts: skills.InstallOptions{Name: "my-skill", Group: "badgroup", LayerData: layerData, Digest: "sha256:abc"}, + setupStoreMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + s.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + // Rollback: installAndRegister removes the DB record on group failure. + s.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) + }, + setupPR: func(pr *skillsmocks.MockPathResolver) { + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(tempDir(t), "my-skill"), nil) + }, + setupGroupMock: func(gm *groupmocks.MockManager) { + gm.EXPECT().Get(gomock.Any(), "badgroup"). + Return(nil, fmt.Errorf("group not found")) + }, + wantErr: "registering skill in group", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + gm := groupmocks.NewMockManager(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + + tt.setupStoreMock(store) + tt.setupGroupMock(gm) + tt.setupPR(pr) + + svc := New(store, WithGroupManager(gm), WithPathResolver(pr)) + + _, err := svc.Install(t.Context(), tt.opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/skills/skillsvc/list.go b/pkg/skills/skillsvc/list.go new file mode 100644 index 0000000000..c87488840b --- /dev/null +++ b/pkg/skills/skillsvc/list.go @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "fmt" + "net/http" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/skills" + "github.com/stacklok/toolhive/pkg/storage" +) + +// List returns all installed skills matching the given options. +func (s *service) List(ctx context.Context, opts skills.ListOptions) ([]skills.InstalledSkill, error) { + scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) + if err != nil { + return nil, err + } + filter := storage.ListFilter{ + Scope: scope, + ClientApp: opts.ClientApp, + ProjectRoot: projectRoot, + } + all, err := s.store.List(ctx, filter) + if err != nil { + return nil, err + } + + if opts.Group == "" { + return all, nil + } + + if s.groupManager == nil { + return nil, httperr.WithCode( + fmt.Errorf("group filtering is not available: group manager is not configured"), + http.StatusInternalServerError, + ) + } + + group, err := s.groupManager.Get(ctx, opts.Group) + if err != nil { + return nil, fmt.Errorf("getting group %q: %w", opts.Group, err) + } + + // Build a lookup set of skill names in the group. + groupSkills := make(map[string]struct{}, len(group.Skills)) + for _, name := range group.Skills { + groupSkills[name] = struct{}{} + } + + filtered := make([]skills.InstalledSkill, 0, len(all)) + for _, sk := range all { + if _, ok := groupSkills[sk.Metadata.Name]; ok { + filtered = append(filtered, sk) + } + } + return filtered, nil +} + +// Info returns detailed information about a skill. +func (s *service) Info(ctx context.Context, opts skills.InfoOptions) (*skills.SkillInfo, error) { + if err := skills.ValidateSkillName(opts.Name); err != nil { + return nil, httperr.WithCode(err, http.StatusBadRequest) + } + + scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) + if err != nil { + return nil, err + } + scope = defaultScope(scope) + + skill, err := s.store.Get(ctx, opts.Name, scope, projectRoot) + if err != nil { + return nil, err + } + + return &skills.SkillInfo{ + Metadata: skill.Metadata, + InstalledSkill: &skill, + }, nil +} diff --git a/pkg/skills/skillsvc/oci.go b/pkg/skills/skillsvc/oci.go new file mode 100644 index 0000000000..cfddaac465 --- /dev/null +++ b/pkg/skills/skillsvc/oci.go @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + nameref "github.com/google/go-containerregistry/pkg/name" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/stacklok/toolhive-core/httperr" + ociskills "github.com/stacklok/toolhive-core/oci/skills" +) + +// qualifiedOCIRef returns the full OCI reference string including the tag or +// digest. When the user omits a tag (e.g. "ghcr.io/org/skill"), +// go-containerregistry's ParseReference defaults to "latest" internally but +// String() omits it. This function only appends the implicit tag when the +// original string does not already include one. +func qualifiedOCIRef(ref nameref.Reference) string { + s := ref.String() + if _, ok := ref.(nameref.Digest); ok { + return s // already has @sha256:... + } + if strings.Contains(s, ":") { + return s // already has an explicit tag + } + return s + ":" + ref.Identifier() +} + +func parseOCIReference(name string) (nameref.Reference, bool, error) { + // Structural check: skill names never contain '/', ':', or '@'. + // OCI references always require at least one of these. + if !strings.ContainsAny(name, "/:@") { + return nil, false, nil + } + + ref, err := nameref.ParseReference(name) + if err != nil { + return nil, true, err + } + return ref, true, nil +} + +// isUnambiguousOCIRef reports whether raw was clearly intended by the user as +// an OCI reference, meaning a failed pull must NOT fall back to a registry +// catalogue lookup. A ref is unambiguous if any of the following hold: +// +// - the parsed form is a digest reference (e.g. "name@sha256:...") +// - the raw string contains ':' (explicit tag such as "name:v1") +// - the raw string has more than one '/' (multi-segment path such as +// "ghcr.io/org/skill") +// +// The parsed Reference alone is insufficient for the tag case: +// nameref.ParseReference normalizes "foo/bar" to "foo/bar:latest" (a name.Tag), +// making it indistinguishable from an explicitly tagged reference. We therefore +// rely on the parsed form for the digest check and fall back to string +// inspection for the tag and segment-count checks. +func isUnambiguousOCIRef(raw string, ref nameref.Reference) bool { + if _, isDigest := ref.(nameref.Digest); isDigest { + return true + } + return strings.Contains(raw, ":") || strings.Count(raw, "/") > 1 +} + +// isSkillArtifact reports whether the OCI descriptor at digest d carries +// ArtifactType == ArtifactTypeSkill. It inspects the top-level index or +// manifest without descending into layers, so it is cheap to call. +func (s *service) isSkillArtifact(ctx context.Context, d digest.Digest) (bool, error) { + isIndex, err := s.ociStore.IsIndex(ctx, d) + if err != nil { + return false, fmt.Errorf("checking OCI content type: %w", err) + } + + if isIndex { + index, indexErr := s.ociStore.GetIndex(ctx, d) + if indexErr != nil { + return false, fmt.Errorf("reading OCI index: %w", indexErr) + } + return index.ArtifactType == ociskills.ArtifactTypeSkill, nil + } + + manifestBytes, err := s.ociStore.GetManifest(ctx, d) + if err != nil { + return false, fmt.Errorf("reading OCI manifest: %w", err) + } + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return false, fmt.Errorf("parsing OCI manifest: %w", err) + } + return manifest.ArtifactType == ociskills.ArtifactTypeSkill, nil +} + +// extractOCIContent navigates the OCI content graph from a pulled digest, +// extracting the skill config and raw layer data. +func (s *service) extractOCIContent(ctx context.Context, d digest.Digest) ([]byte, *ociskills.SkillConfig, error) { + isIndex, err := s.ociStore.IsIndex(ctx, d) + if err != nil { + return nil, nil, fmt.Errorf("checking OCI content type: %w", err) + } + + manifestDigest := d + if isIndex { + // Skill content is platform-agnostic — all platforms share the same + // layer, so we can use the first manifest in the index. + index, indexErr := s.ociStore.GetIndex(ctx, d) + if indexErr != nil { + return nil, nil, fmt.Errorf("reading OCI index: %w", indexErr) + } + if len(index.Manifests) == 0 { + return nil, nil, httperr.WithCode( + errors.New("OCI index contains no manifests"), + http.StatusUnprocessableEntity, + ) + } + manifestDigest = index.Manifests[0].Digest + } + + manifestBytes, err := s.ociStore.GetManifest(ctx, manifestDigest) + if err != nil { + return nil, nil, fmt.Errorf("reading OCI manifest: %w", err) + } + + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return nil, nil, fmt.Errorf("parsing OCI manifest: %w", err) + } + + if len(manifest.Layers) == 0 { + return nil, nil, httperr.WithCode( + errors.New("OCI manifest contains no layers"), + http.StatusUnprocessableEntity, + ) + } + + // Skills use a single-layer format (one tar.gz). Validate the first + // (and only expected) layer. + if manifest.Layers[0].MediaType != ocispec.MediaTypeImageLayerGzip { + return nil, nil, httperr.WithCode( + fmt.Errorf("unexpected layer media type %q, expected %q", + manifest.Layers[0].MediaType, ocispec.MediaTypeImageLayerGzip), + http.StatusUnprocessableEntity, + ) + } + + // Extract skill config from the OCI image config. + configBytes, err := s.ociStore.GetBlob(ctx, manifest.Config.Digest) + if err != nil { + return nil, nil, fmt.Errorf("reading OCI config blob: %w", err) + } + + var imgConfig ocispec.Image + if err := json.Unmarshal(configBytes, &imgConfig); err != nil { + return nil, nil, fmt.Errorf("parsing OCI image config: %w", err) + } + + skillConfig, err := ociskills.SkillConfigFromImageConfig(&imgConfig) + if err != nil { + return nil, nil, fmt.Errorf("extracting skill config from OCI artifact: %w", err) + } + + // Guard against oversized layers before loading into memory. + if manifest.Layers[0].Size > maxCompressedLayerSize { + return nil, nil, httperr.WithCode( + fmt.Errorf("compressed layer size %d bytes exceeds maximum %d bytes", + manifest.Layers[0].Size, maxCompressedLayerSize), + http.StatusUnprocessableEntity, + ) + } + + // Extract the raw tar.gz layer data. + layerData, err := s.ociStore.GetBlob(ctx, manifest.Layers[0].Digest) + if err != nil { + return nil, nil, fmt.Errorf("reading OCI layer blob: %w", err) + } + + return layerData, skillConfig, nil +} diff --git a/pkg/skills/skillsvc/oci_test.go b/pkg/skills/skillsvc/oci_test.go new file mode 100644 index 0000000000..a2706a167e --- /dev/null +++ b/pkg/skills/skillsvc/oci_test.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "testing" + + nameref "github.com/google/go-containerregistry/pkg/name" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQualifiedOCIRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + { + name: "explicit tag unchanged", + input: "ghcr.io/org/my-skill:v1", + want: "ghcr.io/org/my-skill:v1", + }, + { + name: "no tag defaults to latest", + input: "ghcr.io/stacklok/toolhive/skills/toolhive-cli-user", + want: "ghcr.io/stacklok/toolhive/skills/toolhive-cli-user:latest", + }, + { + name: "digest unchanged", + input: "ghcr.io/org/my-skill@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + want: "ghcr.io/org/my-skill@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ref, err := nameref.ParseReference(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.want, qualifiedOCIRef(ref)) + }) + } +} diff --git a/pkg/skills/skillsvc/registry.go b/pkg/skills/skillsvc/registry.go new file mode 100644 index 0000000000..09aae72ce2 --- /dev/null +++ b/pkg/skills/skillsvc/registry.go @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "fmt" + "log/slog" + "net/http" + "strings" + + nameref "github.com/google/go-containerregistry/pkg/name" + + "github.com/stacklok/toolhive-core/httperr" + regtypes "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" +) + +// registryResolveResult holds the outcome of a registry skill name lookup. +// Exactly one of OCIRef or GitURL will be set. +type registryResolveResult struct { + OCIRef nameref.Reference + GitURL string // raw git:// URL for installFromGit +} + +// resolveFromRegistry attempts to resolve a skill name by querying the +// configured skill registry/index. Accepts either a plain name ("skill-creator") +// or a qualified "namespace/name" ("io.github.stacklok/skill-creator"). +// Returns (result, nil) on success, (nil, nil) when no match is found or no +// lookup is configured, or (nil, err) on ambiguity. +func (s *service) resolveFromRegistry(name string) (*registryResolveResult, error) { + if s.skillLookup == nil { + return nil, nil + } + + // Split qualified "namespace/name" if present. Use the last segment as + // the search query since SearchSkills matches on name substring. + wantNamespace, searchName := splitQualifiedName(name) + + results, err := s.skillLookup.SearchSkills(searchName) + if err != nil { + slog.Warn("registry skill lookup failed, falling back to not-found", "name", name, "error", err) + return nil, nil + } + + // Filter for exact match. Case-insensitive because registry data + // may not be normalized to lowercase even though local skill names are. + var matches []regtypes.Skill + for _, sk := range results { + if !strings.EqualFold(sk.Name, searchName) { + continue + } + if wantNamespace != "" && !strings.EqualFold(sk.Namespace, wantNamespace) { + continue + } + matches = append(matches, sk) + } + + if len(matches) == 0 { + return nil, nil + } + + if len(matches) > 1 { + const maxCandidates = 5 + var candidates []string + for _, sk := range matches { + candidates = append(candidates, sk.Namespace+"/"+sk.Name) + } + suffix := "" + if len(candidates) > maxCandidates { + suffix = fmt.Sprintf(" and %d more", len(candidates)-maxCandidates) + candidates = candidates[:maxCandidates] + } + return nil, httperr.WithCode( + fmt.Errorf("ambiguous skill name %q matches multiple registry entries: %s%s; install by full OCI reference instead", + name, strings.Join(candidates, ", "), suffix), + http.StatusConflict, + ) + } + + return resolveRegistryPackages(name, matches[0].Packages) +} + +// splitQualifiedName splits "namespace/name" into (namespace, name). +// If the input has no "/" it returns ("", name) unchanged. +func splitQualifiedName(s string) (namespace, name string) { + idx := strings.LastIndex(s, "/") + if idx < 0 { + return "", s + } + return s[:idx], s[idx+1:] +} + +// resolveRegistryPackages selects the best installable package from a registry +// entry. OCI packages are preferred; git is the fallback. +func resolveRegistryPackages(name string, packages []regtypes.SkillPackage) (*registryResolveResult, error) { + // Try OCI packages first (preferred). + for _, pkg := range packages { + if pkg.RegistryType == "oci" && pkg.Identifier != "" { + ref, parseErr := nameref.ParseReference(pkg.Identifier) + if parseErr != nil { + id := truncate(pkg.Identifier, 256) + return nil, httperr.WithCode( + fmt.Errorf("registry skill %q has invalid OCI identifier %q: %w", name, id, parseErr), + http.StatusUnprocessableEntity, + ) + } + return ®istryResolveResult{OCIRef: ref}, nil + } + } + + // Fallback: look for git packages. + for _, pkg := range packages { + if pkg.RegistryType == "git" && pkg.URL != "" { + gitURL, gitErr := buildGitReferenceFromRegistryURL(pkg.URL) + if gitErr != nil { + u := truncate(pkg.URL, 256) + return nil, httperr.WithCode( + fmt.Errorf("registry skill %q has invalid git URL %q: %w", name, u, gitErr), + http.StatusUnprocessableEntity, + ) + } + if pkg.Subfolder != "" { + gitURL += "#" + pkg.Subfolder + } + return ®istryResolveResult{GitURL: gitURL}, nil + } + } + + return nil, httperr.WithCode( + fmt.Errorf("skill %q found in registry but has no installable package (OCI or git)", name), + http.StatusUnprocessableEntity, + ) +} + +// truncate returns s shortened to maxLen with an ellipsis appended if needed. +func truncate(s string, maxLen int) string { + if len(s) > maxLen { + return s[:maxLen] + "..." + } + return s +} + +// buildGitReferenceFromRegistryURL converts a registry URL (typically HTTPS) +// to a git:// scheme reference that ParseGitReference can handle. +func buildGitReferenceFromRegistryURL(rawURL string) (string, error) { + // The registry may store URLs as "https://github.com/org/repo" or + // already as "git://github.com/org/repo". + if gitresolver.IsGitReference(rawURL) { + // Already a git:// URL — validate it. + if _, err := gitresolver.ParseGitReference(rawURL); err != nil { + return "", err + } + return rawURL, nil + } + + // Convert https://host/path → git://host/path + stripped := strings.TrimPrefix(rawURL, "https://") + stripped = strings.TrimPrefix(stripped, "http://") + if stripped == rawURL { + return "", fmt.Errorf("unsupported URL scheme; expected https:// or git://") + } + gitURL := "git://" + stripped + + // Validate the constructed reference. + if _, err := gitresolver.ParseGitReference(gitURL); err != nil { + return "", err + } + return gitURL, nil +} diff --git a/pkg/skills/skillsvc/scope.go b/pkg/skills/skillsvc/scope.go new file mode 100644 index 0000000000..c3f2eb119c --- /dev/null +++ b/pkg/skills/skillsvc/scope.go @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "net/http" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/skills" +) + +func normalizeProjectRoot(scope skills.Scope, projectRoot string) (skills.Scope, string, error) { + normalizedScope, normalizedRoot, err := skills.NormalizeScopeAndProjectRoot(scope, projectRoot) + if err != nil { + return normalizedScope, normalizedRoot, httperr.WithCode(err, http.StatusBadRequest) + } + return normalizedScope, normalizedRoot, nil +} + +// defaultScope returns ScopeUser when s is empty, otherwise returns s unchanged. +func defaultScope(s skills.Scope) skills.Scope { + if s == "" { + return skills.ScopeUser + } + return s +} diff --git a/pkg/skills/skillsvc/service.go b/pkg/skills/skillsvc/service.go new file mode 100644 index 0000000000..ef87558eab --- /dev/null +++ b/pkg/skills/skillsvc/service.go @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package skillsvc provides the default implementation of skills.SkillService. +package skillsvc + +import ( + "sync" + + ociskills "github.com/stacklok/toolhive-core/oci/skills" + regtypes "github.com/stacklok/toolhive-core/registry/types" + "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/skills" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" + "github.com/stacklok/toolhive/pkg/storage" +) + +// Option configures the skill service. +type Option func(*service) + +// WithPathResolver sets the path resolver for skill installations. +func WithPathResolver(pr skills.PathResolver) Option { + return func(s *service) { + s.pathResolver = pr + } +} + +// WithInstaller sets the installer for filesystem operations. +func WithInstaller(inst skills.Installer) Option { + return func(s *service) { + s.installer = inst + } +} + +// WithOCIStore sets the local OCI store for skill artifacts. +func WithOCIStore(store *ociskills.Store) Option { + return func(s *service) { + s.ociStore = store + } +} + +// WithPackager sets the skill packager for building OCI artifacts. +func WithPackager(p ociskills.SkillPackager) Option { + return func(s *service) { + s.packager = p + } +} + +// WithRegistryClient sets the registry client for push/pull operations. +func WithRegistryClient(rc ociskills.RegistryClient) Option { + return func(s *service) { + s.registry = rc + } +} + +// WithGroupManager sets the group manager for skill group membership. +func WithGroupManager(mgr groups.Manager) Option { + return func(s *service) { + s.groupManager = mgr + } +} + +// SkillLookup resolves a plain skill name against a registry/index. +// registry.Provider implicitly satisfies this interface. +type SkillLookup interface { + SearchSkills(query string) ([]regtypes.Skill, error) +} + +// WithSkillLookup sets the registry-based skill lookup for name resolution. +func WithSkillLookup(sl SkillLookup) Option { + return func(s *service) { + s.skillLookup = sl + } +} + +// WithGitResolver sets the git resolver for git:// skill installations. +func WithGitResolver(gr gitresolver.Resolver) Option { + return func(s *service) { + s.gitResolver = gr + } +} + +// skillLock provides per-skill mutual exclusion keyed by scope/name/projectRoot. +// Entries are never evicted. This is acceptable because the number of distinct +// skills on a single machine is expected to remain small (< 1000). +type skillLock struct { + mu sync.Mutex + // locks holds per-key mutexes. INVARIANT: entries must never be deleted + // from this map. The two-phase lock() method depends on pointers remaining + // valid after the global mutex is released. See lock() for details. + locks map[string]*sync.Mutex +} + +// lock acquires a per-skill mutex and returns a function that releases it. +func (sl *skillLock) lock(name string, scope skills.Scope, projectRoot string) func() { + sl.mu.Lock() + key := string(scope) + "/" + name + "/" + projectRoot + m, ok := sl.locks[key] + if !ok { + m = &sync.Mutex{} + sl.locks[key] = m + } + sl.mu.Unlock() + + m.Lock() + return m.Unlock +} + +// service is the default implementation of skills.SkillService. +type service struct { + locks skillLock + store storage.SkillStore + groupManager groups.Manager + pathResolver skills.PathResolver + installer skills.Installer + ociStore *ociskills.Store + packager ociskills.SkillPackager + registry ociskills.RegistryClient + skillLookup SkillLookup + gitResolver gitresolver.Resolver +} + +// New creates a new SkillService backed by the given store. +func New(store storage.SkillStore, opts ...Option) skills.SkillService { + s := &service{ + store: store, + locks: skillLock{locks: make(map[string]*sync.Mutex)}, + } + for _, o := range opts { + o(s) + } + if s.installer == nil { + s.installer = skills.NewInstaller() + } + if s.gitResolver == nil { + s.gitResolver = gitresolver.NewResolver() + } + return s +} diff --git a/pkg/skills/skillsvc/service_test.go b/pkg/skills/skillsvc/service_test.go new file mode 100644 index 0000000000..000d584f57 --- /dev/null +++ b/pkg/skills/skillsvc/service_test.go @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/groups" + groupmocks "github.com/stacklok/toolhive/pkg/groups/mocks" + "github.com/stacklok/toolhive/pkg/skills" + "github.com/stacklok/toolhive/pkg/storage" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestList(t *testing.T) { + t.Parallel() + + projectRoot := makeProjectRoot(t) + + tests := []struct { + name string + opts skills.ListOptions + setupMock func(*storemocks.MockSkillStore) + wantCode int + wantErr string + wantCount int + }{ + { + name: "delegates to store with scope", + opts: skills.ListOptions{Scope: skills.ScopeUser}, + setupMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().List(gomock.Any(), storage.ListFilter{Scope: skills.ScopeUser}). + Return([]skills.InstalledSkill{{Metadata: skills.SkillMetadata{Name: "my-skill"}}}, nil) + }, + wantCount: 1, + }, + { + name: "empty scope returns all", + opts: skills.ListOptions{}, + setupMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().List(gomock.Any(), storage.ListFilter{}). + Return([]skills.InstalledSkill{}, nil) + }, + wantCount: 0, + }, + { + name: "delegates to store with project root", + opts: skills.ListOptions{Scope: skills.ScopeProject, ProjectRoot: projectRoot}, + setupMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().List(gomock.Any(), storage.ListFilter{ + Scope: skills.ScopeProject, + ProjectRoot: projectRoot, + }).Return([]skills.InstalledSkill{}, nil) + }, + wantCount: 0, + }, + { + name: "project scope requires project root", + opts: skills.ListOptions{Scope: skills.ScopeProject}, + setupMock: func(_ *storemocks.MockSkillStore) {}, + wantCode: http.StatusBadRequest, + wantErr: "project_root is required", + }, + { + name: "delegates to store with client app", + opts: skills.ListOptions{ClientApp: "claude-code"}, + setupMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().List(gomock.Any(), storage.ListFilter{ClientApp: "claude-code"}). + Return([]skills.InstalledSkill{}, nil) + }, + wantCount: 0, + }, + { + name: "propagates store errors", + opts: skills.ListOptions{}, + setupMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("db error")) + }, + wantErr: "db error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + tt.setupMock(store) + + result, err := New(store).List(t.Context(), tt.opts) + if tt.wantCode != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Len(t, result, tt.wantCount) + }) + } +} +func TestNewWithZeroOptions(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + + // New(store) without options should work + svc := New(store) + require.NotNil(t, svc) +} +func TestListFiltersByGroup(t *testing.T) { + t.Parallel() + + allSkills := []skills.InstalledSkill{ + {Metadata: skills.SkillMetadata{Name: "skill-a"}}, + {Metadata: skills.SkillMetadata{Name: "skill-b"}}, + {Metadata: skills.SkillMetadata{Name: "skill-c"}}, + } + + tests := []struct { + name string + opts skills.ListOptions + setupMock func(*storemocks.MockSkillStore, *groupmocks.MockManager) + wantNames []string + wantCode int + wantErr string + }{ + { + name: "no group filter returns all skills", + opts: skills.ListOptions{}, + setupMock: func(s *storemocks.MockSkillStore, _ *groupmocks.MockManager) { + s.EXPECT().List(gomock.Any(), storage.ListFilter{}).Return(allSkills, nil) + }, + wantNames: []string{"skill-a", "skill-b", "skill-c"}, + }, + { + name: "group filter returns only matching skills", + opts: skills.ListOptions{Group: "mygroup"}, + setupMock: func(s *storemocks.MockSkillStore, gm *groupmocks.MockManager) { + s.EXPECT().List(gomock.Any(), storage.ListFilter{}).Return(allSkills, nil) + gm.EXPECT().Get(gomock.Any(), "mygroup").Return(&groups.Group{ + Name: "mygroup", + Skills: []string{"skill-a", "skill-c"}, + }, nil) + }, + wantNames: []string{"skill-a", "skill-c"}, + }, + { + name: "group filter with empty group skills returns no skills", + opts: skills.ListOptions{Group: "emptygroup"}, + setupMock: func(s *storemocks.MockSkillStore, gm *groupmocks.MockManager) { + s.EXPECT().List(gomock.Any(), storage.ListFilter{}).Return(allSkills, nil) + gm.EXPECT().Get(gomock.Any(), "emptygroup").Return(&groups.Group{ + Name: "emptygroup", + Skills: []string{}, + }, nil) + }, + wantNames: []string{}, + }, + { + name: "group filter without group manager returns error", + opts: skills.ListOptions{Group: "mygroup"}, + setupMock: func(s *storemocks.MockSkillStore, _ *groupmocks.MockManager) { + s.EXPECT().List(gomock.Any(), storage.ListFilter{}).Return(allSkills, nil) + }, + wantCode: http.StatusInternalServerError, + wantErr: "group manager is not configured", + }, + { + name: "group manager Get error propagates", + opts: skills.ListOptions{Group: "badgroup"}, + setupMock: func(s *storemocks.MockSkillStore, gm *groupmocks.MockManager) { + s.EXPECT().List(gomock.Any(), storage.ListFilter{}).Return(allSkills, nil) + gm.EXPECT().Get(gomock.Any(), "badgroup").Return(nil, fmt.Errorf("group not found")) + }, + wantErr: "getting group", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + gm := groupmocks.NewMockManager(ctrl) + tt.setupMock(store, gm) + + opts := []Option{} + // Only wire the group manager for tests that don't test the nil-manager case. + if tt.wantCode != http.StatusInternalServerError { + opts = append(opts, WithGroupManager(gm)) + } + svc := New(store, opts...) + + result, err := svc.List(t.Context(), tt.opts) + if tt.wantCode != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, sk := range result { + names = append(names, sk.Metadata.Name) + } + if tt.wantNames == nil { + tt.wantNames = []string{} + } + if names == nil { + names = []string{} + } + assert.ElementsMatch(t, tt.wantNames, names) + }) + } +} diff --git a/pkg/skills/skillsvc/skillsvc.go b/pkg/skills/skillsvc/skillsvc.go deleted file mode 100644 index 545f4d1b10..0000000000 --- a/pkg/skills/skillsvc/skillsvc.go +++ /dev/null @@ -1,1982 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -// Package skillsvc provides the default implementation of skills.SkillService. -package skillsvc - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "net/http" - "os" - "path/filepath" - "slices" - "strings" - "sync" - "time" - - nameref "github.com/google/go-containerregistry/pkg/name" - "github.com/opencontainers/go-digest" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - - "github.com/stacklok/toolhive-core/httperr" - ociskills "github.com/stacklok/toolhive-core/oci/skills" - regtypes "github.com/stacklok/toolhive-core/registry/types" - "github.com/stacklok/toolhive/pkg/client" - "github.com/stacklok/toolhive/pkg/groups" - "github.com/stacklok/toolhive/pkg/skills" - "github.com/stacklok/toolhive/pkg/skills/gitresolver" - "github.com/stacklok/toolhive/pkg/storage" -) - -// Option configures the skill service. -type Option func(*service) - -// WithPathResolver sets the path resolver for skill installations. -func WithPathResolver(pr skills.PathResolver) Option { - return func(s *service) { - s.pathResolver = pr - } -} - -// WithInstaller sets the installer for filesystem operations. -func WithInstaller(inst skills.Installer) Option { - return func(s *service) { - s.installer = inst - } -} - -// WithOCIStore sets the local OCI store for skill artifacts. -func WithOCIStore(store *ociskills.Store) Option { - return func(s *service) { - s.ociStore = store - } -} - -// WithPackager sets the skill packager for building OCI artifacts. -func WithPackager(p ociskills.SkillPackager) Option { - return func(s *service) { - s.packager = p - } -} - -// WithRegistryClient sets the registry client for push/pull operations. -func WithRegistryClient(rc ociskills.RegistryClient) Option { - return func(s *service) { - s.registry = rc - } -} - -// WithGroupManager sets the group manager for skill group membership. -func WithGroupManager(mgr groups.Manager) Option { - return func(s *service) { - s.groupManager = mgr - } -} - -// SkillLookup resolves a plain skill name against a registry/index. -// registry.Provider implicitly satisfies this interface. -type SkillLookup interface { - SearchSkills(query string) ([]regtypes.Skill, error) -} - -// WithSkillLookup sets the registry-based skill lookup for name resolution. -func WithSkillLookup(sl SkillLookup) Option { - return func(s *service) { - s.skillLookup = sl - } -} - -// WithGitResolver sets the git resolver for git:// skill installations. -func WithGitResolver(gr gitresolver.Resolver) Option { - return func(s *service) { - s.gitResolver = gr - } -} - -// skillLock provides per-skill mutual exclusion keyed by scope/name/projectRoot. -// Entries are never evicted. This is acceptable because the number of distinct -// skills on a single machine is expected to remain small (< 1000). -type skillLock struct { - mu sync.Mutex - // locks holds per-key mutexes. INVARIANT: entries must never be deleted - // from this map. The two-phase lock() method depends on pointers remaining - // valid after the global mutex is released. See lock() for details. - locks map[string]*sync.Mutex -} - -// lock acquires a per-skill mutex and returns a function that releases it. -func (sl *skillLock) lock(name string, scope skills.Scope, projectRoot string) func() { - sl.mu.Lock() - key := string(scope) + "/" + name + "/" + projectRoot - m, ok := sl.locks[key] - if !ok { - m = &sync.Mutex{} - sl.locks[key] = m - } - sl.mu.Unlock() - - m.Lock() - return m.Unlock -} - -// service is the default implementation of skills.SkillService. -type service struct { - locks skillLock - store storage.SkillStore - groupManager groups.Manager - pathResolver skills.PathResolver - installer skills.Installer - ociStore *ociskills.Store - packager ociskills.SkillPackager - registry ociskills.RegistryClient - skillLookup SkillLookup - gitResolver gitresolver.Resolver -} - -// New creates a new SkillService backed by the given store. -func New(store storage.SkillStore, opts ...Option) skills.SkillService { - s := &service{ - store: store, - locks: skillLock{locks: make(map[string]*sync.Mutex)}, - } - for _, o := range opts { - o(s) - } - if s.installer == nil { - s.installer = skills.NewInstaller() - } - if s.gitResolver == nil { - s.gitResolver = gitresolver.NewResolver() - } - return s -} - -// List returns all installed skills matching the given options. -func (s *service) List(ctx context.Context, opts skills.ListOptions) ([]skills.InstalledSkill, error) { - scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) - if err != nil { - return nil, err - } - filter := storage.ListFilter{ - Scope: scope, - ClientApp: opts.ClientApp, - ProjectRoot: projectRoot, - } - all, err := s.store.List(ctx, filter) - if err != nil { - return nil, err - } - - if opts.Group == "" { - return all, nil - } - - if s.groupManager == nil { - return nil, httperr.WithCode( - fmt.Errorf("group filtering is not available: group manager is not configured"), - http.StatusInternalServerError, - ) - } - - group, err := s.groupManager.Get(ctx, opts.Group) - if err != nil { - return nil, fmt.Errorf("getting group %q: %w", opts.Group, err) - } - - // Build a lookup set of skill names in the group. - groupSkills := make(map[string]struct{}, len(group.Skills)) - for _, name := range group.Skills { - groupSkills[name] = struct{}{} - } - - filtered := make([]skills.InstalledSkill, 0, len(all)) - for _, sk := range all { - if _, ok := groupSkills[sk.Metadata.Name]; ok { - filtered = append(filtered, sk) - } - } - return filtered, nil -} - -// Install installs a skill. When the Name field contains an OCI reference -// (detected by the presence of '/', ':', or '@'), the artifact is pulled from -// the registry and extracted. When LayerData is provided, the skill is extracted -// to disk and a full installation record is created. Without LayerData, a -// pending record is created. -func (s *service) Install(ctx context.Context, opts skills.InstallOptions) (*skills.InstallResult, error) { - scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) - if err != nil { - return nil, err - } - scope = defaultScope(scope) - // Canonicalize the project root so that equivalent paths produce - // the same lock key and DB record. - opts.ProjectRoot = projectRoot - - // Git references (git://host/owner/repo[@ref][#path]) are dispatched first; - // the prefix is unambiguous and cannot collide with OCI references. - if gitresolver.IsGitReference(opts.Name) { - result, err := s.installFromGit(ctx, opts, scope) - if err != nil { - return nil, err - } - return s.installAndRegister(ctx, result, opts.Group, result.Skill.Metadata.Name, scope, opts.ProjectRoot) - } - - ref, isOCI, err := parseOCIReference(opts.Name) - if err != nil { - return nil, httperr.WithCode( - fmt.Errorf("invalid OCI reference %q: %w", opts.Name, err), - http.StatusBadRequest, - ) - } - if isOCI { - result, ociErr := s.installFromOCI(ctx, opts, scope, ref) - if ociErr == nil { - return s.installAndRegister(ctx, result, opts.Group, opts.Name, scope, opts.ProjectRoot) - } - // OCI pull failed — fall back to registry lookup for names that look - // like a qualified "namespace/name". Names that are unambiguously OCI - // (digest, explicit tag, or multi-segment path) must not trigger a - // registry search. See isUnambiguousOCIRef for the full rule set. - if isUnambiguousOCIRef(opts.Name, ref) { - return nil, ociErr - } - slog.Debug("OCI pull failed, attempting registry fallback", "name", opts.Name, "error", ociErr) - resolved, regErr := s.resolveFromRegistry(opts.Name) - if regErr != nil { - return nil, regErr - } - if resolved != nil { - return s.installFromResolvedRegistry(ctx, opts, scope, resolved) - } - return nil, ociErr - } - - // Plain skill name — validate and proceed with existing flow. - if err := skills.ValidateSkillName(opts.Name); err != nil { - return nil, httperr.WithCode(err, http.StatusBadRequest) - } - - return s.installByName(ctx, opts, scope) -} - -// installByName handles installation for a validated plain skill name. It -// checks the local OCI store and registry before falling back to an error. -func (s *service) installByName( - ctx context.Context, - opts skills.InstallOptions, - scope skills.Scope, -) (*skills.InstallResult, error) { - unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) - locked := true - defer func() { - if locked { - unlock() - } - }() - - // Without layer data, check the local OCI store for a matching tag, - // then the registry/index, before returning an error. - if len(opts.LayerData) == 0 { - resolved := false - if s.ociStore != nil { - var resolveErr error - // Pass pointer to hydrate opts with layer data, digest, and version. - resolved, resolveErr = s.resolveFromLocalStore(ctx, &opts) - if resolveErr != nil { - return nil, resolveErr - } - } - if !resolved { - // Release lock before registry lookup -- installFromOCI - // acquires its own lock on the artifact's skill name, which - // could be the same key, causing deadlock since sync.Mutex - // is not re-entrant. - unlock() - locked = false - - return s.installFromRegistryLookup(ctx, opts, scope) - } - // resolved: opts hydrated, fall through to installWithExtraction - } - - result, err := s.installWithExtraction(ctx, opts, scope) - if err != nil { - return nil, err - } - return s.installAndRegister(ctx, result, opts.Group, opts.Name, scope, opts.ProjectRoot) -} - -// installFromRegistryLookup resolves a plain skill name via the registry and -// dispatches to the appropriate installer (OCI or git). -func (s *service) installFromRegistryLookup( - ctx context.Context, - opts skills.InstallOptions, - scope skills.Scope, -) (*skills.InstallResult, error) { - resolved, regErr := s.resolveFromRegistry(opts.Name) - if regErr != nil { - return nil, regErr - } - if resolved != nil { - return s.installFromResolvedRegistry(ctx, opts, scope, resolved) - } - - return nil, httperr.WithCode( - fmt.Errorf("skill %q not found in local store or registry;"+ - " install by OCI reference:\n thv skill install ghcr.io/<namespace>/%s:<version>", - opts.Name, opts.Name), - http.StatusNotFound, - ) -} - -// installFromResolvedRegistry dispatches an install to the appropriate -// backend (OCI or git) based on the result of a registry lookup. -func (s *service) installFromResolvedRegistry( - ctx context.Context, - opts skills.InstallOptions, - scope skills.Scope, - resolved *registryResolveResult, -) (*skills.InstallResult, error) { - switch { - case resolved.OCIRef != nil: - slog.Info("resolved skill from registry (OCI)", "name", opts.Name, "oci_reference", resolved.OCIRef.String()) - opts.Name = resolved.OCIRef.String() - result, ociErr := s.installFromOCI(ctx, opts, scope, resolved.OCIRef) - if ociErr != nil { - return nil, ociErr - } - // Use the skill name extracted from the artifact, not opts.Name which - // holds the OCI ref string. installFromOCI mutates its own copy of opts - // (Go pass-by-value), so the caller never sees the updated name. - return s.installAndRegister(ctx, result, opts.Group, result.Skill.Metadata.Name, scope, opts.ProjectRoot) - case resolved.GitURL != "": - slog.Info("resolved skill from registry (git)", "name", opts.Name, "git_url", resolved.GitURL) - opts.Name = resolved.GitURL - result, gitErr := s.installFromGit(ctx, opts, scope) - if gitErr != nil { - return nil, gitErr - } - return s.installAndRegister(ctx, result, opts.Group, result.Skill.Metadata.Name, scope, opts.ProjectRoot) - } - return nil, httperr.WithCode( - fmt.Errorf("skill %q resolved from registry but has no installable package", opts.Name), - http.StatusUnprocessableEntity, - ) -} - -// Uninstall removes an installed skill and cleans up files for all clients. -func (s *service) Uninstall(ctx context.Context, opts skills.UninstallOptions) error { - if err := skills.ValidateSkillName(opts.Name); err != nil { - return httperr.WithCode(err, http.StatusBadRequest) - } - - scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) - if err != nil { - return err - } - scope = defaultScope(scope) - opts.ProjectRoot = projectRoot - - unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) - defer unlock() - - // Look up the existing record to find which clients have files. - existing, err := s.store.Get(ctx, opts.Name, scope, opts.ProjectRoot) - if err != nil { - return err - } - - // Determine the boundary directory for empty-parent cleanup. - stopDir := opts.ProjectRoot - if scope == skills.ScopeUser { - if homeDir, err := os.UserHomeDir(); err == nil { - stopDir = homeDir - } - } - - // Remove files for each client — best-effort: collect errors but don't - // abort on the first failure so we clean up as much as possible. - var cleanupErrs []error - if s.pathResolver != nil { - for _, clientType := range existing.Clients { - skillPath, pathErr := s.pathResolver.GetSkillPath(clientType, opts.Name, scope, opts.ProjectRoot) - if pathErr != nil { - cleanupErrs = append(cleanupErrs, fmt.Errorf("resolving path for client %q: %w", clientType, pathErr)) - continue - } - if rmErr := s.installer.Remove(skillPath); rmErr != nil { - cleanupErrs = append(cleanupErrs, fmt.Errorf("removing files for client %q: %w", clientType, rmErr)) - continue - } - if stopDir != "" { - skills.RemoveEmptyParents(filepath.Dir(skillPath), stopDir) - } - } - } - - if err := s.store.Delete(ctx, opts.Name, scope, opts.ProjectRoot); err != nil { - return err - } - - // Remove the skill from all groups — best-effort, same pattern as file cleanup. - if s.groupManager != nil { - if groupErr := groups.RemoveSkillFromAllGroups(ctx, s.groupManager, opts.Name); groupErr != nil { - cleanupErrs = append(cleanupErrs, fmt.Errorf("removing skill from groups: %w", groupErr)) - } - } - - return errors.Join(cleanupErrs...) -} - -// Info returns detailed information about a skill. -func (s *service) Info(ctx context.Context, opts skills.InfoOptions) (*skills.SkillInfo, error) { - if err := skills.ValidateSkillName(opts.Name); err != nil { - return nil, httperr.WithCode(err, http.StatusBadRequest) - } - - scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) - if err != nil { - return nil, err - } - scope = defaultScope(scope) - - skill, err := s.store.Get(ctx, opts.Name, scope, projectRoot) - if err != nil { - return nil, err - } - - return &skills.SkillInfo{ - Metadata: skill.Metadata, - InstalledSkill: &skill, - }, nil -} - -// GetContent retrieves the SKILL.md body and file listing from a skill artifact -// without installing it. The reference may be: -// - A local build tag (e.g. "my-skill") -// - A fully-qualified OCI reference (e.g. "ghcr.io/org/skill:v1") -// - A git:// reference (e.g. "git://github.com/org/repo#path/to/skill") -// - An https:// URL (converted to git:// internally) -// -// Resolution order: git (git:// and https://) → OCI (local store, then remote -// pull) → registry catalog lookup. -func (s *service) GetContent(ctx context.Context, opts skills.ContentOptions) (*skills.SkillContent, error) { - ref := opts.Reference - if ref == "" { - return nil, httperr.WithCode( - errors.New("reference is required"), - http.StatusBadRequest, - ) - } - - // Git references (git:// or https://) are dispatched first since their - // scheme prefix is unambiguous and cannot collide with OCI references. - if gitresolver.IsGitReference(ref) { - return s.getContentFromGit(ctx, ref) - } - if isHTTPURL(ref) { - gitURL, err := buildGitReferenceFromRegistryURL(ref) - if err != nil { - return nil, httperr.WithCode( - fmt.Errorf("invalid URL %q: %w", ref, err), - http.StatusBadRequest, - ) - } - return s.getContentFromGit(ctx, gitURL) - } - - // Try OCI resolution (local store + remote pull). If this succeeds, return. - content, ociErr := s.getContentFromOCI(ctx, ref) - if ociErr == nil { - return content, nil - } - - // OCI failed — try resolving via registry name lookup (e.g. "skill-creator" - // or "io.github.stacklok/skill-creator" from the catalog index). Skip for - // refs that are unambiguously OCI (digest, explicit tag, or multi-segment - // path) to avoid a wasted network round-trip. - if parsedRef, isOCI, parseErr := parseOCIReference(ref); parseErr == nil && isOCI && - isUnambiguousOCIRef(ref, parsedRef) { - return nil, ociErr - } - resolved, regErr := s.resolveFromRegistry(ref) - if regErr != nil { - return nil, regErr - } - if resolved != nil { - switch { - case resolved.OCIRef != nil: - return s.getContentFromOCI(ctx, resolved.OCIRef.String()) - case resolved.GitURL != "": - return s.getContentFromGit(ctx, resolved.GitURL) - } - } - - // Nothing matched — return the original OCI error. - return nil, ociErr -} - -// getContentFromGit clones a git repository and extracts the SKILL.md content. -func (s *service) getContentFromGit(ctx context.Context, ref string) (*skills.SkillContent, error) { - if s.gitResolver == nil { - return nil, httperr.WithCode( - errors.New("git resolver is not configured"), - http.StatusInternalServerError, - ) - } - - gitRef, err := gitresolver.ParseGitReference(ref) - if err != nil { - return nil, httperr.WithCode( - fmt.Errorf("invalid git reference: %w", err), - http.StatusBadRequest, - ) - } - - resolved, err := s.gitResolver.Resolve(ctx, gitRef) - if err != nil { - return nil, httperr.WithCode( - fmt.Errorf("resolving git skill: %w", err), - http.StatusBadGateway, - ) - } - - content := &skills.SkillContent{ - Name: resolved.SkillConfig.Name, - Description: resolved.SkillConfig.Description, - Version: resolved.SkillConfig.Version, - License: resolved.SkillConfig.License, - Body: string(resolved.SkillConfig.Body), - Files: make([]skills.SkillFileEntry, 0, len(resolved.Files)), - } - - for _, f := range resolved.Files { - content.Files = append(content.Files, skills.SkillFileEntry{ - Path: f.Path, - Size: len(f.Content), - }) - } - - return content, nil -} - -// getContentFromOCI resolves a reference from the local OCI store or pulls it -// from a remote registry, then extracts the SKILL.md content. -func (s *service) getContentFromOCI(ctx context.Context, ref string) (*skills.SkillContent, error) { - if s.ociStore == nil { - return nil, httperr.WithCode( - errors.New("OCI store is not configured"), - http.StatusInternalServerError, - ) - } - - // Try the local store first (covers local builds by tag name and - // previously pulled remote refs tagged by Pull). - d, resolveErr := s.ociStore.Resolve(ctx, ref) - if resolveErr != nil { - if s.registry == nil { - return nil, httperr.WithCode( - fmt.Errorf("reference %q not found in local store and OCI registry is not configured", ref), - http.StatusBadRequest, - ) - } - - ociRef, isOCI, parseErr := parseOCIReference(ref) - if parseErr != nil { - return nil, httperr.WithCode( - fmt.Errorf("invalid reference %q: %w", ref, parseErr), - http.StatusBadRequest, - ) - } - if !isOCI { - return nil, httperr.WithCode( - fmt.Errorf("reference %q not found in local store and is not a valid OCI reference", ref), - http.StatusBadRequest, - ) - } - - qualifiedRef := qualifiedOCIRef(ociRef) - pullCtx, cancel := context.WithTimeout(ctx, ociPullTimeout) - defer cancel() - - var pullErr error - d, pullErr = s.registry.Pull(pullCtx, s.ociStore, qualifiedRef) - if pullErr != nil { - return nil, httperr.WithCode( - fmt.Errorf("pulling OCI artifact %q: %w", qualifiedRef, pullErr), - http.StatusBadRequest, - ) - } - } - - layerData, skillConfig, err := s.extractOCIContent(ctx, d) - if err != nil { - return nil, err - } - - entries, err := ociskills.DecompressTar(layerData) - if err != nil { - return nil, fmt.Errorf("decompressing skill layer: %w", err) - } - - content := &skills.SkillContent{ - Name: skillConfig.Name, - Description: skillConfig.Description, - Version: skillConfig.Version, - License: skillConfig.License, - Files: make([]skills.SkillFileEntry, 0, len(entries)), - } - - for _, entry := range entries { - content.Files = append(content.Files, skills.SkillFileEntry{ - Path: entry.Path, - Size: len(entry.Content), - }) - if strings.EqualFold(filepath.Base(entry.Path), "SKILL.md") { - content.Body = string(entry.Content) - } - } - - return content, nil -} - -// isHTTPURL returns true if the reference starts with http:// or https://. -func isHTTPURL(ref string) bool { - return strings.HasPrefix(ref, "https://") || strings.HasPrefix(ref, "http://") -} - -// Validate checks whether a skill definition is valid. -func (*service) Validate(_ context.Context, path string) (*skills.ValidationResult, error) { - if err := validateLocalPath(path); err != nil { - return nil, err - } - return skills.ValidateSkillDir(path) -} - -// Build packages a skill directory into a local OCI artifact. -func (s *service) Build(ctx context.Context, opts skills.BuildOptions) (*skills.BuildResult, error) { - if s.packager == nil || s.ociStore == nil { - return nil, httperr.WithCode( - errors.New("OCI packaging is not configured"), - http.StatusInternalServerError, - ) - } - if err := validateLocalPath(opts.Path); err != nil { - return nil, err - } - result, err := s.packager.Package(ctx, opts.Path, ociskills.DefaultPackageOptions()) - if err != nil { - return nil, fmt.Errorf("packaging skill: %w", err) - } - - // Tag resolution precedence: - // 1. Explicit tag from BuildOptions.Tag - // 2. Skill name from the parsed config (SKILL.md frontmatter) - // 3. No tag — use raw digest as the reference - tag := func() string { - if opts.Tag != "" { - return opts.Tag - } - if result.Config != nil && result.Config.Name != "" { - return result.Config.Name - } - return "" - }() - - if tag != "" { - if err := validateOCITagOrReference(tag); err != nil { - return nil, err - } - } - - if tag != "" { - if tagErr := s.ociStore.Tag(ctx, result.IndexDigest, tag); tagErr != nil { - return nil, fmt.Errorf("tagging artifact: %w", tagErr) - } - } - - ref := func() string { - if tag != "" { - return tag - } - return result.IndexDigest.String() - }() - - return &skills.BuildResult{Reference: ref}, nil -} - -// Push pushes a locally built skill artifact to a remote OCI registry. -func (s *service) Push(ctx context.Context, opts skills.PushOptions) error { - if s.registry == nil || s.ociStore == nil { - return httperr.WithCode( - errors.New("OCI registry is not configured"), - http.StatusInternalServerError, - ) - } - if opts.Reference == "" { - return httperr.WithCode( - errors.New("reference is required"), - http.StatusBadRequest, - ) - } - - d, err := s.ociStore.Resolve(ctx, opts.Reference) - if err != nil { - slog.Debug("failed to resolve OCI reference", "reference", opts.Reference, "error", err) - return httperr.WithCode( - fmt.Errorf("reference %q not found in local store", opts.Reference), - http.StatusNotFound, - ) - } - - if err := s.registry.Push(ctx, s.ociStore, d, opts.Reference); err != nil { - return fmt.Errorf("pushing to registry: %w", err) - } - - return nil -} - -// ListBuilds returns all locally-built OCI skill artifacts in the local store. -func (s *service) ListBuilds(ctx context.Context) ([]skills.LocalBuild, error) { - if s.ociStore == nil { - return nil, httperr.WithCode( - errors.New("OCI packaging is not configured"), - http.StatusInternalServerError, - ) - } - - tags, err := s.ociStore.ListTags(ctx) - if err != nil { - return nil, fmt.Errorf("listing local OCI builds: %w", err) - } - - builds := make([]skills.LocalBuild, 0, len(tags)) - for _, tag := range tags { - d, resolveErr := s.ociStore.Resolve(ctx, tag) - if resolveErr != nil { - slog.Debug("failed to resolve tag in local OCI store", "tag", tag, "error", resolveErr) - continue - } - - isSkill, typeErr := s.isSkillArtifact(ctx, d) - if typeErr != nil { - slog.Debug("failed to check artifact type in local OCI store", "tag", tag, "error", typeErr) - continue - } - if !isSkill { - continue - } - - build := skills.LocalBuild{ - Tag: tag, - Digest: d.String(), - } - - // Best-effort: enrich with skill metadata from the OCI config labels. - if _, cfg, extractErr := s.extractOCIContent(ctx, d); extractErr == nil && cfg != nil { - build.Name = cfg.Name - build.Description = cfg.Description - build.Version = cfg.Version - } else if extractErr != nil { - slog.Debug("failed to extract skill config from local build", "tag", tag, "error", extractErr) - } - - builds = append(builds, build) - } - - return builds, nil -} - -// DeleteBuild removes a locally-built OCI skill artifact from the local store. -// It deletes the tag and, when no other tag shares the same digest, also -// garbage-collects all associated blobs. -func (s *service) DeleteBuild(ctx context.Context, tag string) error { - if s.ociStore == nil { - return httperr.WithCode( - errors.New("OCI packaging is not configured"), - http.StatusInternalServerError, - ) - } - return s.ociStore.DeleteBuild(ctx, tag) -} - -// ociPullTimeout is the maximum time allowed for pulling an OCI artifact. -const ociPullTimeout = 5 * time.Minute - -// maxCompressedLayerSize is the maximum compressed layer size we'll load into -// memory. Skills are typically small (< 1MB compressed); this limit prevents a -// malicious artifact from causing OOM before the decompression limits kick in. -const maxCompressedLayerSize int64 = 50 * 1024 * 1024 // 50 MB - -// qualifiedOCIRef returns the full OCI reference string including the tag or -// digest. When the user omits a tag (e.g. "ghcr.io/org/skill"), -// go-containerregistry's ParseReference defaults to "latest" internally but -// String() omits it. This function only appends the implicit tag when the -// original string does not already include one. -func qualifiedOCIRef(ref nameref.Reference) string { - s := ref.String() - if _, ok := ref.(nameref.Digest); ok { - return s // already has @sha256:... - } - if strings.Contains(s, ":") { - return s // already has an explicit tag - } - return s + ":" + ref.Identifier() -} - -func parseOCIReference(name string) (nameref.Reference, bool, error) { - // Structural check: skill names never contain '/', ':', or '@'. - // OCI references always require at least one of these. - if !strings.ContainsAny(name, "/:@") { - return nil, false, nil - } - - ref, err := nameref.ParseReference(name) - if err != nil { - return nil, true, err - } - return ref, true, nil -} - -// isUnambiguousOCIRef reports whether raw was clearly intended by the user as -// an OCI reference, meaning a failed pull must NOT fall back to a registry -// catalogue lookup. A ref is unambiguous if any of the following hold: -// -// - the parsed form is a digest reference (e.g. "name@sha256:...") -// - the raw string contains ':' (explicit tag such as "name:v1") -// - the raw string has more than one '/' (multi-segment path such as -// "ghcr.io/org/skill") -// -// The parsed Reference alone is insufficient for the tag case: -// nameref.ParseReference normalizes "foo/bar" to "foo/bar:latest" (a name.Tag), -// making it indistinguishable from an explicitly tagged reference. We therefore -// rely on the parsed form for the digest check and fall back to string -// inspection for the tag and segment-count checks. -func isUnambiguousOCIRef(raw string, ref nameref.Reference) bool { - if _, isDigest := ref.(nameref.Digest); isDigest { - return true - } - return strings.Contains(raw, ":") || strings.Count(raw, "/") > 1 -} - -// installFromOCI pulls a skill artifact from a remote registry, extracts -// metadata and layer data, then delegates to the standard extraction flow. -func (s *service) installFromOCI( - ctx context.Context, - opts skills.InstallOptions, - scope skills.Scope, - ref nameref.Reference, -) (*skills.InstallResult, error) { - if s.registry == nil || s.ociStore == nil { - return nil, httperr.WithCode( - errors.New("OCI registry is not configured"), - http.StatusInternalServerError, - ) - } - if s.pathResolver == nil { - return nil, httperr.WithCode( - errors.New("path resolver is required for OCI installs"), - http.StatusInternalServerError, - ) - } - - ociRef := qualifiedOCIRef(ref) - - pullCtx, cancel := context.WithTimeout(ctx, ociPullTimeout) - defer cancel() - - pulledDigest, err := s.registry.Pull(pullCtx, s.ociStore, ociRef) - if err != nil { - return nil, httperr.WithCode( - fmt.Errorf("pulling OCI artifact %q: %w", ociRef, err), - http.StatusBadRequest, - ) - } - - layerData, skillConfig, err := s.extractOCIContent(ctx, pulledDigest) - if err != nil { - return nil, err - } - - if err := skills.ValidateSkillName(skillConfig.Name); err != nil { - return nil, httperr.WithCode( - fmt.Errorf("skill artifact contains invalid name: %w", err), - http.StatusUnprocessableEntity, - ) - } - - // Supply chain defense: the declared skill name must match the last path - // component of the OCI reference. The Agent Skills spec requires that the - // name field matches the parent directory name; by extension, it should - // match the repository name in the OCI reference. A mismatch could - // indicate a supply chain attack (e.g., a trusted reference pointing to - // an artifact that overwrites a different skill). - repo := ref.Context().RepositoryStr() - if idx := strings.LastIndex(repo, "/"); idx >= 0 { - repo = repo[idx+1:] - } - if repo != skillConfig.Name { - return nil, httperr.WithCode( - fmt.Errorf( - "skill name %q in artifact does not match OCI reference repository %q", - skillConfig.Name, repo, - ), - http.StatusUnprocessableEntity, - ) - } - - // Hydrate install options from the pulled artifact. - opts.Name = skillConfig.Name - opts.LayerData = layerData - opts.Reference = ociRef - opts.Digest = pulledDigest.String() - if opts.Version == "" && skillConfig.Version != "" { - opts.Version = skillConfig.Version - } - // Note: version is optional; if both are empty, install without a version. - - unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) - defer unlock() - - return s.installWithExtraction(ctx, opts, scope) -} - -// installFromGit clones a git repository, extracts the skill, writes files to -// disk, and creates a DB record. The digest is the git commit hash, enabling -// same-commit no-op and upgrade detection. -func (s *service) installFromGit( - ctx context.Context, - opts skills.InstallOptions, - scope skills.Scope, -) (*skills.InstallResult, error) { - if s.gitResolver == nil { - return nil, httperr.WithCode( - errors.New("git resolver is not configured"), - http.StatusInternalServerError, - ) - } - if s.pathResolver == nil { - return nil, httperr.WithCode( - errors.New("path resolver is required for git installs"), - http.StatusInternalServerError, - ) - } - - // Parse the git:// reference. - gitRef, err := gitresolver.ParseGitReference(opts.Name) - if err != nil { - return nil, httperr.WithCode( - fmt.Errorf("invalid git reference: %w", err), - http.StatusBadRequest, - ) - } - - // Preserve the original git:// URL for provenance tracking. - gitURL := opts.Name - - // Clone, read SKILL.md, collect files. - resolved, err := s.gitResolver.Resolve(ctx, gitRef) - if err != nil { - return nil, httperr.WithCode( - fmt.Errorf("resolving git skill: %w", err), - http.StatusBadGateway, - ) - } - - if err := skills.ValidateSkillName(resolved.SkillConfig.Name); err != nil { - return nil, httperr.WithCode( - fmt.Errorf("skill contains invalid name: %w", err), - http.StatusUnprocessableEntity, - ) - } - - // Hydrate install options from the git result. - opts.Name = resolved.SkillConfig.Name - opts.Reference = gitURL - opts.Digest = resolved.CommitHash - if opts.Version == "" && resolved.SkillConfig.Version != "" { - opts.Version = resolved.SkillConfig.Version - } - - unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) - defer unlock() - - clientTypes, clientDirs, err := s.resolveAndValidateClients(opts, opts.Name, scope, opts.ProjectRoot) - if err != nil { - return nil, err - } - - return s.applyGitInstall(ctx, opts, scope, clientTypes, clientDirs, resolved.Files) -} - -// applyGitInstall handles the create/upgrade/no-op logic for a git-based skill -// install. It checks the store for an existing record, writes files, and -// persists the result. -func (s *service) applyGitInstall( - ctx context.Context, - opts skills.InstallOptions, - scope skills.Scope, - clientTypes []string, - clientDirs map[string]string, - files []gitresolver.FileEntry, -) (*skills.InstallResult, error) { - existing, storeErr := s.store.Get(ctx, opts.Name, scope, opts.ProjectRoot) - isNotFound := errors.Is(storeErr, storage.ErrNotFound) - if storeErr != nil && !isNotFound { - return nil, fmt.Errorf("checking existing skill: %w", storeErr) - } - if !isNotFound { - return s.applyGitInstallExisting(ctx, opts, scope, existing, clientTypes, clientDirs, files) - } - return s.applyGitInstallFresh(ctx, opts, scope, clientTypes, clientDirs, files) -} - -func (s *service) applyGitInstallExisting( - ctx context.Context, - opts skills.InstallOptions, - scope skills.Scope, - existing skills.InstalledSkill, - clientTypes []string, - clientDirs map[string]string, - files []gitresolver.FileEntry, -) (*skills.InstallResult, error) { - if existing.Digest != opts.Digest { - allClients, allDirs, err := s.expandToExistingClients( - existing.Clients, clientTypes, clientDirs, opts.Name, scope, opts.ProjectRoot) - if err != nil { - return nil, err - } - // Deduplicate so clients sharing the same directory don't conflict. - dirsToWrite := uniqueDirClients(allClients, allDirs, nil) - return s.gitWriteMultiAndPersist(ctx, opts, scope, allClients, allDirs, files, - dirsToWrite, nil, true, true) - } - clientsExplicit := len(opts.Clients) > 0 - if clientsContainAll(existing.Clients, clientTypes) || - (len(existing.Clients) == 0 && len(clientTypes) <= 1 && !clientsExplicit) { - return &skills.InstallResult{Skill: existing}, nil - } - toWrite := missingClients(existing.Clients, clientTypes) - if len(toWrite) == 0 { - return &skills.InstallResult{Skill: existing}, nil - } - // Deduplicate and skip directories already owned by existing clients. - dirsToWrite := uniqueDirClients(toWrite, clientDirs, existingClientDirs(existing.Clients, clientDirs)) - if len(dirsToWrite) == 0 { - return s.gitWriteMultiAndPersist(ctx, opts, scope, clientTypes, clientDirs, files, - nil, existing.Clients, true, false) - } - for _, ct := range dirsToWrite { - dir := filepath.Clean(clientDirs[ct]) - if _, statErr := os.Stat(dir); statErr == nil && !opts.Force { // lgtm[go/path-injection] - return nil, httperr.WithCode( - fmt.Errorf("directory %q exists but is not managed by ToolHive; use force to overwrite", dir), - http.StatusConflict, - ) - } - } - return s.gitWriteMultiAndPersist(ctx, opts, scope, clientTypes, clientDirs, files, - dirsToWrite, existing.Clients, true, false) -} - -func missingClients(existing, requested []string) []string { - var out []string - for _, ct := range requested { - if !slices.Contains(existing, ct) { - out = append(out, ct) - } - } - return out -} - -func (s *service) applyGitInstallFresh( - ctx context.Context, - opts skills.InstallOptions, - scope skills.Scope, - clientTypes []string, - clientDirs map[string]string, - files []gitresolver.FileEntry, -) (*skills.InstallResult, error) { - // Deduplicate so clients sharing the same directory don't conflict. - dirsToCheck := uniqueDirClients(clientTypes, clientDirs, nil) - for _, ct := range dirsToCheck { - dir := filepath.Clean(clientDirs[ct]) - if _, statErr := os.Stat(dir); statErr == nil && !opts.Force { // lgtm[go/path-injection] - return nil, httperr.WithCode( - fmt.Errorf("directory %q exists but is not managed by ToolHive; use force to overwrite", dir), - http.StatusConflict, - ) - } - } - return s.gitWriteMultiAndPersist(ctx, opts, scope, clientTypes, clientDirs, files, - dirsToCheck, nil, false, false) -} - -// gitWriteMultiAndPersist writes git files to the given client directories, -// verifies each tree, then creates or updates the store record. On failure -// after any write, previously written directories in this call are removed. -func (s *service) gitWriteMultiAndPersist( - ctx context.Context, - opts skills.InstallOptions, - scope skills.Scope, - allRequested []string, - clientDirs map[string]string, - files []gitresolver.FileEntry, - dirsToWrite []string, - existingClients []string, - isUpgrade, writeAggressive bool, -) (*skills.InstallResult, error) { - var written []string - for _, ct := range dirsToWrite { - dir := filepath.Clean(clientDirs[ct]) - writeMode := opts.Force - if writeAggressive { - writeMode = true - } - if writeErr := gitresolver.WriteFiles(files, dir, writeMode); writeErr != nil { - for _, wct := range written { - _ = s.installer.Remove(filepath.Clean(clientDirs[wct])) - } - return nil, fmt.Errorf("writing git skill: %w", writeErr) - } - if checkErr := skills.CheckFilesystem(dir); checkErr != nil { - _ = s.installer.Remove(dir) - for _, wct := range written { - _ = s.installer.Remove(filepath.Clean(clientDirs[wct])) - } - return nil, fmt.Errorf("post-extraction verification failed: %w", checkErr) - } - written = append(written, ct) - } - - sk := buildInstalledSkill(opts, scope, allRequested, existingClients) - if isUpgrade { - if err := s.store.Update(ctx, sk); err != nil { - for _, wct := range written { - _ = s.installer.Remove(filepath.Clean(clientDirs[wct])) - } - return nil, err - } - } else { - if err := s.store.Create(ctx, sk); err != nil { - for _, wct := range written { - _ = s.installer.Remove(filepath.Clean(clientDirs[wct])) - } - return nil, err - } - } - return &skills.InstallResult{Skill: sk}, nil -} - -// resolveFromLocalStore attempts to resolve a skill name as a tag in the local -// OCI store. On success it hydrates opts with layer data, digest, and version -// from the artifact. Returns (true, nil) when resolved, (false, nil) when the -// tag is not found, or (false, err) on validation/extraction failure. -func (s *service) resolveFromLocalStore(ctx context.Context, opts *skills.InstallOptions) (bool, error) { - d, err := s.ociStore.Resolve(ctx, opts.Name) - if err != nil { - // Tag not found in the local store — not an error, just unresolved. - slog.Debug("skill name not found in local OCI store", "name", opts.Name, "error", err) - return false, nil - } - - layerData, skillConfig, err := s.extractOCIContent(ctx, d) - if err != nil { - return false, err - } - - if err := skills.ValidateSkillName(skillConfig.Name); err != nil { - return false, httperr.WithCode( - fmt.Errorf("local artifact contains invalid skill name: %w", err), - http.StatusUnprocessableEntity, - ) - } - - // Supply-chain defense: the skill name declared inside the artifact must - // match the tag used to install it. A mismatch could indicate a - // tampered or mis-tagged artifact. - if skillConfig.Name != opts.Name { - return false, httperr.WithCode( - fmt.Errorf( - "skill name %q in local artifact does not match install name %q", - skillConfig.Name, opts.Name, - ), - http.StatusUnprocessableEntity, - ) - } - - opts.LayerData = layerData - opts.Digest = d.String() - if opts.Reference == "" { - opts.Reference = opts.Name - } - if opts.Version == "" && skillConfig.Version != "" { - opts.Version = skillConfig.Version - } - - return true, nil -} - -// registryResolveResult holds the outcome of a registry skill name lookup. -// Exactly one of OCIRef or GitURL will be set. -type registryResolveResult struct { - OCIRef nameref.Reference - GitURL string // raw git:// URL for installFromGit -} - -// resolveFromRegistry attempts to resolve a skill name by querying the -// configured skill registry/index. Accepts either a plain name ("skill-creator") -// or a qualified "namespace/name" ("io.github.stacklok/skill-creator"). -// Returns (result, nil) on success, (nil, nil) when no match is found or no -// lookup is configured, or (nil, err) on ambiguity. -func (s *service) resolveFromRegistry(name string) (*registryResolveResult, error) { - if s.skillLookup == nil { - return nil, nil - } - - // Split qualified "namespace/name" if present. Use the last segment as - // the search query since SearchSkills matches on name substring. - wantNamespace, searchName := splitQualifiedName(name) - - results, err := s.skillLookup.SearchSkills(searchName) - if err != nil { - slog.Warn("registry skill lookup failed, falling back to not-found", "name", name, "error", err) - return nil, nil - } - - // Filter for exact match. Case-insensitive because registry data - // may not be normalized to lowercase even though local skill names are. - var matches []regtypes.Skill - for _, sk := range results { - if !strings.EqualFold(sk.Name, searchName) { - continue - } - if wantNamespace != "" && !strings.EqualFold(sk.Namespace, wantNamespace) { - continue - } - matches = append(matches, sk) - } - - if len(matches) == 0 { - return nil, nil - } - - if len(matches) > 1 { - const maxCandidates = 5 - var candidates []string - for _, sk := range matches { - candidates = append(candidates, sk.Namespace+"/"+sk.Name) - } - suffix := "" - if len(candidates) > maxCandidates { - suffix = fmt.Sprintf(" and %d more", len(candidates)-maxCandidates) - candidates = candidates[:maxCandidates] - } - return nil, httperr.WithCode( - fmt.Errorf("ambiguous skill name %q matches multiple registry entries: %s%s; install by full OCI reference instead", - name, strings.Join(candidates, ", "), suffix), - http.StatusConflict, - ) - } - - return resolveRegistryPackages(name, matches[0].Packages) -} - -// splitQualifiedName splits "namespace/name" into (namespace, name). -// If the input has no "/" it returns ("", name) unchanged. -func splitQualifiedName(s string) (namespace, name string) { - idx := strings.LastIndex(s, "/") - if idx < 0 { - return "", s - } - return s[:idx], s[idx+1:] -} - -// resolveRegistryPackages selects the best installable package from a registry -// entry. OCI packages are preferred; git is the fallback. -func resolveRegistryPackages(name string, packages []regtypes.SkillPackage) (*registryResolveResult, error) { - // Try OCI packages first (preferred). - for _, pkg := range packages { - if pkg.RegistryType == "oci" && pkg.Identifier != "" { - ref, parseErr := nameref.ParseReference(pkg.Identifier) - if parseErr != nil { - id := truncate(pkg.Identifier, 256) - return nil, httperr.WithCode( - fmt.Errorf("registry skill %q has invalid OCI identifier %q: %w", name, id, parseErr), - http.StatusUnprocessableEntity, - ) - } - return ®istryResolveResult{OCIRef: ref}, nil - } - } - - // Fallback: look for git packages. - for _, pkg := range packages { - if pkg.RegistryType == "git" && pkg.URL != "" { - gitURL, gitErr := buildGitReferenceFromRegistryURL(pkg.URL) - if gitErr != nil { - u := truncate(pkg.URL, 256) - return nil, httperr.WithCode( - fmt.Errorf("registry skill %q has invalid git URL %q: %w", name, u, gitErr), - http.StatusUnprocessableEntity, - ) - } - if pkg.Subfolder != "" { - gitURL += "#" + pkg.Subfolder - } - return ®istryResolveResult{GitURL: gitURL}, nil - } - } - - return nil, httperr.WithCode( - fmt.Errorf("skill %q found in registry but has no installable package (OCI or git)", name), - http.StatusUnprocessableEntity, - ) -} - -// truncate returns s shortened to maxLen with an ellipsis appended if needed. -func truncate(s string, maxLen int) string { - if len(s) > maxLen { - return s[:maxLen] + "..." - } - return s -} - -// buildGitReferenceFromRegistryURL converts a registry URL (typically HTTPS) -// to a git:// scheme reference that ParseGitReference can handle. -func buildGitReferenceFromRegistryURL(rawURL string) (string, error) { - // The registry may store URLs as "https://github.com/org/repo" or - // already as "git://github.com/org/repo". - if gitresolver.IsGitReference(rawURL) { - // Already a git:// URL — validate it. - if _, err := gitresolver.ParseGitReference(rawURL); err != nil { - return "", err - } - return rawURL, nil - } - - // Convert https://host/path → git://host/path - stripped := strings.TrimPrefix(rawURL, "https://") - stripped = strings.TrimPrefix(stripped, "http://") - if stripped == rawURL { - return "", fmt.Errorf("unsupported URL scheme; expected https:// or git://") - } - gitURL := "git://" + stripped - - // Validate the constructed reference. - if _, err := gitresolver.ParseGitReference(gitURL); err != nil { - return "", err - } - return gitURL, nil -} - -// isSkillArtifact reports whether the OCI descriptor at digest d carries -// ArtifactType == ArtifactTypeSkill. It inspects the top-level index or -// manifest without descending into layers, so it is cheap to call. -func (s *service) isSkillArtifact(ctx context.Context, d digest.Digest) (bool, error) { - isIndex, err := s.ociStore.IsIndex(ctx, d) - if err != nil { - return false, fmt.Errorf("checking OCI content type: %w", err) - } - - if isIndex { - index, indexErr := s.ociStore.GetIndex(ctx, d) - if indexErr != nil { - return false, fmt.Errorf("reading OCI index: %w", indexErr) - } - return index.ArtifactType == ociskills.ArtifactTypeSkill, nil - } - - manifestBytes, err := s.ociStore.GetManifest(ctx, d) - if err != nil { - return false, fmt.Errorf("reading OCI manifest: %w", err) - } - var manifest ocispec.Manifest - if err := json.Unmarshal(manifestBytes, &manifest); err != nil { - return false, fmt.Errorf("parsing OCI manifest: %w", err) - } - return manifest.ArtifactType == ociskills.ArtifactTypeSkill, nil -} - -// extractOCIContent navigates the OCI content graph from a pulled digest, -// extracting the skill config and raw layer data. -func (s *service) extractOCIContent(ctx context.Context, d digest.Digest) ([]byte, *ociskills.SkillConfig, error) { - isIndex, err := s.ociStore.IsIndex(ctx, d) - if err != nil { - return nil, nil, fmt.Errorf("checking OCI content type: %w", err) - } - - manifestDigest := d - if isIndex { - // Skill content is platform-agnostic — all platforms share the same - // layer, so we can use the first manifest in the index. - index, indexErr := s.ociStore.GetIndex(ctx, d) - if indexErr != nil { - return nil, nil, fmt.Errorf("reading OCI index: %w", indexErr) - } - if len(index.Manifests) == 0 { - return nil, nil, httperr.WithCode( - errors.New("OCI index contains no manifests"), - http.StatusUnprocessableEntity, - ) - } - manifestDigest = index.Manifests[0].Digest - } - - manifestBytes, err := s.ociStore.GetManifest(ctx, manifestDigest) - if err != nil { - return nil, nil, fmt.Errorf("reading OCI manifest: %w", err) - } - - var manifest ocispec.Manifest - if err := json.Unmarshal(manifestBytes, &manifest); err != nil { - return nil, nil, fmt.Errorf("parsing OCI manifest: %w", err) - } - - if len(manifest.Layers) == 0 { - return nil, nil, httperr.WithCode( - errors.New("OCI manifest contains no layers"), - http.StatusUnprocessableEntity, - ) - } - - // Skills use a single-layer format (one tar.gz). Validate the first - // (and only expected) layer. - if manifest.Layers[0].MediaType != ocispec.MediaTypeImageLayerGzip { - return nil, nil, httperr.WithCode( - fmt.Errorf("unexpected layer media type %q, expected %q", - manifest.Layers[0].MediaType, ocispec.MediaTypeImageLayerGzip), - http.StatusUnprocessableEntity, - ) - } - - // Extract skill config from the OCI image config. - configBytes, err := s.ociStore.GetBlob(ctx, manifest.Config.Digest) - if err != nil { - return nil, nil, fmt.Errorf("reading OCI config blob: %w", err) - } - - var imgConfig ocispec.Image - if err := json.Unmarshal(configBytes, &imgConfig); err != nil { - return nil, nil, fmt.Errorf("parsing OCI image config: %w", err) - } - - skillConfig, err := ociskills.SkillConfigFromImageConfig(&imgConfig) - if err != nil { - return nil, nil, fmt.Errorf("extracting skill config from OCI artifact: %w", err) - } - - // Guard against oversized layers before loading into memory. - if manifest.Layers[0].Size > maxCompressedLayerSize { - return nil, nil, httperr.WithCode( - fmt.Errorf("compressed layer size %d bytes exceeds maximum %d bytes", - manifest.Layers[0].Size, maxCompressedLayerSize), - http.StatusUnprocessableEntity, - ) - } - - // Extract the raw tar.gz layer data. - layerData, err := s.ociStore.GetBlob(ctx, manifest.Layers[0].Digest) - if err != nil { - return nil, nil, fmt.Errorf("reading OCI layer blob: %w", err) - } - - return layerData, skillConfig, nil -} - -// clientsAllSentinel is the reserved value that expands to all skill-supporting clients. -const clientsAllSentinel = "all" - -// resolveAndValidateClients returns the deduplicated client list and a map of -// client identifier to install directory. Empty opts.Clients (or the sentinel -// value "all") expands to every skill-supporting client returned by the path resolver. -func (s *service) resolveAndValidateClients( - opts skills.InstallOptions, - skillName string, - scope skills.Scope, - projectRoot string, -) ([]string, map[string]string, error) { - if s.pathResolver == nil { - return nil, nil, httperr.WithCode( - fmt.Errorf("path resolver is required for skill installs"), - http.StatusInternalServerError, - ) - } - - var requested []string - switch { - case len(opts.Clients) == 0 || (len(opts.Clients) == 1 && strings.EqualFold(opts.Clients[0], clientsAllSentinel)): - clients := s.pathResolver.ListSkillSupportingClients() - if len(clients) == 0 { - return nil, nil, httperr.WithCode( - errors.New("no supported clients detected on this system; "+ - "use --clients to target a specific client explicitly"), - http.StatusBadRequest, - ) - } - requested = clients - default: - for _, c := range opts.Clients { - if c == "" { - return nil, nil, httperr.WithCode( - errors.New("clients entries must be non-empty strings"), - http.StatusBadRequest, - ) - } - if strings.EqualFold(c, clientsAllSentinel) { - return nil, nil, httperr.WithCode( - fmt.Errorf("%q cannot be combined with other client names", clientsAllSentinel), - http.StatusBadRequest, - ) - } - } - requested = dedupeStringsPreserveOrder(opts.Clients) - } - - paths := make(map[string]string, len(requested)) - for _, ct := range requested { - dir, err := s.pathResolver.GetSkillPath(ct, skillName, scope, projectRoot) - if err != nil { - if errors.Is(err, client.ErrUnsupportedClientType) || errors.Is(err, client.ErrSkillsNotSupported) { - return nil, nil, httperr.WithCode( - fmt.Errorf("invalid client %q: %w", ct, err), - http.StatusBadRequest, - ) - } - return nil, nil, fmt.Errorf("resolving skill path for client %q: %w", ct, err) - } - dir = filepath.Clean(dir) - if err := validateResolvedDir(dir); err != nil { - return nil, nil, fmt.Errorf("resolved path for client %q is unsafe: %w", ct, err) - } - paths[ct] = dir - } - return requested, paths, nil -} - -// expandToExistingClients merges existingClients into requestedClients and -// resolves paths for any existing client not already in clientDirs. This -// ensures upgrades write new files to all clients, not just the requested set. -func (s *service) expandToExistingClients( - existingClients, requestedClients []string, - clientDirs map[string]string, - skillName string, scope skills.Scope, projectRoot string, -) ([]string, map[string]string, error) { - allClients := mergeClientLists(requestedClients, existingClients) - allDirs := make(map[string]string, len(allClients)) - for k, v := range clientDirs { - allDirs[k] = v - } - for _, ct := range allClients { - if _, ok := allDirs[ct]; ok { - continue - } - dir, err := s.pathResolver.GetSkillPath(ct, skillName, scope, projectRoot) - if err != nil { - return nil, nil, fmt.Errorf("resolving skill path for existing client %q: %w", ct, err) - } - dir = filepath.Clean(dir) - if err := validateResolvedDir(dir); err != nil { - return nil, nil, fmt.Errorf("resolved path for client %q is unsafe: %w", ct, err) - } - allDirs[ct] = dir - } - return allClients, allDirs, nil -} - -// validateResolvedDir ensures a directory path is absolute and free of -// path-traversal segments. Callers must pass a filepath.Clean'd value. -func validateResolvedDir(dir string) error { - if !filepath.IsAbs(dir) { - return fmt.Errorf("path must be absolute, got %q", dir) - } - for _, seg := range strings.Split(filepath.ToSlash(dir), "/") { - if seg == ".." { - return fmt.Errorf("path contains traversal segment: %q", dir) - } - } - return nil -} - -func dedupeStringsPreserveOrder(in []string) []string { - seen := make(map[string]struct{}, len(in)) - out := make([]string, 0, len(in)) - for _, s := range in { - if _, ok := seen[s]; ok { - continue - } - seen[s] = struct{}{} - out = append(out, s) - } - return out -} - -// clientsContainAll reports whether every value in requested appears in existing. -func clientsContainAll(existing, requested []string) bool { - for _, r := range requested { - if !slices.Contains(existing, r) { - return false - } - } - return true -} - -// mergeClientLists returns existing followed by any requested entries not already present. -func mergeClientLists(existing, requested []string) []string { - out := make([]string, len(existing)) - copy(out, existing) - seen := make(map[string]struct{}, len(existing)+len(requested)) - for _, c := range existing { - seen[c] = struct{}{} - } - for _, c := range requested { - if _, ok := seen[c]; ok { - continue - } - seen[c] = struct{}{} - out = append(out, c) - } - if len(out) == 0 { - return nil - } - return out -} - -// installWithExtraction handles the full install flow: managed/unmanaged -// detection, extraction, and DB record creation or update. -func (s *service) installWithExtraction( - ctx context.Context, opts skills.InstallOptions, scope skills.Scope, -) (*skills.InstallResult, error) { - clientTypes, clientDirs, err := s.resolveAndValidateClients(opts, opts.Name, scope, opts.ProjectRoot) - if err != nil { - return nil, err - } - - existing, storeErr := s.store.Get(ctx, opts.Name, scope, opts.ProjectRoot) - isNotFound := errors.Is(storeErr, storage.ErrNotFound) - if storeErr != nil && !isNotFound { - return nil, fmt.Errorf("checking existing skill: %w", storeErr) - } - - if isExtractionNoOp(existing, storeErr, opts, clientTypes) { - return &skills.InstallResult{Skill: existing}, nil - } - - digestMatches := storeErr == nil && existing.Digest == opts.Digest - if digestMatches && storeErr == nil { - return s.installExtractionSameDigestNewClients(ctx, opts, scope, existing, clientTypes, clientDirs) - } - - if storeErr == nil { - return s.installExtractionUpgradeDigest(ctx, opts, scope, existing, clientTypes, clientDirs) - } - - return s.installExtractionFresh(ctx, opts, scope, clientTypes, clientDirs) -} - -// isExtractionNoOp reports whether the install can be short-circuited because -// the same digest and all requested clients are already present. Legacy store -// rows (empty Clients slice) are treated as satisfied only when the user did -// not explicitly specify --clients. -func isExtractionNoOp(existing skills.InstalledSkill, storeErr error, opts skills.InstallOptions, clientTypes []string) bool { - if storeErr != nil || existing.Digest != opts.Digest { - return false - } - if clientsContainAll(existing.Clients, clientTypes) { - return true - } - return len(existing.Clients) == 0 && len(clientTypes) <= 1 && len(opts.Clients) == 0 -} - -func (s *service) installExtractionSameDigestNewClients( - ctx context.Context, - opts skills.InstallOptions, - scope skills.Scope, - existing skills.InstalledSkill, - clientTypes []string, - clientDirs map[string]string, -) (*skills.InstallResult, error) { - toWrite := missingClients(existing.Clients, clientTypes) - if len(toWrite) == 0 { - return &skills.InstallResult{Skill: existing}, nil - } - // Deduplicate and skip directories already owned by existing clients. - dirsToWrite := uniqueDirClients(toWrite, clientDirs, existingClientDirs(existing.Clients, clientDirs)) - if len(dirsToWrite) == 0 { - // All new clients share directories with existing ones — no-op. - sk := buildInstalledSkill(opts, scope, clientTypes, existing.Clients) - if err := s.store.Update(ctx, sk); err != nil { - return nil, err - } - return &skills.InstallResult{Skill: sk}, nil - } - var written []string - for _, ct := range dirsToWrite { - dir := filepath.Clean(clientDirs[ct]) - if _, statErr := os.Stat(dir); statErr == nil && !opts.Force { // lgtm[go/path-injection] - removeSkillDirs(s.installer, clientDirs, written) - return nil, httperr.WithCode( - fmt.Errorf("directory %q exists but is not managed by ToolHive; use force to overwrite", dir), - http.StatusConflict, - ) - } - if _, exErr := s.installer.Extract(opts.LayerData, dir, opts.Force); exErr != nil { - removeSkillDirs(s.installer, clientDirs, written) - return nil, fmt.Errorf("extracting skill: %w", exErr) - } - written = append(written, ct) - } - sk := buildInstalledSkill(opts, scope, clientTypes, existing.Clients) - if err := s.store.Update(ctx, sk); err != nil { - removeSkillDirs(s.installer, clientDirs, written) - return nil, err - } - return &skills.InstallResult{Skill: sk}, nil -} - -func removeSkillDirs(inst skills.Installer, clientDirs map[string]string, clients []string) { - for _, ct := range clients { - _ = inst.Remove(filepath.Clean(clientDirs[ct])) - } -} - -// uniqueDirClients returns the subset of clients whose resolved directory is -// unique. When multiple clients share the same path (e.g. vscode and -// vscode-insider both using ~/.copilot/skills), only the first is returned. -// This prevents double-extraction while still recording all clients in the DB. -// -// occupiedDirs is pre-seeded into the seen set so that new clients whose -// directory is already owned by an existing installed client are also skipped. -// Pass nil when there are no pre-existing directories to exclude. -func uniqueDirClients(clients []string, clientDirs map[string]string, occupiedDirs map[string]struct{}) []string { - seen := make(map[string]struct{}, len(clients)+len(occupiedDirs)) - for dir := range occupiedDirs { - seen[dir] = struct{}{} - } - out := make([]string, 0, len(clients)) - for _, ct := range clients { - dir := filepath.Clean(clientDirs[ct]) - if _, ok := seen[dir]; ok { - continue - } - seen[dir] = struct{}{} - out = append(out, ct) - } - return out -} - -// existingClientDirs builds the set of directories already occupied by the -// given installed clients. Used to seed uniqueDirClients so that new clients -// sharing a directory with an existing client are skipped rather than -// triggering a false "directory exists" conflict. -func existingClientDirs(existing []string, clientDirs map[string]string) map[string]struct{} { - dirs := make(map[string]struct{}, len(existing)) - for _, ct := range existing { - if dir, ok := clientDirs[ct]; ok { - dirs[filepath.Clean(dir)] = struct{}{} - } - } - return dirs -} - -func (s *service) installExtractionUpgradeDigest( - ctx context.Context, - opts skills.InstallOptions, - scope skills.Scope, - existing skills.InstalledSkill, - clientTypes []string, - clientDirs map[string]string, -) (*skills.InstallResult, error) { - allClients, allDirs, err := s.expandToExistingClients( - existing.Clients, clientTypes, clientDirs, opts.Name, scope, opts.ProjectRoot) - if err != nil { - return nil, err - } - // Deduplicate so clients sharing the same directory don't conflict. - dirsToWrite := uniqueDirClients(allClients, allDirs, nil) - var written []string - for _, ct := range dirsToWrite { - dir := filepath.Clean(allDirs[ct]) - if _, exErr := s.installer.Extract(opts.LayerData, dir, true); exErr != nil { - removeSkillDirs(s.installer, allDirs, written) - return nil, fmt.Errorf("extracting skill upgrade: %w", exErr) - } - written = append(written, ct) - } - sk := buildInstalledSkill(opts, scope, allClients, nil) - if err := s.store.Update(ctx, sk); err != nil { - removeSkillDirs(s.installer, allDirs, dirsToWrite) - return nil, err - } - return &skills.InstallResult{Skill: sk}, nil -} - -func (s *service) installExtractionFresh( - ctx context.Context, - opts skills.InstallOptions, - scope skills.Scope, - clientTypes []string, - clientDirs map[string]string, -) (*skills.InstallResult, error) { - // Deduplicate so clients sharing the same directory don't conflict. - dirsToWrite := uniqueDirClients(clientTypes, clientDirs, nil) - - for _, ct := range dirsToWrite { - dir := filepath.Clean(clientDirs[ct]) - if _, statErr := os.Stat(dir); statErr == nil && !opts.Force { // lgtm[go/path-injection] - return nil, httperr.WithCode( - fmt.Errorf("directory %q exists but is not managed by ToolHive; use force to overwrite", dir), - http.StatusConflict, - ) - } - } - var written []string - for _, ct := range dirsToWrite { - dir := filepath.Clean(clientDirs[ct]) - if _, exErr := s.installer.Extract(opts.LayerData, dir, opts.Force); exErr != nil { - removeSkillDirs(s.installer, clientDirs, written) - return nil, fmt.Errorf("extracting skill: %w", exErr) - } - written = append(written, ct) - } - sk := buildInstalledSkill(opts, scope, clientTypes, nil) - if err := s.store.Create(ctx, sk); err != nil { - removeSkillDirs(s.installer, clientDirs, dirsToWrite) - return nil, err - } - return &skills.InstallResult{Skill: sk}, nil -} - -// buildInstalledSkill constructs an InstalledSkill from install options. -// requestedClientTypes is the set of clients targeted by this install; they -// are merged with existingClients for the persisted Clients field. -func buildInstalledSkill( - opts skills.InstallOptions, - scope skills.Scope, - requestedClientTypes []string, - existingClients []string, -) skills.InstalledSkill { - clients := mergeClientLists(existingClients, requestedClientTypes) - - return skills.InstalledSkill{ - Metadata: skills.SkillMetadata{ - Name: opts.Name, - Version: opts.Version, - }, - Scope: scope, - ProjectRoot: opts.ProjectRoot, - Reference: opts.Reference, - Digest: opts.Digest, - Status: skills.InstallStatusInstalled, - InstalledAt: time.Now().UTC(), - Clients: clients, - } -} - -// validateLocalPath checks that a path is non-empty, absolute, and does not -// contain ".." path traversal segments. This prevents API clients from -// accessing arbitrary directories on the host filesystem via traversal. -func validateLocalPath(path string) error { - if path == "" { - return httperr.WithCode(errors.New("path is required"), http.StatusBadRequest) - } - if strings.ContainsRune(path, 0) { - return httperr.WithCode(errors.New("path contains null bytes"), http.StatusBadRequest) - } - if !filepath.IsAbs(path) { - return httperr.WithCode( - fmt.Errorf("path must be absolute, got %q", path), - http.StatusBadRequest, - ) - } - // Check the raw path for ".." segments before cleaning resolves them. - // This catches traversal attempts like /safe/dir/../../../etc. - for _, segment := range strings.Split(filepath.ToSlash(path), "/") { - if segment == ".." { - return httperr.WithCode(errors.New("path must not contain '..' traversal segments"), http.StatusBadRequest) - } - } - return nil -} - -// validateOCITagOrReference accepts either a bare OCI tag ("v1.0.0") or a full -// OCI reference ("ghcr.io/org/repo:v1.0.0"). The --tag flag in `thv skill build` -// supports both forms (matching `docker build -t` semantics), so we route to -// the appropriate parser based on the presence of '/', ':', or '@'. -func validateOCITagOrReference(value string) error { - if strings.ContainsAny(value, "/:@") { - // Looks like a full OCI reference — validate as such. - if _, err := nameref.ParseReference(value, nameref.StrictValidation); err != nil { - return httperr.WithCode( - fmt.Errorf("invalid OCI reference or tag %q: %w", value, err), - http.StatusBadRequest, - ) - } - return nil - } - // Bare tag — construct a dummy reference to validate the tag portion. - if _, err := nameref.NewTag("dummy.invalid/repo:"+value, nameref.StrictValidation); err != nil { - return httperr.WithCode( - fmt.Errorf("invalid OCI reference or tag %q: %w", value, err), - http.StatusBadRequest, - ) - } - return nil -} - -func normalizeProjectRoot(scope skills.Scope, projectRoot string) (skills.Scope, string, error) { - normalizedScope, normalizedRoot, err := skills.NormalizeScopeAndProjectRoot(scope, projectRoot) - if err != nil { - return normalizedScope, normalizedRoot, httperr.WithCode(err, http.StatusBadRequest) - } - return normalizedScope, normalizedRoot, nil -} - -// defaultScope returns ScopeUser when s is empty, otherwise returns s unchanged. -func defaultScope(s skills.Scope) skills.Scope { - if s == "" { - return skills.ScopeUser - } - return s -} - -// registerSkillInGroup adds the skill to the requested group when a group -// manager is configured. When groupName is empty it defaults to the -// "default" group, matching workload behavior. -func (s *service) registerSkillInGroup(ctx context.Context, groupName string, skillName string) error { - if s.groupManager == nil { - return nil - } - if groupName == "" { - groupName = groups.DefaultGroup - } - return groups.AddSkillToGroup(ctx, s.groupManager, groupName, skillName) -} - -// installAndRegister registers the just-installed skill in the target group. -// If group registration fails, the DB record is rolled back so that a retry -// starts fresh rather than leaving the system in an inconsistent state (skill -// installed but not in the expected group). -func (s *service) installAndRegister( - ctx context.Context, - result *skills.InstallResult, - groupName string, - skillName string, - scope skills.Scope, - projectRoot string, -) (*skills.InstallResult, error) { - if err := s.registerSkillInGroup(ctx, groupName, skillName); err != nil { - // Best-effort rollback: remove the DB record so retries start fresh. - // Files on disk are left in place; a fresh install will detect them - // and either overwrite (force) or return a conflict. - _ = s.store.Delete(ctx, skillName, scope, projectRoot) - return nil, fmt.Errorf("registering skill in group: %w", err) - } - return result, nil -} diff --git a/pkg/skills/skillsvc/skillsvc_test.go b/pkg/skills/skillsvc/skillsvc_test.go deleted file mode 100644 index 22bf569f00..0000000000 --- a/pkg/skills/skillsvc/skillsvc_test.go +++ /dev/null @@ -1,3912 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package skillsvc - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "os" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - nameref "github.com/google/go-containerregistry/pkg/name" - godigest "github.com/opencontainers/go-digest" - specs "github.com/opencontainers/image-spec/specs-go" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" - - "github.com/stacklok/toolhive-core/httperr" - ociskills "github.com/stacklok/toolhive-core/oci/skills" - ocimocks "github.com/stacklok/toolhive-core/oci/skills/mocks" - regtypes "github.com/stacklok/toolhive-core/registry/types" - "github.com/stacklok/toolhive/pkg/client" - "github.com/stacklok/toolhive/pkg/groups" - groupmocks "github.com/stacklok/toolhive/pkg/groups/mocks" - regmocks "github.com/stacklok/toolhive/pkg/registry/mocks" - "github.com/stacklok/toolhive/pkg/skills" - "github.com/stacklok/toolhive/pkg/skills/gitresolver" - gitmocks "github.com/stacklok/toolhive/pkg/skills/gitresolver/mocks" - skillsmocks "github.com/stacklok/toolhive/pkg/skills/mocks" - "github.com/stacklok/toolhive/pkg/storage" - storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" -) - -const testCommitHash = "abcdef1234567890abcdef1234567890abcdef12" - -func makeLayerData(t *testing.T) []byte { - t.Helper() - files := []ociskills.FileEntry{ - {Path: "SKILL.md", Content: []byte("---\nname: my-skill\ndescription: test\n---\n# Skill"), Mode: 0644}, - } - data, err := ociskills.CompressTar(files, ociskills.DefaultTarOptions(), ociskills.DefaultGzipOptions()) - require.NoError(t, err) - return data -} - -func tempDir(t *testing.T) string { - t.Helper() - realTmpDir, _ := filepath.EvalSymlinks(t.TempDir()) - return realTmpDir -} - -func makeProjectRoot(t *testing.T) string { - t.Helper() - dir := t.TempDir() - resolved, err := filepath.EvalSymlinks(dir) - require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Join(resolved, ".git"), 0o755)) - return resolved -} - -func TestList(t *testing.T) { - t.Parallel() - - projectRoot := makeProjectRoot(t) - - tests := []struct { - name string - opts skills.ListOptions - setupMock func(*storemocks.MockSkillStore) - wantCode int - wantErr string - wantCount int - }{ - { - name: "delegates to store with scope", - opts: skills.ListOptions{Scope: skills.ScopeUser}, - setupMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().List(gomock.Any(), storage.ListFilter{Scope: skills.ScopeUser}). - Return([]skills.InstalledSkill{{Metadata: skills.SkillMetadata{Name: "my-skill"}}}, nil) - }, - wantCount: 1, - }, - { - name: "empty scope returns all", - opts: skills.ListOptions{}, - setupMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().List(gomock.Any(), storage.ListFilter{}). - Return([]skills.InstalledSkill{}, nil) - }, - wantCount: 0, - }, - { - name: "delegates to store with project root", - opts: skills.ListOptions{Scope: skills.ScopeProject, ProjectRoot: projectRoot}, - setupMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().List(gomock.Any(), storage.ListFilter{ - Scope: skills.ScopeProject, - ProjectRoot: projectRoot, - }).Return([]skills.InstalledSkill{}, nil) - }, - wantCount: 0, - }, - { - name: "project scope requires project root", - opts: skills.ListOptions{Scope: skills.ScopeProject}, - setupMock: func(_ *storemocks.MockSkillStore) {}, - wantCode: http.StatusBadRequest, - wantErr: "project_root is required", - }, - { - name: "delegates to store with client app", - opts: skills.ListOptions{ClientApp: "claude-code"}, - setupMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().List(gomock.Any(), storage.ListFilter{ClientApp: "claude-code"}). - Return([]skills.InstalledSkill{}, nil) - }, - wantCount: 0, - }, - { - name: "propagates store errors", - opts: skills.ListOptions{}, - setupMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("db error")) - }, - wantErr: "db error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - tt.setupMock(store) - - result, err := New(store).List(t.Context(), tt.opts) - if tt.wantCode != 0 { - require.Error(t, err) - assert.Equal(t, tt.wantCode, httperr.Code(err)) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - require.NoError(t, err) - assert.Len(t, result, tt.wantCount) - }) - } -} - -func TestInstallPlainNameNotFound(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - opts skills.InstallOptions - wantCode int - wantErr string - }{ - { - name: "plain name without store or lookup returns not found", - opts: skills.InstallOptions{Name: "my-skill"}, - wantCode: http.StatusNotFound, - wantErr: "not found in local store or registry", - }, - { - name: "rejects project scope without root", - opts: skills.InstallOptions{Name: "my-skill", Scope: skills.ScopeProject}, - wantCode: http.StatusBadRequest, - }, - { - name: "rejects invalid name", - opts: skills.InstallOptions{Name: "A"}, - wantCode: http.StatusBadRequest, - }, - { - name: "rejects empty name", - opts: skills.InstallOptions{Name: ""}, - wantCode: http.StatusBadRequest, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - - _, err := New(store).Install(t.Context(), tt.opts) - require.Error(t, err) - assert.Equal(t, tt.wantCode, httperr.Code(err)) - if tt.wantErr != "" { - assert.Contains(t, err.Error(), tt.wantErr) - } - }) - } -} - -func TestInstallWithExtraction(t *testing.T) { - t.Parallel() - - layerData := makeLayerData(t) - - t.Run("fresh install creates record and extracts files", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - - targetDir := filepath.Join(tempDir(t), "my-skill") - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, sk skills.InstalledSkill) error { - assert.Equal(t, skills.InstallStatusInstalled, sk.Status) - assert.Equal(t, "sha256:abc", sk.Digest) - assert.Equal(t, []string{"claude-code"}, sk.Clients) - return nil - }) - - svc := New(store, WithPathResolver(pr)) - result, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - }) - require.NoError(t, err) - assert.Equal(t, skills.InstallStatusInstalled, result.Skill.Status) - - // Verify files were extracted - _, statErr := os.Stat(filepath.Join(targetDir, "SKILL.md")) - assert.NoError(t, statErr) - }) - - t.Run("same digest is no-op", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - - targetDir := filepath.Join(tempDir(t), "my-skill") - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Digest: "sha256:abc", - Status: skills.InstallStatusInstalled, - } - - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - // No Create or Update should be called - - svc := New(store, WithPathResolver(pr)) - result, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - }) - require.NoError(t, err) - assert.Equal(t, "my-skill", result.Skill.Metadata.Name) - }) - - t.Run("different digest triggers upgrade", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - - targetDir := filepath.Join(tempDir(t), "my-skill") - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Digest: "sha256:old", - Status: skills.InstallStatusInstalled, - Clients: []string{"claude-code"}, - } - - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, sk skills.InstalledSkill) error { - assert.Equal(t, "sha256:new", sk.Digest) - assert.Equal(t, skills.InstallStatusInstalled, sk.Status) - return nil - }) - - svc := New(store, WithPathResolver(pr)) - result, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:new", - }) - require.NoError(t, err) - assert.Equal(t, "sha256:new", result.Skill.Digest) - }) - - t.Run("unmanaged directory refused without force", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - - // Create an existing unmanaged directory - targetDir := filepath.Join(tempDir(t), "my-skill") - require.NoError(t, os.MkdirAll(targetDir, 0750)) - - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - - svc := New(store, WithPathResolver(pr)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - }) - require.Error(t, err) - assert.Equal(t, http.StatusConflict, httperr.Code(err)) - assert.Contains(t, err.Error(), "not managed by ToolHive") - }) - - t.Run("unmanaged directory overwritten with force", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - - // Create an existing unmanaged directory - targetDir := filepath.Join(tempDir(t), "my-skill") - require.NoError(t, os.MkdirAll(targetDir, 0750)) - - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) - - svc := New(store, WithPathResolver(pr)) - result, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - Force: true, - }) - require.NoError(t, err) - assert.Equal(t, skills.InstallStatusInstalled, result.Skill.Status) - }) - - t.Run("explicit client used over default", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - - targetDir := filepath.Join(tempDir(t), "my-skill") - pr.EXPECT().GetSkillPath("custom-client", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, sk skills.InstalledSkill) error { - assert.Equal(t, []string{"custom-client"}, sk.Clients) - return nil - }) - - svc := New(store, WithPathResolver(pr)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Clients: []string{"custom-client"}, - Digest: "sha256:abc", - }) - require.NoError(t, err) - }) - - t.Run("multiple clients fresh install", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - inst := skillsmocks.NewMockInstaller(ctrl) - - dirA := filepath.Join(t.TempDir(), "a", "my-skill") - dirB := filepath.Join(t.TempDir(), "b", "my-skill") - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) - pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - inst.EXPECT().Extract(layerData, dirA, false).Return(&skills.ExtractResult{SkillDir: dirA, Files: 1}, nil) - inst.EXPECT().Extract(layerData, dirB, false).Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil) - store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, sk skills.InstalledSkill) error { - assert.ElementsMatch(t, []string{"claude-code", "opencode"}, sk.Clients) - return nil - }) - - svc := New(store, WithPathResolver(pr), WithInstaller(inst)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - Clients: []string{"claude-code", "opencode"}, - }) - require.NoError(t, err) - }) - - t.Run("same digest adds second client without re-extracting first", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - inst := skillsmocks.NewMockInstaller(ctrl) - - dirA := filepath.Join(t.TempDir(), "a") - dirB := filepath.Join(t.TempDir(), "b") - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Digest: "sha256:abc", - Clients: []string{"claude-code"}, - } - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) - pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - inst.EXPECT().Extract(layerData, dirB, false).Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil) - store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, sk skills.InstalledSkill) error { - assert.ElementsMatch(t, []string{"claude-code", "opencode"}, sk.Clients) - return nil - }) - - svc := New(store, WithPathResolver(pr), WithInstaller(inst)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - Clients: []string{"claude-code", "opencode"}, - }) - require.NoError(t, err) - }) - - t.Run("invalid client returns bad request", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - - pr.EXPECT().GetSkillPath("not-a-real-client", "my-skill", skills.ScopeUser, "").Return("", fmt.Errorf("%w: not-a-real-client", client.ErrUnsupportedClientType)) - - svc := New(store, WithPathResolver(pr)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - Clients: []string{"not-a-real-client"}, - }) - require.Error(t, err) - assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) - }) - - t.Run("empty string in clients list returns bad request", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - - svc := New(store, WithPathResolver(pr)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - Clients: []string{"claude-code", ""}, - }) - require.Error(t, err) - assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) - }) - - t.Run("clients all sentinel expands to every skill-supporting client", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - inst := skillsmocks.NewMockInstaller(ctrl) - - dirA := filepath.Join(t.TempDir(), "a", "my-skill") - dirB := filepath.Join(t.TempDir(), "b", "my-skill") - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code", "opencode"}) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) - pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - inst.EXPECT().Extract(layerData, dirA, false).Return(&skills.ExtractResult{SkillDir: dirA, Files: 1}, nil) - inst.EXPECT().Extract(layerData, dirB, false).Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil) - store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, sk skills.InstalledSkill) error { - assert.ElementsMatch(t, []string{"claude-code", "opencode"}, sk.Clients) - return nil - }) - - svc := New(store, WithPathResolver(pr), WithInstaller(inst)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - Clients: []string{"all"}, - }) - require.NoError(t, err) - }) - - t.Run("all sentinel mixed with named client returns bad request", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - - svc := New(store, WithPathResolver(pr)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - Clients: []string{"all", "opencode"}, - }) - require.Error(t, err) - assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) - }) - - t.Run("fresh install rolls back extraction on store.Create failure", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - inst := skillsmocks.NewMockInstaller(ctrl) - - targetDir := filepath.Join(t.TempDir(), "my-skill") - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - inst.EXPECT().Extract(layerData, targetDir, false).Return(&skills.ExtractResult{SkillDir: targetDir, Files: 1}, nil) - store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(fmt.Errorf("db write error")) - inst.EXPECT().Remove(targetDir).Return(nil) - - svc := New(store, WithPathResolver(pr), WithInstaller(inst)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "db write error") - }) - - t.Run("upgrade rolls back extraction on store.Update failure", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - inst := skillsmocks.NewMockInstaller(ctrl) - - targetDir := filepath.Join(t.TempDir(), "my-skill") - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Digest: "sha256:old", - Status: skills.InstallStatusInstalled, - Clients: []string{"claude-code"}, - } - - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - inst.EXPECT().Extract(layerData, targetDir, true).Return(&skills.ExtractResult{SkillDir: targetDir, Files: 1}, nil) - store.EXPECT().Update(gomock.Any(), gomock.Any()).Return(fmt.Errorf("db update error")) - inst.EXPECT().Remove(targetDir).Return(nil) - - svc := New(store, WithPathResolver(pr), WithInstaller(inst)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:new", - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "db update error") - }) - - t.Run("multi-client fresh install rolls back first client on second extract failure", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - inst := skillsmocks.NewMockInstaller(ctrl) - - dirA := filepath.Join(t.TempDir(), "a", "my-skill") - dirB := filepath.Join(t.TempDir(), "b", "my-skill") - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) - pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, ""). - Return(skills.InstalledSkill{}, storage.ErrNotFound) - inst.EXPECT().Extract(layerData, dirA, false). - Return(&skills.ExtractResult{SkillDir: dirA, Files: 1}, nil) - inst.EXPECT().Extract(layerData, dirB, false). - Return(nil, fmt.Errorf("disk full")) - inst.EXPECT().Remove(dirA).Return(nil) - - svc := New(store, WithPathResolver(pr), WithInstaller(inst)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - Clients: []string{"claude-code", "opencode"}, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "disk full") - }) - - t.Run("upgrade digest rolls back written clients on second extract failure", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - inst := skillsmocks.NewMockInstaller(ctrl) - - dirA := filepath.Join(t.TempDir(), "a", "my-skill") - dirB := filepath.Join(t.TempDir(), "b", "my-skill") - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Digest: "sha256:old", - Clients: []string{"claude-code"}, - } - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) - pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - inst.EXPECT().Extract(layerData, dirA, true). - Return(&skills.ExtractResult{SkillDir: dirA, Files: 1}, nil) - inst.EXPECT().Extract(layerData, dirB, true). - Return(nil, fmt.Errorf("write error")) - inst.EXPECT().Remove(dirA).Return(nil) - - svc := New(store, WithPathResolver(pr), WithInstaller(inst)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:new", - Clients: []string{"claude-code", "opencode"}, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "write error") - }) - - t.Run("upgrade digest rolls back all clients on store.Update failure", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - inst := skillsmocks.NewMockInstaller(ctrl) - - dirA := filepath.Join(t.TempDir(), "a", "my-skill") - dirB := filepath.Join(t.TempDir(), "b", "my-skill") - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Digest: "sha256:old", - Clients: []string{"claude-code"}, - } - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) - pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - inst.EXPECT().Extract(layerData, dirA, true). - Return(&skills.ExtractResult{SkillDir: dirA, Files: 1}, nil) - inst.EXPECT().Extract(layerData, dirB, true). - Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil) - store.EXPECT().Update(gomock.Any(), gomock.Any()).Return(fmt.Errorf("db failure")) - inst.EXPECT().Remove(dirA).Return(nil) - inst.EXPECT().Remove(dirB).Return(nil) - - svc := New(store, WithPathResolver(pr), WithInstaller(inst)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:new", - Clients: []string{"claude-code", "opencode"}, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "db failure") - }) - - t.Run("same digest new client rolls back on extract failure", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - inst := skillsmocks.NewMockInstaller(ctrl) - - dirA := filepath.Join(t.TempDir(), "a", "my-skill") - dirB := filepath.Join(t.TempDir(), "b", "my-skill") - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Digest: "sha256:abc", - Clients: []string{"claude-code"}, - } - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) - pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - inst.EXPECT().Extract(layerData, dirB, false).Return(nil, fmt.Errorf("extract boom")) - - svc := New(store, WithPathResolver(pr), WithInstaller(inst)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - Clients: []string{"claude-code", "opencode"}, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "extract boom") - }) - - t.Run("same digest new client rolls back on store.Update failure", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - inst := skillsmocks.NewMockInstaller(ctrl) - - dirA := filepath.Join(t.TempDir(), "a", "my-skill") - dirB := filepath.Join(t.TempDir(), "b", "my-skill") - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Digest: "sha256:abc", - Clients: []string{"claude-code"}, - } - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) - pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - inst.EXPECT().Extract(layerData, dirB, false). - Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil) - store.EXPECT().Update(gomock.Any(), gomock.Any()).Return(fmt.Errorf("db update boom")) - inst.EXPECT().Remove(dirB).Return(nil) - - svc := New(store, WithPathResolver(pr), WithInstaller(inst)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - Clients: []string{"claude-code", "opencode"}, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "db update boom") - }) - - t.Run("same digest new client unmanaged dir conflict", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - inst := skillsmocks.NewMockInstaller(ctrl) - - dirA := filepath.Join(t.TempDir(), "a", "my-skill") - dirB := filepath.Join(t.TempDir(), "b", "my-skill") - require.NoError(t, os.MkdirAll(dirB, 0o750)) - - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Digest: "sha256:abc", - Clients: []string{"claude-code"}, - } - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) - pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - - svc := New(store, WithPathResolver(pr), WithInstaller(inst)) - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - Clients: []string{"claude-code", "opencode"}, - }) - require.Error(t, err) - assert.Equal(t, http.StatusConflict, httperr.Code(err)) - assert.Contains(t, err.Error(), "not managed by ToolHive") - }) - - t.Run("legacy row with explicit client is not a no-op", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - inst := skillsmocks.NewMockInstaller(ctrl) - - dirB := filepath.Join(t.TempDir(), "b", "my-skill") - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Digest: "sha256:abc", - Clients: []string{}, - } - pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - inst.EXPECT().Extract(layerData, dirB, false). - Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil) - store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, sk skills.InstalledSkill) error { - assert.Contains(t, sk.Clients, "opencode") - return nil - }) - - svc := New(store, WithPathResolver(pr), WithInstaller(inst)) - result, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:abc", - Clients: []string{"opencode"}, - }) - require.NoError(t, err) - assert.Equal(t, "my-skill", result.Skill.Metadata.Name) - }) - - t.Run("upgrade extracts to all existing clients not just requested", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - inst := skillsmocks.NewMockInstaller(ctrl) - - dirA := filepath.Join(t.TempDir(), "a", "my-skill") - dirB := filepath.Join(t.TempDir(), "b", "my-skill") - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Digest: "sha256:old", - Clients: []string{"claude-code"}, - } - pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - inst.EXPECT().Extract(layerData, dirB, true). - Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil) - inst.EXPECT().Extract(layerData, dirA, true). - Return(&skills.ExtractResult{SkillDir: dirA, Files: 1}, nil) - store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, sk skills.InstalledSkill) error { - assert.ElementsMatch(t, []string{"opencode", "claude-code"}, sk.Clients) - assert.Equal(t, "sha256:new", sk.Digest) - return nil - }) - - svc := New(store, WithPathResolver(pr), WithInstaller(inst)) - result, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "my-skill", - LayerData: layerData, - Digest: "sha256:new", - Clients: []string{"opencode"}, - }) - require.NoError(t, err) - assert.ElementsMatch(t, []string{"opencode", "claude-code"}, result.Skill.Clients) - }) -} - -// buildTestArtifact creates a real OCI skill artifact in the store and returns -// the index digest. This uses the real Packager so the store has a proper index, -// manifest, config blob, and layer blob — identical to what a real pull would -// produce. -func buildTestArtifact(t *testing.T, store *ociskills.Store, skillName, version string) godigest.Digest { - t.Helper() - - // Create a temporary skill directory. - skillDir := filepath.Join(t.TempDir(), skillName) - require.NoError(t, os.MkdirAll(skillDir, 0o750)) - fm := fmt.Sprintf("---\nname: %s\ndescription: test skill\nversion: %s\n---\n# %s\nA test skill.\n", - skillName, version, skillName) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(fm), 0o600)) - - packager := ociskills.NewPackager(store) - result, err := packager.Package(t.Context(), skillDir, ociskills.DefaultPackageOptions()) - require.NoError(t, err) - return result.IndexDigest -} - -func buildManifestWithLayerSize(t *testing.T, store *ociskills.Store, skillName string, layerSize int64) godigest.Digest { - t.Helper() - - imgConfig := ocispec.Image{ - Config: ocispec.ImageConfig{ - Labels: map[string]string{ - ociskills.LabelSkillName: skillName, - ociskills.LabelSkillDescription: "test", - ociskills.LabelSkillVersion: "1.0.0", - }, - }, - } - configBytes, err := json.Marshal(imgConfig) - require.NoError(t, err) - configDigest, err := store.PutBlob(t.Context(), configBytes) - require.NoError(t, err) - - layerBytes := []byte("layer") - layerDigest, err := store.PutBlob(t.Context(), layerBytes) - require.NoError(t, err) - - manifest := ocispec.Manifest{ - Versioned: specs.Versioned{SchemaVersion: 2}, - MediaType: ocispec.MediaTypeImageManifest, - ArtifactType: ociskills.ArtifactTypeSkill, - Config: ocispec.Descriptor{ - MediaType: ocispec.MediaTypeImageConfig, - Digest: configDigest, - Size: int64(len(configBytes)), - }, - Layers: []ocispec.Descriptor{ - { - MediaType: ocispec.MediaTypeImageLayerGzip, - Digest: layerDigest, - Size: layerSize, - }, - }, - } - manifestBytes, err := json.Marshal(manifest) - require.NoError(t, err) - manifestDigest, err := store.PutManifest(t.Context(), manifestBytes) - require.NoError(t, err) - - return manifestDigest -} - -func TestInstallFromOCI(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - opts skills.InstallOptions - setup func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) - wantCode int - wantErr string - wantName string - wantVersion string - wantDigest bool - wantRefSaved string - }{ - { - name: "registry not configured", - opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - return nil, nil, storemocks.NewMockSkillStore(ctrl), skillsmocks.NewMockPathResolver(ctrl) - }, - wantCode: http.StatusInternalServerError, - }, - { - name: "ociStore not configured", - opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - return ocimocks.NewMockRegistryClient(ctrl), nil, storemocks.NewMockSkillStore(ctrl), skillsmocks.NewMockPathResolver(ctrl) - }, - wantCode: http.StatusInternalServerError, - }, - { - name: "pathResolver not configured", - opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - return ocimocks.NewMockRegistryClient(ctrl), ociStore, storemocks.NewMockSkillStore(ctrl), nil - }, - wantCode: http.StatusInternalServerError, - }, - { - name: "pull error propagates", - opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). - Return(godigest.Digest(""), fmt.Errorf("auth required")) - pr := skillsmocks.NewMockPathResolver(ctrl) - return reg, ociStore, storemocks.NewMockSkillStore(ctrl), pr - }, - wantErr: "auth required", - }, - { - name: "invalid skill name in artifact", - opts: skills.InstallOptions{Name: "ghcr.io/org/bad-artifact:v1"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - - // Build an artifact with an invalid skill name (uppercase). - skillDir := filepath.Join(tempDir(t), "INVALID") - require.NoError(t, os.MkdirAll(skillDir, 0o750)) - require.NoError(t, os.WriteFile( - filepath.Join(skillDir, "SKILL.md"), - []byte("---\nname: INVALID\ndescription: test\n---\n# Bad"), - 0o600, - )) - packager := ociskills.NewPackager(ociStore) - result, pkgErr := packager.Package(t.Context(), skillDir, ociskills.DefaultPackageOptions()) - require.NoError(t, pkgErr) - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/bad-artifact:v1"). - Return(result.IndexDigest, nil) - pr := skillsmocks.NewMockPathResolver(ctrl) - return reg, ociStore, storemocks.NewMockSkillStore(ctrl), pr - }, - wantCode: http.StatusUnprocessableEntity, - }, - { - name: "oversized layer returns 422", - opts: skills.InstallOptions{Name: "ghcr.io/org/oversize-skill:v1"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - manifestDigest := buildManifestWithLayerSize(t, ociStore, "oversize-skill", maxCompressedLayerSize+1) - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/oversize-skill:v1"). - Return(manifestDigest, nil) - pr := skillsmocks.NewMockPathResolver(ctrl) - return reg, ociStore, storemocks.NewMockSkillStore(ctrl), pr - }, - wantCode: http.StatusUnprocessableEntity, - wantErr: "compressed layer size", - }, - { - name: "successful pull and install", - opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1", Clients: []string{"claude-code"}}, - setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). - Return(indexDigest, nil) - - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - targetDir := filepath.Join(tempDir(t), "installed", "my-skill") - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, sk skills.InstalledSkill) error { - assert.Equal(t, "my-skill", sk.Metadata.Name) - assert.Equal(t, "1.0.0", sk.Metadata.Version) - assert.Equal(t, "ghcr.io/org/my-skill:v1", sk.Reference) - assert.Contains(t, sk.Digest, "sha256:") - assert.Equal(t, skills.InstallStatusInstalled, sk.Status) - return nil - }) - return reg, ociStore, store, pr - }, - wantName: "my-skill", - wantVersion: "1.0.0", - wantDigest: true, - wantRefSaved: "ghcr.io/org/my-skill:v1", - }, - { - name: "name mismatch between artifact and reference is rejected", - opts: skills.InstallOptions{Name: "ghcr.io/org/some-repo:v1", Clients: []string{"claude-code"}}, - setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - // The artifact declares itself as "actual-skill", not "some-repo". - indexDigest := buildTestArtifact(t, ociStore, "actual-skill", "2.0.0") - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/some-repo:v1"). - Return(indexDigest, nil) - - pr := skillsmocks.NewMockPathResolver(ctrl) - return reg, ociStore, storemocks.NewMockSkillStore(ctrl), pr - }, - wantCode: http.StatusUnprocessableEntity, - wantErr: "does not match OCI reference repository", - }, - { - name: "preserves caller version over config version", - opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1", Version: "override-version", Clients: []string{"claude-code"}}, - setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). - Return(indexDigest, nil) - - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - targetDir := filepath.Join(tempDir(t), "installed", "my-skill") - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, sk skills.InstalledSkill) error { - assert.Equal(t, "override-version", sk.Metadata.Version) - return nil - }) - return reg, ociStore, store, pr - }, - wantName: "my-skill", - wantVersion: "override-version", - }, - { - name: "hydrates version from config when caller omits it", - opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1", Clients: []string{"claude-code"}}, - setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - indexDigest := buildTestArtifact(t, ociStore, "my-skill", "3.0.0") - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). - Return(indexDigest, nil) - - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - targetDir := filepath.Join(tempDir(t), "installed", "my-skill") - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, sk skills.InstalledSkill) error { - assert.Equal(t, "3.0.0", sk.Metadata.Version) - return nil - }) - return reg, ociStore, store, pr - }, - wantName: "my-skill", - wantVersion: "3.0.0", - }, - { - name: "invalid OCI reference returns 400", - opts: skills.InstallOptions{Name: "not://valid:ref:extra"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - return nil, nil, storemocks.NewMockSkillStore(ctrl), nil - }, - wantCode: http.StatusBadRequest, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - registry, ociStore, store, pr := tt.setup(t, ctrl) - - var opts []Option - if registry != nil { - opts = append(opts, WithRegistryClient(registry)) - } - if ociStore != nil { - opts = append(opts, WithOCIStore(ociStore)) - } - if pr != nil { - opts = append(opts, WithPathResolver(pr)) - } - - svc := New(store, opts...) - result, err := svc.Install(t.Context(), tt.opts) - - if tt.wantCode != 0 { - require.Error(t, err) - assert.Equal(t, tt.wantCode, httperr.Code(err)) - return - } - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - require.NoError(t, err) - if tt.wantName != "" { - assert.Equal(t, tt.wantName, result.Skill.Metadata.Name) - } - if tt.wantVersion != "" { - assert.Equal(t, tt.wantVersion, result.Skill.Metadata.Version) - } - if tt.wantDigest { - assert.Contains(t, result.Skill.Digest, "sha256:") - } - if tt.wantRefSaved != "" { - assert.Equal(t, tt.wantRefSaved, result.Skill.Reference) - } - }) - } -} - -func TestInstallFromLocalStore(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - opts skills.InstallOptions - setup func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) - wantCode int - wantErr string - wantStatus string - wantVersion string - wantDigest bool - }{ - { - name: "happy path: build then install", - opts: skills.InstallOptions{Name: "my-skill", Clients: []string{"claude-code"}}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - - // Build an artifact and tag it with the skill name. - indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") - require.NoError(t, ociStore.Tag(t.Context(), indexDigest, "my-skill")) - - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - targetDir := filepath.Join(tempDir(t), "installed", "my-skill") - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, sk skills.InstalledSkill) error { - assert.Equal(t, "my-skill", sk.Metadata.Name) - assert.Equal(t, "1.0.0", sk.Metadata.Version) - assert.Contains(t, sk.Digest, "sha256:") - assert.Equal(t, skills.InstallStatusInstalled, sk.Status) - return nil - }) - return ociStore, store, pr - }, - wantStatus: string(skills.InstallStatusInstalled), - wantVersion: "1.0.0", - wantDigest: true, - }, - { - name: "name mismatch in local artifact", - opts: skills.InstallOptions{Name: "evil-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - - // Build "real-skill" but tag it as "evil-skill". - indexDigest := buildTestArtifact(t, ociStore, "real-skill", "1.0.0") - require.NoError(t, ociStore.Tag(t.Context(), indexDigest, "evil-skill")) - - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - return ociStore, store, pr - }, - wantCode: http.StatusUnprocessableEntity, - wantErr: "does not match install name", - }, - { - name: "tag not found returns not found error", - opts: skills.InstallOptions{Name: "no-such-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - // Empty store — no tags. - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - return ociStore, store, pr - }, - wantCode: http.StatusNotFound, - wantErr: "not found in local store or registry", - }, - { - name: "nil ociStore returns not found error", - opts: skills.InstallOptions{Name: "some-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - store := storemocks.NewMockSkillStore(ctrl) - return nil, store, nil - }, - wantCode: http.StatusNotFound, - wantErr: "not found in local store or registry", - }, - { - name: "corrupt manifest propagates error", - opts: skills.InstallOptions{Name: "corrupt-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - - // Store raw bytes as a "manifest" and tag it — this will - // fail during extractOCIContent because it's not valid JSON. - badManifest := []byte(`not valid json`) - d, putErr := ociStore.PutManifest(t.Context(), badManifest) - require.NoError(t, putErr) - require.NoError(t, ociStore.Tag(t.Context(), d, "corrupt-skill")) - - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - return ociStore, store, pr - }, - wantErr: "checking OCI content type", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - ociStore, store, pr := tt.setup(t, ctrl) - - var opts []Option - if ociStore != nil { - opts = append(opts, WithOCIStore(ociStore)) - } - if pr != nil { - opts = append(opts, WithPathResolver(pr)) - } - - svc := New(store, opts...) - result, err := svc.Install(t.Context(), tt.opts) - - if tt.wantCode != 0 { - require.Error(t, err) - assert.Equal(t, tt.wantCode, httperr.Code(err)) - return - } - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - require.NoError(t, err) - if tt.wantStatus != "" { - assert.Equal(t, tt.wantStatus, string(result.Skill.Status)) - } - if tt.wantVersion != "" { - assert.Equal(t, tt.wantVersion, result.Skill.Metadata.Version) - } - if tt.wantDigest { - assert.Contains(t, result.Skill.Digest, "sha256:") - } - }) - } -} - -func TestUninstall(t *testing.T) { - t.Parallel() - - projectRoot := makeProjectRoot(t) - - t.Run("success with file cleanup", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - - // Create a skill directory to be cleaned up - skillDir := filepath.Join(t.TempDir(), "my-skill") - require.NoError(t, os.MkdirAll(skillDir, 0750)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0600)) - - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Scope: skills.ScopeUser, - Clients: []string{"claude-code"}, - } - - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(skillDir, nil) - store.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) - - svc := New(store, WithPathResolver(pr)) - err := svc.Uninstall(t.Context(), skills.UninstallOptions{Name: "my-skill"}) - require.NoError(t, err) - - // Verify directory was removed - _, statErr := os.Stat(skillDir) - assert.True(t, os.IsNotExist(statErr)) - }) - - t.Run("success without path resolver", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Scope: skills.ScopeUser, - } - - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - store.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) - - svc := New(store) - err := svc.Uninstall(t.Context(), skills.UninstallOptions{Name: "my-skill"}) - require.NoError(t, err) - }) - - t.Run("respects explicit scope", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Scope: skills.ScopeProject, - } - - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeProject, projectRoot).Return(existing, nil) - store.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeProject, projectRoot).Return(nil) - - svc := New(store) - err := svc.Uninstall(t.Context(), skills.UninstallOptions{ - Name: "my-skill", - Scope: skills.ScopeProject, - ProjectRoot: projectRoot, - }) - require.NoError(t, err) - }) - - t.Run("project scope requires project root", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - - svc := New(store) - err := svc.Uninstall(t.Context(), skills.UninstallOptions{ - Name: "my-skill", - Scope: skills.ScopeProject, - }) - require.Error(t, err) - assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) - }) - - t.Run("returns 404 when not found", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - - svc := New(store) - err := svc.Uninstall(t.Context(), skills.UninstallOptions{Name: "my-skill"}) - require.Error(t, err) - assert.Equal(t, http.StatusNotFound, httperr.Code(err)) - }) - - t.Run("rejects invalid name", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - - svc := New(store) - err := svc.Uninstall(t.Context(), skills.UninstallOptions{Name: "X"}) - require.Error(t, err) - assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) - }) - - t.Run("cleans up all clients", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - - dir1 := filepath.Join(t.TempDir(), "client1", "my-skill") - dir2 := filepath.Join(t.TempDir(), "client2", "my-skill") - require.NoError(t, os.MkdirAll(dir1, 0750)) - require.NoError(t, os.MkdirAll(dir2, 0750)) - - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Scope: skills.ScopeUser, - Clients: []string{"client-a", "client-b"}, - } - - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - pr.EXPECT().GetSkillPath("client-a", "my-skill", skills.ScopeUser, "").Return(dir1, nil) - pr.EXPECT().GetSkillPath("client-b", "my-skill", skills.ScopeUser, "").Return(dir2, nil) - store.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) - - svc := New(store, WithPathResolver(pr)) - err := svc.Uninstall(t.Context(), skills.UninstallOptions{Name: "my-skill"}) - require.NoError(t, err) - - _, statErr1 := os.Stat(dir1) - assert.True(t, os.IsNotExist(statErr1)) - _, statErr2 := os.Stat(dir2) - assert.True(t, os.IsNotExist(statErr2)) - }) - - t.Run("best-effort cleanup continues on remove error", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - inst := skillsmocks.NewMockInstaller(ctrl) - - existing := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Scope: skills.ScopeUser, - Clients: []string{"client-a", "client-b"}, - } - - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) - pr.EXPECT().GetSkillPath("client-a", "my-skill", skills.ScopeUser, "").Return("/some/dir-a", nil) - pr.EXPECT().GetSkillPath("client-b", "my-skill", skills.ScopeUser, "").Return("/some/dir-b", nil) - // First remove fails, but second should still be attempted - inst.EXPECT().Remove("/some/dir-a").Return(fmt.Errorf("permission denied")) - inst.EXPECT().Remove("/some/dir-b").Return(nil) - store.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) - - svc := New(store, WithPathResolver(pr), WithInstaller(inst)) - err := svc.Uninstall(t.Context(), skills.UninstallOptions{Name: "my-skill"}) - // Store deletion succeeds, but cleanup errors are returned - require.Error(t, err) - assert.Contains(t, err.Error(), "permission denied") - }) -} - -func TestInfo(t *testing.T) { - t.Parallel() - - projectRoot := makeProjectRoot(t) - - installed := skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill", Version: "1.0.0"}, - Scope: skills.ScopeUser, - Status: skills.InstallStatusInstalled, - } - - tests := []struct { - name string - opts skills.InfoOptions - setupMock func(*storemocks.MockSkillStore) - wantCode int - wantErr string - }{ - { - name: "found skill", - opts: skills.InfoOptions{Name: "my-skill"}, - setupMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(installed, nil) - }, - }, - { - name: "not found returns 404", - opts: skills.InfoOptions{Name: "unknown"}, - setupMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().Get(gomock.Any(), "unknown", skills.ScopeUser, ""). - Return(skills.InstalledSkill{}, storage.ErrNotFound) - }, - wantCode: http.StatusNotFound, - }, - { - name: "propagates store errors", - opts: skills.InfoOptions{Name: "my-skill"}, - setupMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, ""). - Return(skills.InstalledSkill{}, fmt.Errorf("db error")) - }, - wantErr: "db error", - }, - { - name: "rejects invalid name", - opts: skills.InfoOptions{Name: "X"}, - setupMock: func(_ *storemocks.MockSkillStore) {}, - wantCode: http.StatusBadRequest, - }, - { - name: "rejects empty name", - opts: skills.InfoOptions{Name: ""}, - setupMock: func(_ *storemocks.MockSkillStore) {}, - wantCode: http.StatusBadRequest, - }, - { - name: "respects project scope", - opts: skills.InfoOptions{Name: "my-skill", Scope: skills.ScopeProject, ProjectRoot: projectRoot}, - setupMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeProject, projectRoot).Return(installed, nil) - }, - }, - { - name: "project scope missing project root", - opts: skills.InfoOptions{Name: "my-skill", Scope: skills.ScopeProject}, - setupMock: func(_ *storemocks.MockSkillStore) {}, - wantCode: http.StatusBadRequest, - }, - { - name: "defaults to user scope when empty", - opts: skills.InfoOptions{Name: "my-skill", Scope: ""}, - setupMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(installed, nil) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - tt.setupMock(store) - - info, err := New(store).Info(t.Context(), tt.opts) - if tt.wantCode != 0 { - require.Error(t, err) - assert.Equal(t, tt.wantCode, httperr.Code(err)) - return - } - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - require.NoError(t, err) - require.NotNil(t, info.InstalledSkill) - assert.Equal(t, "my-skill", info.InstalledSkill.Metadata.Name) - }) - } -} - -func TestValidate(t *testing.T) { - t.Parallel() - - t.Run("valid skill directory", func(t *testing.T) { - t.Parallel() - dir := t.TempDir() - skillDir := filepath.Join(dir, "test-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o750)) - require.NoError(t, os.WriteFile( - filepath.Join(skillDir, "SKILL.md"), - []byte("---\nname: test-skill\ndescription: A test skill\n---\n# Test Skill\n"), - 0o600, - )) - - svc := New(&storage.NoopSkillStore{}) - result, err := svc.Validate(t.Context(), skillDir) - require.NoError(t, err) - assert.True(t, result.Valid) - }) - - t.Run("missing SKILL.md", func(t *testing.T) { - t.Parallel() - svc := New(&storage.NoopSkillStore{}) - result, err := svc.Validate(t.Context(), t.TempDir()) - require.NoError(t, err) - assert.False(t, result.Valid) - assert.Contains(t, result.Errors, "SKILL.md not found in skill directory") - }) - - t.Run("empty path returns 400", func(t *testing.T) { - t.Parallel() - svc := New(&storage.NoopSkillStore{}) - _, err := svc.Validate(t.Context(), "") - require.Error(t, err) - assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) - }) - - t.Run("relative path returns 400", func(t *testing.T) { - t.Parallel() - svc := New(&storage.NoopSkillStore{}) - _, err := svc.Validate(t.Context(), "relative/path") - require.Error(t, err) - assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) - }) - - t.Run("path traversal returns 400", func(t *testing.T) { - t.Parallel() - svc := New(&storage.NoopSkillStore{}) - _, err := svc.Validate(t.Context(), "/foo/../../../etc") - require.Error(t, err) - assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) - }) -} - -// putTestManifest stores a minimal manifest in the OCI store and returns its digest. -func putTestManifest(t *testing.T, store *ociskills.Store) godigest.Digest { - t.Helper() - d, err := store.PutManifest(t.Context(), []byte(`{"schemaVersion":2}`)) - require.NoError(t, err) - return d -} - -func TestBuild(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - opts skills.BuildOptions - setup func(*gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) - wantCode int - wantRef string - wantErr string - }{ - { - name: "nil packager returns 500", - opts: skills.BuildOptions{Path: "/some/dir"}, - setup: func(_ *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { - return nil, nil - }, - wantCode: http.StatusInternalServerError, - }, - { - name: "empty path returns 400", - opts: skills.BuildOptions{Path: ""}, - setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - return ocimocks.NewMockSkillPackager(ctrl), ociStore - }, - wantCode: http.StatusBadRequest, - }, - { - name: "relative path returns 400", - opts: skills.BuildOptions{Path: "relative/path"}, - setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - return ocimocks.NewMockSkillPackager(ctrl), ociStore - }, - wantCode: http.StatusBadRequest, - }, - { - name: "path traversal returns 400", - opts: skills.BuildOptions{Path: "/some/dir/../../../etc"}, - setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - return ocimocks.NewMockSkillPackager(ctrl), ociStore - }, - wantCode: http.StatusBadRequest, - }, - { - name: "invalid tag returns 400", - opts: skills.BuildOptions{Path: "/some/dir", Tag: "invalid tag!@#"}, - setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - d := putTestManifest(t, ociStore) - p := ocimocks.NewMockSkillPackager(ctrl) - p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). - Return(&ociskills.PackageResult{ - IndexDigest: d, - Config: &ociskills.SkillConfig{}, - }, nil) - return p, ociStore - }, - wantCode: http.StatusBadRequest, - }, - { - name: "packager error propagates", - opts: skills.BuildOptions{Path: "/some/dir"}, - setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - p := ocimocks.NewMockSkillPackager(ctrl) - p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). - Return(nil, fmt.Errorf("packaging failed")) - return p, ociStore - }, - wantErr: "packaging skill", - }, - { - name: "successful build with explicit tag", - opts: skills.BuildOptions{Path: "/some/dir", Tag: "v1.0.0"}, - setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - d := putTestManifest(t, ociStore) - p := ocimocks.NewMockSkillPackager(ctrl) - p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). - Return(&ociskills.PackageResult{ - IndexDigest: d, - Config: &ociskills.SkillConfig{Name: "my-skill"}, - }, nil) - return p, ociStore - }, - wantRef: "v1.0.0", - }, - { - name: "build without tag uses config name", - opts: skills.BuildOptions{Path: "/some/dir"}, - setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - d := putTestManifest(t, ociStore) - p := ocimocks.NewMockSkillPackager(ctrl) - p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). - Return(&ociskills.PackageResult{ - IndexDigest: d, - Config: &ociskills.SkillConfig{Name: "my-skill"}, - }, nil) - return p, ociStore - }, - wantRef: "my-skill", - }, - { - name: "build without tag or config name returns digest", - opts: skills.BuildOptions{Path: "/some/dir"}, - setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - d := putTestManifest(t, ociStore) - p := ocimocks.NewMockSkillPackager(ctrl) - p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). - Return(&ociskills.PackageResult{ - IndexDigest: d, - Config: &ociskills.SkillConfig{}, - }, nil) - return p, ociStore - }, - // wantRef is set dynamically below since the digest depends on store content - }, - { - name: "invalid fallback config name returns 400", - opts: skills.BuildOptions{Path: "/some/dir"}, - setup: func(ctrl *gomock.Controller) (ociskills.SkillPackager, *ociskills.Store) { - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - d := putTestManifest(t, ociStore) - p := ocimocks.NewMockSkillPackager(ctrl) - p.EXPECT().Package(gomock.Any(), "/some/dir", gomock.Any()). - Return(&ociskills.PackageResult{ - IndexDigest: d, - Config: &ociskills.SkillConfig{Name: "invalid name!@#"}, - }, nil) - return p, ociStore - }, - wantCode: http.StatusBadRequest, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - packager, ociStore := tt.setup(ctrl) - - svc := New(&storage.NoopSkillStore{}, - WithPackager(packager), - WithOCIStore(ociStore), - ) - - result, err := svc.Build(t.Context(), tt.opts) - if tt.wantCode != 0 { - require.Error(t, err) - assert.Equal(t, tt.wantCode, httperr.Code(err)) - return - } - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - require.NoError(t, err) - if tt.wantRef != "" { - assert.Equal(t, tt.wantRef, result.Reference) - } else { - // Fallback case returns a digest string - assert.Contains(t, result.Reference, "sha256:") - } - }) - } -} - -func TestPush(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - opts skills.PushOptions - setup func(*gomock.Controller) (ociskills.RegistryClient, *ociskills.Store) - wantCode int - wantErr string - }{ - { - name: "nil registry returns 500", - opts: skills.PushOptions{Reference: "ghcr.io/test/skill:v1"}, - setup: func(_ *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store) { - return nil, nil - }, - wantCode: http.StatusInternalServerError, - }, - { - name: "empty reference returns 400", - opts: skills.PushOptions{Reference: ""}, - setup: func(ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store) { - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - return ocimocks.NewMockRegistryClient(ctrl), ociStore - }, - wantCode: http.StatusBadRequest, - }, - { - name: "resolve not found returns 404", - opts: skills.PushOptions{Reference: "nonexistent"}, - setup: func(ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store) { - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - return ocimocks.NewMockRegistryClient(ctrl), ociStore - }, - wantCode: http.StatusNotFound, - }, - { - name: "registry push error propagates", - opts: skills.PushOptions{Reference: "my-tag"}, - setup: func(ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store) { - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - // Create a manifest so Resolve succeeds. - d, tagErr := ociStore.PutManifest(t.Context(), []byte(`{"schemaVersion":2}`)) - require.NoError(t, tagErr) - require.NoError(t, ociStore.Tag(t.Context(), d, "my-tag")) - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Push(gomock.Any(), ociStore, d, "my-tag"). - Return(fmt.Errorf("auth failed")) - return reg, ociStore - }, - wantErr: "pushing to registry", - }, - { - name: "successful push", - opts: skills.PushOptions{Reference: "my-tag"}, - setup: func(ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store) { - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - d, tagErr := ociStore.PutManifest(t.Context(), []byte(`{"schemaVersion":2}`)) - require.NoError(t, tagErr) - require.NoError(t, ociStore.Tag(t.Context(), d, "my-tag")) - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Push(gomock.Any(), ociStore, d, "my-tag").Return(nil) - return reg, ociStore - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - registry, ociStore := tt.setup(ctrl) - - svc := New(&storage.NoopSkillStore{}, - WithRegistryClient(registry), - WithOCIStore(ociStore), - ) - - err := svc.Push(t.Context(), tt.opts) - if tt.wantCode != 0 { - require.Error(t, err) - assert.Equal(t, tt.wantCode, httperr.Code(err)) - return - } - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - require.NoError(t, err) - }) - } -} - -func TestNewWithZeroOptions(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - - // New(store) without options should work - svc := New(store) - require.NotNil(t, svc) -} - -func TestConcurrentInstallAndUninstall(t *testing.T) { - t.Parallel() - - layerData := makeLayerData(t) - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - - // Per-skill atomic counters verify that at most one goroutine is inside - // a critical section for a given skill at any time. - var inFlight sync.Map // skill name -> *int32 - - assertExclusive := func(name string) { - counter, _ := inFlight.LoadOrStore(name, new(int32)) - cnt := counter.(*int32) - cur := atomic.AddInt32(cnt, 1) - assert.Equal(t, int32(1), cur, "concurrent access detected for %s", name) - // Sleep briefly to widen the window for detecting overlap. - time.Sleep(time.Millisecond) - atomic.AddInt32(cnt, -1) - } - - // PathResolver returns unique temp directories per skill so extractions - // don't collide. Use a temp base that outlives individual subtests. - baseDir := tempDir(t) - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}).AnyTimes() - pr.EXPECT().GetSkillPath(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( - func(_, skillName string, _ skills.Scope, _ string) (string, error) { - return filepath.Join(baseDir, skillName), nil - }).AnyTimes() - - store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, sk skills.InstalledSkill) error { - assertExclusive(sk.Metadata.Name) - return nil - }).AnyTimes() - store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, name string, _ skills.Scope, _ string) (skills.InstalledSkill, error) { - assertExclusive(name) - return skills.InstalledSkill{}, storage.ErrNotFound - }).AnyTimes() - store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, sk skills.InstalledSkill) error { - assertExclusive(sk.Metadata.Name) - return nil - }).AnyTimes() - store.EXPECT().Delete(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, name string, _ skills.Scope, _ string) error { - assertExclusive(name) - return nil - }).AnyTimes() - - svc := New(store, WithPathResolver(pr)) - - // Run concurrent install/uninstall pairs across multiple skill names. - // Different skills proceed independently; the same skill name is - // serialized by the per-skill lock. The atomic counters above detect - // any overlap within a skill's critical section. - skillNames := []string{"skill-a", "skill-b", "skill-c"} - const goroutinesPerSkill = 5 - - var wg sync.WaitGroup - wg.Add(len(skillNames) * goroutinesPerSkill) - - for _, name := range skillNames { - for range goroutinesPerSkill { - go func() { - defer wg.Done() - // Provide LayerData so Install exercises installWithExtraction. - _, _ = svc.Install(t.Context(), skills.InstallOptions{ - Name: name, - LayerData: layerData, - Digest: "sha256:concurrent-test", - }) - // Uninstall may fail (not found) — that's fine for concurrency testing. - _ = svc.Uninstall(t.Context(), skills.UninstallOptions{Name: name}) - }() - } - } - - wg.Wait() -} - -// ---------- group-integration tests ---------- - -func TestListFiltersByGroup(t *testing.T) { - t.Parallel() - - allSkills := []skills.InstalledSkill{ - {Metadata: skills.SkillMetadata{Name: "skill-a"}}, - {Metadata: skills.SkillMetadata{Name: "skill-b"}}, - {Metadata: skills.SkillMetadata{Name: "skill-c"}}, - } - - tests := []struct { - name string - opts skills.ListOptions - setupMock func(*storemocks.MockSkillStore, *groupmocks.MockManager) - wantNames []string - wantCode int - wantErr string - }{ - { - name: "no group filter returns all skills", - opts: skills.ListOptions{}, - setupMock: func(s *storemocks.MockSkillStore, _ *groupmocks.MockManager) { - s.EXPECT().List(gomock.Any(), storage.ListFilter{}).Return(allSkills, nil) - }, - wantNames: []string{"skill-a", "skill-b", "skill-c"}, - }, - { - name: "group filter returns only matching skills", - opts: skills.ListOptions{Group: "mygroup"}, - setupMock: func(s *storemocks.MockSkillStore, gm *groupmocks.MockManager) { - s.EXPECT().List(gomock.Any(), storage.ListFilter{}).Return(allSkills, nil) - gm.EXPECT().Get(gomock.Any(), "mygroup").Return(&groups.Group{ - Name: "mygroup", - Skills: []string{"skill-a", "skill-c"}, - }, nil) - }, - wantNames: []string{"skill-a", "skill-c"}, - }, - { - name: "group filter with empty group skills returns no skills", - opts: skills.ListOptions{Group: "emptygroup"}, - setupMock: func(s *storemocks.MockSkillStore, gm *groupmocks.MockManager) { - s.EXPECT().List(gomock.Any(), storage.ListFilter{}).Return(allSkills, nil) - gm.EXPECT().Get(gomock.Any(), "emptygroup").Return(&groups.Group{ - Name: "emptygroup", - Skills: []string{}, - }, nil) - }, - wantNames: []string{}, - }, - { - name: "group filter without group manager returns error", - opts: skills.ListOptions{Group: "mygroup"}, - setupMock: func(s *storemocks.MockSkillStore, _ *groupmocks.MockManager) { - s.EXPECT().List(gomock.Any(), storage.ListFilter{}).Return(allSkills, nil) - }, - wantCode: http.StatusInternalServerError, - wantErr: "group manager is not configured", - }, - { - name: "group manager Get error propagates", - opts: skills.ListOptions{Group: "badgroup"}, - setupMock: func(s *storemocks.MockSkillStore, gm *groupmocks.MockManager) { - s.EXPECT().List(gomock.Any(), storage.ListFilter{}).Return(allSkills, nil) - gm.EXPECT().Get(gomock.Any(), "badgroup").Return(nil, fmt.Errorf("group not found")) - }, - wantErr: "getting group", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - gm := groupmocks.NewMockManager(ctrl) - tt.setupMock(store, gm) - - opts := []Option{} - // Only wire the group manager for tests that don't test the nil-manager case. - if tt.wantCode != http.StatusInternalServerError { - opts = append(opts, WithGroupManager(gm)) - } - svc := New(store, opts...) - - result, err := svc.List(t.Context(), tt.opts) - if tt.wantCode != 0 { - require.Error(t, err) - assert.Equal(t, tt.wantCode, httperr.Code(err)) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - require.NoError(t, err) - var names []string - for _, sk := range result { - names = append(names, sk.Metadata.Name) - } - if tt.wantNames == nil { - tt.wantNames = []string{} - } - if names == nil { - names = []string{} - } - assert.ElementsMatch(t, tt.wantNames, names) - }) - } -} - -func TestInstallAddsSkillToGroup(t *testing.T) { - t.Parallel() - - layerData := makeLayerData(t) - - // These tests provide LayerData so Install goes through installWithExtraction, - // which exercises group registration without needing OCI resolution. - tests := []struct { - name string - opts skills.InstallOptions - setupStoreMock func(*storemocks.MockSkillStore) - setupPR func(*skillsmocks.MockPathResolver) - setupGroupMock func(*groupmocks.MockManager) - wantErr string - }{ - { - name: "install with group registers skill", - opts: skills.InstallOptions{Name: "my-skill", Group: "mygroup", LayerData: layerData, Digest: "sha256:abc"}, - setupStoreMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - s.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) - }, - setupPR: func(pr *skillsmocks.MockPathResolver) { - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(tempDir(t), "my-skill"), nil) - }, - setupGroupMock: func(gm *groupmocks.MockManager) { - gm.EXPECT().Get(gomock.Any(), "mygroup"). - Return(&groups.Group{Name: "mygroup", Skills: []string{}}, nil) - gm.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) - }, - }, - { - name: "install without group defaults to default group", - opts: skills.InstallOptions{Name: "my-skill", LayerData: layerData, Digest: "sha256:abc"}, - setupStoreMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - s.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) - }, - setupPR: func(pr *skillsmocks.MockPathResolver) { - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(tempDir(t), "my-skill"), nil) - }, - setupGroupMock: func(gm *groupmocks.MockManager) { - gm.EXPECT().Get(gomock.Any(), groups.DefaultGroup). - Return(&groups.Group{Name: groups.DefaultGroup, Skills: []string{}}, nil) - gm.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) - }, - }, - { - name: "group registration error rolls back DB record", - opts: skills.InstallOptions{Name: "my-skill", Group: "badgroup", LayerData: layerData, Digest: "sha256:abc"}, - setupStoreMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - s.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) - // Rollback: installAndRegister removes the DB record on group failure. - s.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) - }, - setupPR: func(pr *skillsmocks.MockPathResolver) { - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(tempDir(t), "my-skill"), nil) - }, - setupGroupMock: func(gm *groupmocks.MockManager) { - gm.EXPECT().Get(gomock.Any(), "badgroup"). - Return(nil, fmt.Errorf("group not found")) - }, - wantErr: "registering skill in group", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - gm := groupmocks.NewMockManager(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - - tt.setupStoreMock(store) - tt.setupGroupMock(gm) - tt.setupPR(pr) - - svc := New(store, WithGroupManager(gm), WithPathResolver(pr)) - - _, err := svc.Install(t.Context(), tt.opts) - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestInstallFromRegistry(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - opts skills.InstallOptions - setup func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) - wantCode int - wantErr string - wantName string - wantDigest bool - wantVersion string - }{ - { - name: "registry resolves skill with OCI package", - opts: skills.InstallOptions{Name: "my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - lookup := regmocks.NewMockProvider(ctrl) - lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ - { - Namespace: "io.github.test", - Name: "my-skill", - Packages: []regtypes.SkillPackage{ - {RegistryType: "oci", Identifier: "ghcr.io/test/my-skill:v1.0.0"}, - }, - }, - }, nil) - - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - - // Build and tag an artifact with the skill name so the - // registry client can return it when Pull is called. - indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/test/my-skill:v1.0.0").Return(indexDigest, nil) - - store := storemocks.NewMockSkillStore(ctrl) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) - - pr := skillsmocks.NewMockPathResolver(ctrl) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(tempDir(t), "installed", "my-skill"), nil) - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - - return lookup, reg, ociStore, store, pr - }, - wantName: "my-skill", - wantDigest: true, - wantVersion: "1.0.0", - }, - { - name: "multiple exact name matches returns conflict", - opts: skills.InstallOptions{Name: "my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - lookup := regmocks.NewMockProvider(ctrl) - lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ - {Namespace: "io.github.alice", Name: "my-skill"}, - {Namespace: "io.github.bob", Name: "my-skill"}, - }, nil) - store := storemocks.NewMockSkillStore(ctrl) - return lookup, nil, nil, store, nil - }, - wantCode: http.StatusConflict, - wantErr: "ambiguous skill name", - }, - { - name: "skill with no installable packages returns unprocessable", - opts: skills.InstallOptions{Name: "my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - lookup := regmocks.NewMockProvider(ctrl) - lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ - { - Namespace: "io.github.test", - Name: "my-skill", - Packages: []regtypes.SkillPackage{ - {RegistryType: "npm", URL: "https://npmjs.com/test/my-skill"}, - }, - }, - }, nil) - store := storemocks.NewMockSkillStore(ctrl) - return lookup, nil, nil, store, nil - }, - wantCode: http.StatusUnprocessableEntity, - wantErr: "no installable package", - }, - { - name: "registry lookup error degrades to not found", - opts: skills.InstallOptions{Name: "my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - lookup := regmocks.NewMockProvider(ctrl) - lookup.EXPECT().SearchSkills("my-skill").Return(nil, fmt.Errorf("network error")) - store := storemocks.NewMockSkillStore(ctrl) - return lookup, nil, nil, store, nil - }, - wantCode: http.StatusNotFound, - wantErr: "not found in local store or registry", - }, - { - name: "nil skill lookup returns not found", - opts: skills.InstallOptions{Name: "my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - store := storemocks.NewMockSkillStore(ctrl) - return nil, nil, nil, store, nil - }, - wantCode: http.StatusNotFound, - wantErr: "not found in local store or registry", - }, - { - name: "partial name match only returns not found", - opts: skills.InstallOptions{Name: "my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - lookup := regmocks.NewMockProvider(ctrl) - // Search returns a skill with a different name (partial match only). - lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ - {Namespace: "io.github.test", Name: "my-skill-extended"}, - }, nil) - store := storemocks.NewMockSkillStore(ctrl) - return lookup, nil, nil, store, nil - }, - wantCode: http.StatusNotFound, - wantErr: "not found in local store or registry", - }, - { - name: "invalid OCI identifier in registry result returns unprocessable", - opts: skills.InstallOptions{Name: "my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - lookup := regmocks.NewMockProvider(ctrl) - lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ - { - Namespace: "io.github.test", - Name: "my-skill", - Packages: []regtypes.SkillPackage{ - {RegistryType: "oci", Identifier: "!!!invalid-ref!!!"}, - }, - }, - }, nil) - store := storemocks.NewMockSkillStore(ctrl) - return lookup, nil, nil, store, nil - }, - wantCode: http.StatusUnprocessableEntity, - wantErr: "invalid OCI identifier", - }, - { - name: "case-insensitive name match resolves correctly", - opts: skills.InstallOptions{Name: "my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - lookup := regmocks.NewMockProvider(ctrl) - // Registry returns mixed-case name; should still match. - lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ - { - Namespace: "io.github.test", - Name: "My-Skill", - Packages: []regtypes.SkillPackage{ - {RegistryType: "oci", Identifier: "ghcr.io/test/my-skill:v1.0.0"}, - }, - }, - }, nil) - - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/test/my-skill:v1.0.0").Return(indexDigest, nil) - - store := storemocks.NewMockSkillStore(ctrl) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) - - pr := skillsmocks.NewMockPathResolver(ctrl) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(tempDir(t), "installed", "my-skill"), nil) - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - - return lookup, reg, ociStore, store, pr - }, - wantName: "my-skill", - }, - { - name: "supply chain: registry reference with wrong repo name is rejected", - opts: skills.InstallOptions{Name: "my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - lookup := regmocks.NewMockProvider(ctrl) - // Registry points to wrong-name repo, but artifact declares my-skill. - lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ - { - Namespace: "io.github.test", - Name: "my-skill", - Packages: []regtypes.SkillPackage{ - {RegistryType: "oci", Identifier: "ghcr.io/test/wrong-name:v1.0.0"}, - }, - }, - }, nil) - - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - // Build artifact with name "my-skill" but the ref says "wrong-name". - indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/test/wrong-name:v1.0.0").Return(indexDigest, nil) - - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}).AnyTimes() - - return lookup, reg, ociStore, store, pr - }, - wantCode: http.StatusUnprocessableEntity, - wantErr: "does not match OCI reference repository", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - lookup, reg, ociStore, store, pr := tt.setup(t, ctrl) - - var opts []Option - if lookup != nil { - opts = append(opts, WithSkillLookup(lookup)) - } - if reg != nil { - opts = append(opts, WithRegistryClient(reg)) - } - if ociStore != nil { - opts = append(opts, WithOCIStore(ociStore)) - } - if pr != nil { - opts = append(opts, WithPathResolver(pr)) - } - - svc := New(store, opts...) - result, err := svc.Install(t.Context(), tt.opts) - - if tt.wantCode != 0 { - require.Error(t, err) - assert.Equal(t, tt.wantCode, httperr.Code(err)) - if tt.wantErr != "" { - assert.Contains(t, err.Error(), tt.wantErr) - } - return - } - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - require.NoError(t, err) - if tt.wantName != "" { - assert.Equal(t, tt.wantName, result.Skill.Metadata.Name) - } - if tt.wantDigest { - assert.Contains(t, result.Skill.Digest, "sha256:") - } - if tt.wantVersion != "" { - assert.Equal(t, tt.wantVersion, result.Skill.Metadata.Version) - } - }) - } -} - -func TestInstallFromGit(t *testing.T) { - t.Parallel() - - commitHash := testCommitHash - - tests := []struct { - name string - opts skills.InstallOptions - setup func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) - wantCode int - wantErr string - wantName string - wantDigest string - wantVersion string - }{ - { - name: "git reference installs via git resolver", - opts: skills.InstallOptions{Name: "git://github.com/test/my-skill@v1.0.0"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - gr := gitmocks.NewMockResolver(ctrl) - gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ - SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "1.0.0"}, - Files: []gitresolver.FileEntry{ - {Path: "SKILL.md", Content: []byte("---\nname: my-skill\n---\n# Skill"), Mode: 0644}, - }, - CommitHash: commitHash, - }, nil) - - installBase := filepath.Join(tempDir(t), "installed") - require.NoError(t, os.MkdirAll(installBase, 0o755)) - - store := storemocks.NewMockSkillStore(ctrl) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) - - pr := skillsmocks.NewMockPathResolver(ctrl) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(installBase, "my-skill"), nil) - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - - return gr, store, pr - }, - wantName: "my-skill", - wantDigest: commitHash, - wantVersion: "1.0.0", - }, - { - name: "git reference with nil resolver returns 500", - opts: skills.InstallOptions{Name: "git://github.com/test/my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - store := storemocks.NewMockSkillStore(ctrl) - return nil, store, nil - }, - wantCode: http.StatusInternalServerError, - wantErr: "git resolver is not configured", - }, - { - name: "malformed git reference returns 400", - opts: skills.InstallOptions{Name: "git://"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - gr := gitmocks.NewMockResolver(ctrl) - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - return gr, store, pr - }, - wantCode: http.StatusBadRequest, - wantErr: "invalid git reference", - }, - { - name: "git resolve failure returns 502", - opts: skills.InstallOptions{Name: "git://github.com/test/my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - gr := gitmocks.NewMockResolver(ctrl) - gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("clone failed")) - - store := storemocks.NewMockSkillStore(ctrl) - pr := skillsmocks.NewMockPathResolver(ctrl) - return gr, store, pr - }, - wantCode: http.StatusBadGateway, - wantErr: "resolving git skill", - }, - { - name: "same commit hash is no-op", - opts: skills.InstallOptions{Name: "git://github.com/test/my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - gr := gitmocks.NewMockResolver(ctrl) - gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ - SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "1.0.0"}, - Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("test"), Mode: 0644}}, - CommitHash: commitHash, - }, nil) - - store := storemocks.NewMockSkillStore(ctrl) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill", Version: "1.0.0"}, - Digest: commitHash, - Status: skills.InstallStatusInstalled, - }, nil) - - pr := skillsmocks.NewMockPathResolver(ctrl) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(tempDir(t), "installed", "my-skill"), nil) - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - - return gr, store, pr - }, - wantName: "my-skill", - wantDigest: commitHash, - }, - { - name: "unmanaged directory without force returns conflict", - opts: skills.InstallOptions{Name: "git://github.com/test/my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - gr := gitmocks.NewMockResolver(ctrl) - gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ - SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "1.0.0"}, - Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("test"), Mode: 0644}}, - CommitHash: commitHash, - }, nil) - - // Create the target dir to simulate an unmanaged directory. - installBase := filepath.Join(tempDir(t), "installed") - installDir := filepath.Join(installBase, "my-skill") - require.NoError(t, os.MkdirAll(installDir, 0o755)) - - store := storemocks.NewMockSkillStore(ctrl) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - - pr := skillsmocks.NewMockPathResolver(ctrl) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(installDir, nil) - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - - return gr, store, pr - }, - wantCode: http.StatusConflict, - wantErr: "not managed by ToolHive", - }, - { - name: "different commit hash triggers upgrade", - opts: skills.InstallOptions{Name: "git://github.com/test/my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - newCommit := "1111111111111111111111111111111111111111" - - gr := gitmocks.NewMockResolver(ctrl) - gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ - SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "2.0.0"}, - Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("---\nname: my-skill\n---\n# v2"), Mode: 0644}}, - CommitHash: newCommit, - }, nil) - - // Create the parent directory so the file lock can be created. - installBase := filepath.Join(tempDir(t), "installed") - require.NoError(t, os.MkdirAll(installBase, 0o755)) - installDir := filepath.Join(installBase, "my-skill") - - store := storemocks.NewMockSkillStore(ctrl) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill", Version: "1.0.0"}, - Digest: commitHash, - Clients: []string{"claude-code"}, - }, nil) - store.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) - - pr := skillsmocks.NewMockPathResolver(ctrl) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(installDir, nil) - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - - return gr, store, pr - }, - wantName: "my-skill", - wantDigest: "1111111111111111111111111111111111111111", - wantVersion: "2.0.0", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - gr, store, pr := tt.setup(t, ctrl) - - var opts []Option - if gr != nil { - opts = append(opts, WithGitResolver(gr)) - } - if pr != nil { - opts = append(opts, WithPathResolver(pr)) - } - - svc := New(store, opts...) - - // Override the default git resolver when nil is expected. - if gr == nil { - svc.(*service).gitResolver = nil - } - - result, err := svc.Install(t.Context(), tt.opts) - - if tt.wantCode != 0 { - require.Error(t, err) - assert.Equal(t, tt.wantCode, httperr.Code(err)) - if tt.wantErr != "" { - assert.Contains(t, err.Error(), tt.wantErr) - } - return - } - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - require.NoError(t, err) - if tt.wantName != "" { - assert.Equal(t, tt.wantName, result.Skill.Metadata.Name) - } - if tt.wantDigest != "" { - assert.Equal(t, tt.wantDigest, result.Skill.Digest) - } - if tt.wantVersion != "" { - assert.Equal(t, tt.wantVersion, result.Skill.Metadata.Version) - } - }) - } -} - -func TestInstallFromGitGroupRegistrationRollback(t *testing.T) { - t.Parallel() - - commitHash := testCommitHash - - ctrl := gomock.NewController(t) - - gr := gitmocks.NewMockResolver(ctrl) - gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ - SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "1.0.0"}, - Files: []gitresolver.FileEntry{ - {Path: "SKILL.md", Content: []byte("---\nname: my-skill\n---\n# Skill"), Mode: 0644}, - }, - CommitHash: commitHash, - }, nil) - - installBase := filepath.Join(tempDir(t), "installed") - require.NoError(t, os.MkdirAll(installBase, 0o755)) - - store := storemocks.NewMockSkillStore(ctrl) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) - // Rollback: DB record is removed when group registration fails. - store.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) - - pr := skillsmocks.NewMockPathResolver(ctrl) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(installBase, "my-skill"), nil) - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - - gm := groupmocks.NewMockManager(ctrl) - gm.EXPECT().Get(gomock.Any(), "badgroup").Return(nil, fmt.Errorf("group not found")) - - svc := New(store, WithGitResolver(gr), WithPathResolver(pr), WithGroupManager(gm)) - - _, err := svc.Install(t.Context(), skills.InstallOptions{ - Name: "git://github.com/test/my-skill@v1.0.0", - Group: "badgroup", - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "registering skill in group") - assert.Contains(t, err.Error(), "group not found") -} - -func TestInstallFromRegistryGitFallback(t *testing.T) { - t.Parallel() - - commitHash := testCommitHash - - tests := []struct { - name string - opts skills.InstallOptions - setup func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) - wantCode int - wantErr string - wantName string - }{ - { - name: "registry git package triggers git install", - opts: skills.InstallOptions{Name: "my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - lookup := regmocks.NewMockProvider(ctrl) - lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ - { - Namespace: "io.github.test", - Name: "my-skill", - Packages: []regtypes.SkillPackage{ - {RegistryType: "git", URL: "https://github.com/test/my-skill"}, - }, - }, - }, nil) - - gr := gitmocks.NewMockResolver(ctrl) - gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ - SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "1.0.0"}, - Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("---\nname: my-skill\n---\n"), Mode: 0644}}, - CommitHash: commitHash, - }, nil) - - installBase := filepath.Join(tempDir(t), "installed") - require.NoError(t, os.MkdirAll(installBase, 0o755)) - - store := storemocks.NewMockSkillStore(ctrl) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) - - pr := skillsmocks.NewMockPathResolver(ctrl) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(installBase, "my-skill"), nil) - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - - return lookup, gr, store, pr - }, - wantName: "my-skill", - }, - { - name: "registry git package with invalid URL returns unprocessable", - opts: skills.InstallOptions{Name: "my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - lookup := regmocks.NewMockProvider(ctrl) - lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ - { - Namespace: "io.github.test", - Name: "my-skill", - Packages: []regtypes.SkillPackage{ - {RegistryType: "git", URL: "ftp://invalid/no-owner"}, - }, - }, - }, nil) - - store := storemocks.NewMockSkillStore(ctrl) - return lookup, nil, store, nil - }, - wantCode: http.StatusUnprocessableEntity, - wantErr: "invalid git URL", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - lookup, gr, store, pr := tt.setup(t, ctrl) - - var opts []Option - if lookup != nil { - opts = append(opts, WithSkillLookup(lookup)) - } - if gr != nil { - opts = append(opts, WithGitResolver(gr)) - } - if pr != nil { - opts = append(opts, WithPathResolver(pr)) - } - - svc := New(store, opts...) - result, err := svc.Install(t.Context(), tt.opts) - - if tt.wantCode != 0 { - require.Error(t, err) - assert.Equal(t, tt.wantCode, httperr.Code(err)) - if tt.wantErr != "" { - assert.Contains(t, err.Error(), tt.wantErr) - } - return - } - require.NoError(t, err) - if tt.wantName != "" { - assert.Equal(t, tt.wantName, result.Skill.Metadata.Name) - } - }) - } -} - -// TestInstallQualifiedNameOCIFallback covers the scenario where Install -// receives a "namespace/name" string, which is parsed as an OCI reference but -// fails to pull because the namespace looks like a registry host. The service -// must fall back to a registry catalogue lookup and complete the install from -// the resolved package (OCI or git). Names that carry an explicit tag or digest -// (unambiguously OCI) must NOT trigger a fallback. -func TestInstallQualifiedNameOCIFallback(t *testing.T) { - t.Parallel() - - commitHash := testCommitHash - - tests := []struct { - name string - opts skills.InstallOptions - setup func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) - wantCode int - wantErr string - wantName string - }{ - { - name: "qualified namespace/name falls back to registry OCI package", - opts: skills.InstallOptions{Name: "io.github.stacklok/my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - - indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") - - reg := ocimocks.NewMockRegistryClient(ctrl) - // First Pull is for the raw "io.github.stacklok/my-skill:latest" — fails. - reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "io.github.stacklok/my-skill:latest"). - Return(godigest.Digest(""), fmt.Errorf("no such host")). - Times(1) - // Second Pull is after registry lookup resolves the real OCI ref. - reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/stacklok/my-skill:v1.0.0"). - Return(indexDigest, nil) - - lookup := regmocks.NewMockProvider(ctrl) - lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ - { - Namespace: "io.github.stacklok", - Name: "my-skill", - Packages: []regtypes.SkillPackage{ - {RegistryType: "oci", Identifier: "ghcr.io/stacklok/my-skill:v1.0.0"}, - }, - }, - }, nil) - - installBase := filepath.Join(tempDir(t), "installed") - require.NoError(t, os.MkdirAll(installBase, 0o755)) - - store := storemocks.NewMockSkillStore(ctrl) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) - - pr := skillsmocks.NewMockPathResolver(ctrl) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(installBase, "my-skill"), nil) - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - - return lookup, reg, ociStore, nil, store, pr - }, - wantName: "my-skill", - }, - { - name: "qualified namespace/name falls back to registry git package", - opts: skills.InstallOptions{Name: "io.github.stacklok/my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "io.github.stacklok/my-skill:latest"). - Return(godigest.Digest(""), fmt.Errorf("no such host")) - - lookup := regmocks.NewMockProvider(ctrl) - lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ - { - Namespace: "io.github.stacklok", - Name: "my-skill", - Packages: []regtypes.SkillPackage{ - {RegistryType: "git", URL: "https://github.com/stacklok/my-skill"}, - }, - }, - }, nil) - - gr := gitmocks.NewMockResolver(ctrl) - gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ - SkillConfig: &skills.ParseResult{Name: "my-skill", Version: "1.0.0"}, - Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("---\nname: my-skill\n---\n"), Mode: 0644}}, - CommitHash: commitHash, - }, nil) - - installBase := filepath.Join(tempDir(t), "installed") - require.NoError(t, os.MkdirAll(installBase, 0o755)) - - store := storemocks.NewMockSkillStore(ctrl) - store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) - store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) - - pr := skillsmocks.NewMockPathResolver(ctrl) - pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(filepath.Join(installBase, "my-skill"), nil) - pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}) - - return lookup, reg, ociStore, gr, store, pr - }, - wantName: "my-skill", - }, - { - name: "explicit OCI tag does not fall back to registry on pull failure", - opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill:v1"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/org/my-skill:v1"). - Return(godigest.Digest(""), fmt.Errorf("auth required")) - - // pathResolver must be non-nil so installFromOCI proceeds past its - // nil guard and reaches the Pull call. - pr := skillsmocks.NewMockPathResolver(ctrl) - - store := storemocks.NewMockSkillStore(ctrl) - - // No lookup mock — gomock will fail the test if SearchSkills is called. - return nil, reg, ociStore, nil, store, pr - }, - wantCode: http.StatusBadRequest, - wantErr: "auth required", - }, - { - name: "qualified name with no registry match returns original OCI error", - opts: skills.InstallOptions{Name: "io.github.stacklok/my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "io.github.stacklok/my-skill:latest"). - Return(godigest.Digest(""), fmt.Errorf("no such host")) - - // pathResolver must be non-nil so installFromOCI proceeds past its - // nil guard and reaches the Pull call. - pr := skillsmocks.NewMockPathResolver(ctrl) - - lookup := regmocks.NewMockProvider(ctrl) - lookup.EXPECT().SearchSkills("my-skill").Return(nil, nil) - - store := storemocks.NewMockSkillStore(ctrl) - return lookup, reg, ociStore, nil, store, pr - }, - wantCode: http.StatusBadRequest, - wantErr: "no such host", - }, - { - name: "digest ref does not fall back to registry on pull failure", - // A full 64-char SHA256 hex digest — required for nameref.ParseReference to accept it. - opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/org/my-skill@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"). - Return(godigest.Digest(""), fmt.Errorf("manifest unknown")) - - pr := skillsmocks.NewMockPathResolver(ctrl) - store := storemocks.NewMockSkillStore(ctrl) - // No lookup mock — gomock will fail the test if SearchSkills is called. - return nil, reg, ociStore, nil, store, pr - }, - wantCode: http.StatusBadRequest, - wantErr: "manifest unknown", - }, - { - name: "multi-segment OCI ref does not fall back to registry on pull failure", - opts: skills.InstallOptions{Name: "ghcr.io/org/my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "ghcr.io/org/my-skill:latest"). - Return(godigest.Digest(""), fmt.Errorf("auth required")) - - pr := skillsmocks.NewMockPathResolver(ctrl) - store := storemocks.NewMockSkillStore(ctrl) - // No lookup mock — gomock will fail if SearchSkills is called. - return nil, reg, ociStore, nil, store, pr - }, - wantCode: http.StatusBadRequest, - wantErr: "auth required", - }, - { - name: "registry ambiguity error surfaced to caller", - // resolveFromRegistry returns a conflict error when multiple registry - // entries match the same name — the Install method must propagate it. - opts: skills.InstallOptions{Name: "io.github.stacklok/my-skill"}, - setup: func(t *testing.T, ctrl *gomock.Controller) (*regmocks.MockProvider, *ocimocks.MockRegistryClient, *ociskills.Store, *gitmocks.MockResolver, *storemocks.MockSkillStore, *skillsmocks.MockPathResolver) { - t.Helper() - ociStore, err := ociskills.NewStore(tempDir(t)) - require.NoError(t, err) - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), gomock.Any(), "io.github.stacklok/my-skill:latest"). - Return(godigest.Digest(""), fmt.Errorf("no such host")) - - pr := skillsmocks.NewMockPathResolver(ctrl) - - // Return two results with the same namespace/name so that - // resolveFromRegistry treats this as an ambiguous match and - // returns a conflict error rather than nil. - lookup := regmocks.NewMockProvider(ctrl) - lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ - {Namespace: "io.github.stacklok", Name: "my-skill", Packages: []regtypes.SkillPackage{{RegistryType: "git", URL: "https://github.com/a/my-skill"}}}, - {Namespace: "io.github.stacklok", Name: "my-skill", Packages: []regtypes.SkillPackage{{RegistryType: "git", URL: "https://github.com/b/my-skill"}}}, - }, nil) - - store := storemocks.NewMockSkillStore(ctrl) - return lookup, reg, ociStore, nil, store, pr - }, - wantCode: http.StatusConflict, - wantErr: "ambiguous", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - lookup, reg, ociStore, gr, store, pr := tt.setup(t, ctrl) - - var opts []Option - if lookup != nil { - opts = append(opts, WithSkillLookup(lookup)) - } - if reg != nil { - opts = append(opts, WithRegistryClient(reg)) - } - if ociStore != nil { - opts = append(opts, WithOCIStore(ociStore)) - } - if gr != nil { - opts = append(opts, WithGitResolver(gr)) - } - if pr != nil { - opts = append(opts, WithPathResolver(pr)) - } - - svc := New(store, opts...) - result, err := svc.Install(t.Context(), tt.opts) - - if tt.wantCode != 0 { - require.Error(t, err) - assert.Equal(t, tt.wantCode, httperr.Code(err)) - if tt.wantErr != "" { - assert.Contains(t, err.Error(), tt.wantErr) - } - return - } - require.NoError(t, err) - if tt.wantName != "" { - assert.Equal(t, tt.wantName, result.Skill.Metadata.Name) - } - }) - } -} - -func TestUninstallRemovesSkillFromGroups(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - opts skills.UninstallOptions - setupStoreMock func(*storemocks.MockSkillStore) - setupGroupMock func(*groupmocks.MockManager) - wantErr string - }{ - { - name: "uninstall removes skill from all groups", - opts: skills.UninstallOptions{Name: "my-skill"}, - setupStoreMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, ""). - Return(skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Clients: []string{}, - }, nil) - s.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) - }, - setupGroupMock: func(gm *groupmocks.MockManager) { - gm.EXPECT().List(gomock.Any()).Return([]*groups.Group{ - {Name: "mygroup", Skills: []string{"my-skill"}}, - }, nil) - gm.EXPECT().Update(gomock.Any(), &groups.Group{Name: "mygroup", Skills: []string{}}). - Return(nil) - }, - }, - { - name: "uninstall with no group manager succeeds without group cleanup", - opts: skills.UninstallOptions{Name: "my-skill"}, - setupStoreMock: func(s *storemocks.MockSkillStore) { - s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, ""). - Return(skills.InstalledSkill{ - Metadata: skills.SkillMetadata{Name: "my-skill"}, - Clients: []string{}, - }, nil) - s.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) - }, - setupGroupMock: nil, // no group mock needed - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - store := storemocks.NewMockSkillStore(ctrl) - tt.setupStoreMock(store) - - opts := []Option{} - if tt.setupGroupMock != nil { - gm := groupmocks.NewMockManager(ctrl) - tt.setupGroupMock(gm) - opts = append(opts, WithGroupManager(gm)) - } - - svc := New(store, opts...) - - err := svc.Uninstall(t.Context(), tt.opts) - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestValidateOCITagOrReference(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - tag string - wantErr bool - }{ - // Valid bare tags - {name: "simple version", tag: "v1.0.0", wantErr: false}, - {name: "latest", tag: "latest", wantErr: false}, - {name: "numeric", tag: "123", wantErr: false}, - {name: "with dots", tag: "1.2.3", wantErr: false}, - {name: "with hyphens", tag: "my-skill", wantErr: false}, - {name: "with underscores", tag: "my_skill", wantErr: false}, - {name: "mixed alphanumeric", tag: "v1.0.0-rc.1", wantErr: false}, - {name: "uppercase", tag: "MyTag", wantErr: false}, - {name: "single char", tag: "a", wantErr: false}, - {name: "max length 128 chars", tag: strings.Repeat("a", 128), wantErr: false}, - {name: "exceeds max length 129 chars", tag: strings.Repeat("a", 129), wantErr: true}, - - // Valid full OCI references - {name: "ghcr tagged reference", tag: "ghcr.io/stacklok/toolhive-skills/my-skill:v1.0.0", wantErr: false}, - {name: "CI format tag", tag: "ghcr.io/stacklok/toolhive-skills/my-skill:0.0.1-dev.123_abc1234", wantErr: false}, - {name: "docker hub reference", tag: "docker.io/library/nginx:1.25", wantErr: false}, - {name: "localhost with port", tag: "localhost:5000/my-skill:v1", wantErr: false}, - {name: "digest reference", tag: "ghcr.io/org/repo@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", wantErr: false}, - - // Invalid bare tags - {name: "empty string", tag: "", wantErr: true}, - {name: "contains space", tag: "invalid tag", wantErr: true}, - {name: "contains exclamation", tag: "invalid!", wantErr: true}, - {name: "contains hash", tag: "invalid#tag", wantErr: true}, - - // Invalid full references - {name: "space in tag of reference", tag: "ghcr.io/org/repo:invalid tag", wantErr: true}, - {name: "empty tag after colon", tag: "ghcr.io/org/repo:", wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - err := validateOCITagOrReference(tt.tag) - if tt.wantErr { - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid OCI reference or tag") - // Verify it returns a proper HTTP status code. - var coded *httperr.CodedError - require.ErrorAs(t, err, &coded) - assert.Equal(t, http.StatusBadRequest, coded.HTTPCode()) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestBuildGitReferenceFromRegistryURL(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - rawURL string - want string - wantErr string - }{ - { - name: "https URL converts to git scheme", - rawURL: "https://github.com/org/repo", - want: "git://github.com/org/repo", - }, - { - name: "http URL silently promoted to git scheme", - rawURL: "http://github.com/org/repo", - want: "git://github.com/org/repo", - }, - { - name: "git URL passes through unchanged", - rawURL: "git://github.com/org/repo", - want: "git://github.com/org/repo", - }, - { - name: "https URL with nested path", - rawURL: "https://github.com/org/repo@v1.0#skills/foo", - want: "git://github.com/org/repo@v1.0#skills/foo", - }, - { - name: "empty git reference", - rawURL: "git://", - wantErr: "invalid git reference", - }, - { - name: "unsupported ftp scheme", - rawURL: "ftp://github.com/org/repo", - wantErr: "unsupported URL scheme", - }, - { - name: "bare string no scheme", - rawURL: "noscheme/org/repo", - wantErr: "unsupported URL scheme", - }, - { - name: "https URL missing repo path", - rawURL: "https://github.com", - wantErr: "no repository path after host", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got, err := buildGitReferenceFromRegistryURL(tt.rawURL) - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - require.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestQualifiedOCIRef(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - want string - }{ - { - name: "explicit tag unchanged", - input: "ghcr.io/org/my-skill:v1", - want: "ghcr.io/org/my-skill:v1", - }, - { - name: "no tag defaults to latest", - input: "ghcr.io/stacklok/toolhive/skills/toolhive-cli-user", - want: "ghcr.io/stacklok/toolhive/skills/toolhive-cli-user:latest", - }, - { - name: "digest unchanged", - input: "ghcr.io/org/my-skill@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - want: "ghcr.io/org/my-skill@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ref, err := nameref.ParseReference(tt.input) - require.NoError(t, err) - assert.Equal(t, tt.want, qualifiedOCIRef(ref)) - }) - } -} - -func TestListBuilds(t *testing.T) { - t.Parallel() - - t.Run("nil oci store returns 500", func(t *testing.T) { - t.Parallel() - svc := New(&storage.NoopSkillStore{}) - _, err := svc.ListBuilds(t.Context()) - require.Error(t, err) - assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) - }) - - t.Run("empty store returns empty list", func(t *testing.T) { - t.Parallel() - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - - svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) - artifacts, err := svc.ListBuilds(t.Context()) - require.NoError(t, err) - assert.Empty(t, artifacts) - }) - - t.Run("lists tagged artifacts with metadata", func(t *testing.T) { - t.Parallel() - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - - // Build a real artifact via the packager so extractOCIContent works. - d := buildTestArtifact(t, ociStore, "my-skill", "1.2.3") - require.NoError(t, ociStore.Tag(t.Context(), d, "my-skill")) - - svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) - artifacts, err := svc.ListBuilds(t.Context()) - require.NoError(t, err) - require.Len(t, artifacts, 1) - - assert.Equal(t, "my-skill", artifacts[0].Tag) - assert.Contains(t, artifacts[0].Digest, "sha256:") - assert.Equal(t, "my-skill", artifacts[0].Name) - assert.Equal(t, "1.2.3", artifacts[0].Version) - }) - - t.Run("lists multiple tagged artifacts", func(t *testing.T) { - t.Parallel() - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - - d1 := buildTestArtifact(t, ociStore, "skill-a", "1.0.0") - require.NoError(t, ociStore.Tag(t.Context(), d1, "skill-a")) - d2 := buildTestArtifact(t, ociStore, "skill-b", "2.0.0") - require.NoError(t, ociStore.Tag(t.Context(), d2, "skill-b")) - - svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) - artifacts, err := svc.ListBuilds(t.Context()) - require.NoError(t, err) - assert.Len(t, artifacts, 2) - - // Collect names for assertion (order may vary). - names := make(map[string]string) - for _, a := range artifacts { - names[a.Tag] = a.Version - } - assert.Equal(t, "1.0.0", names["skill-a"]) - assert.Equal(t, "2.0.0", names["skill-b"]) - }) - - t.Run("skill artifact with no extractable metadata still appears", func(t *testing.T) { - t.Parallel() - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - - // Store an index with ArtifactType set to the skill type but no child manifests — - // extractOCIContent will fail but the artifact should still appear with empty metadata fields. - skillIndex := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","artifactType":"dev.toolhive.skills.v1","manifests":[]}` - d, putErr := ociStore.PutManifest(t.Context(), []byte(skillIndex)) - require.NoError(t, putErr) - require.NoError(t, ociStore.Tag(t.Context(), d, "bare-skill-tag")) - - svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) - artifacts, err := svc.ListBuilds(t.Context()) - require.NoError(t, err) - require.Len(t, artifacts, 1) - - assert.Equal(t, "bare-skill-tag", artifacts[0].Tag) - assert.Contains(t, artifacts[0].Digest, "sha256:") - assert.Empty(t, artifacts[0].Name) - assert.Empty(t, artifacts[0].Version) - }) - - t.Run("non-skill artifact is excluded", func(t *testing.T) { - t.Parallel() - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - - // Store a valid skill artifact that should be returned. - skillDigest := buildTestArtifact(t, ociStore, "real-skill", "1.0.0") - require.NoError(t, ociStore.Tag(t.Context(), skillDigest, "real-skill")) - - // Store an index whose ArtifactType is not the skill type — simulates a - // remotely-pulled non-skill OCI artifact sharing the same store. - otherIndex := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","artifactType":"application/vnd.docker.distribution.manifest.v2","manifests":[]}` - otherDigest, putErr := ociStore.PutManifest(t.Context(), []byte(otherIndex)) - require.NoError(t, putErr) - require.NoError(t, ociStore.Tag(t.Context(), otherDigest, "non-skill-tag")) - - svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) - artifacts, err := svc.ListBuilds(t.Context()) - require.NoError(t, err) - require.Len(t, artifacts, 1) - assert.Equal(t, "real-skill", artifacts[0].Tag) - }) -} - -func TestGetContent(t *testing.T) { - t.Parallel() - - t.Run("nil oci store returns 500", func(t *testing.T) { - t.Parallel() - svc := New(&storage.NoopSkillStore{}) - _, err := svc.GetContent(t.Context(), skills.ContentOptions{Reference: "my-skill"}) - require.Error(t, err) - assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) - }) - - t.Run("empty reference returns 400", func(t *testing.T) { - t.Parallel() - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) - _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: ""}) - require.Error(t, err) - assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) - }) - - t.Run("local build tag resolves without registry", func(t *testing.T) { - t.Parallel() - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - - d := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") - require.NoError(t, ociStore.Tag(t.Context(), d, "my-skill")) - - svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) - content, err := svc.GetContent(t.Context(), skills.ContentOptions{Reference: "my-skill"}) - require.NoError(t, err) - - assert.Equal(t, "my-skill", content.Name) - assert.Equal(t, "1.0.0", content.Version) - assert.NotEmpty(t, content.Body) - assert.NotEmpty(t, content.Files) - - // SKILL.md must appear in the file list. - var skillMDFound bool - for _, f := range content.Files { - if strings.EqualFold(filepath.Base(f.Path), "SKILL.md") { - skillMDFound = true - assert.Greater(t, f.Size, 0) - } - } - assert.True(t, skillMDFound, "SKILL.md should be listed in Files") - }) - - t.Run("remote OCI reference triggers pull", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - indexDigest := buildTestArtifact(t, ociStore, "my-skill", "2.0.0") - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v2"). - Return(indexDigest, nil) - - svc := New(&storage.NoopSkillStore{}, - WithOCIStore(ociStore), - WithRegistryClient(reg), - ) - content, err := svc.GetContent(t.Context(), skills.ContentOptions{ - Reference: "ghcr.io/org/my-skill:v2", - }) - require.NoError(t, err) - - assert.Equal(t, "my-skill", content.Name) - assert.Equal(t, "2.0.0", content.Version) - assert.NotEmpty(t, content.Body) - }) - - t.Run("unqualified name not in store without registry returns 400", func(t *testing.T) { - t.Parallel() - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - - svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) - _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: "nonexistent"}) - require.Error(t, err) - assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) - }) - - t.Run("nil registry with unresolvable remote ref returns 400", func(t *testing.T) { - t.Parallel() - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - - // "ghcr.io/org/skill:v1" is a valid OCI ref but registry is nil. - svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) - _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: "ghcr.io/org/skill:v1"}) - require.Error(t, err) - assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) - }) - - t.Run("pull failure propagates as 400", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - - reg := ocimocks.NewMockRegistryClient(ctrl) - reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1"). - Return(godigest.Digest(""), fmt.Errorf("registry unreachable")) - - svc := New(&storage.NoopSkillStore{}, - WithOCIStore(ociStore), - WithRegistryClient(reg), - ) - _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: "ghcr.io/org/my-skill:v1"}) - require.Error(t, err) - assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) - assert.Contains(t, err.Error(), "registry unreachable") - }) - - t.Run("git reference resolves via git resolver", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - - gr := gitmocks.NewMockResolver(ctrl) - gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ - SkillConfig: &skills.ParseResult{ - Name: "my-skill", - Description: "a git skill", - Version: "1.0.0", - Body: []byte("# My Skill\nHello from git"), - }, - Files: []gitresolver.FileEntry{ - {Path: "SKILL.md", Content: []byte("# My Skill\nHello from git"), Mode: 0644}, - {Path: "hooks.sh", Content: []byte("#!/bin/sh"), Mode: 0644}, - }, - CommitHash: testCommitHash, - }, nil) - - svc := New(&storage.NoopSkillStore{}, WithGitResolver(gr)) - content, err := svc.GetContent(t.Context(), skills.ContentOptions{ - Reference: "git://github.com/test/my-skill#skills/my-skill", - }) - require.NoError(t, err) - assert.Equal(t, "my-skill", content.Name) - assert.Equal(t, "a git skill", content.Description) - assert.Equal(t, "1.0.0", content.Version) - assert.Contains(t, content.Body, "Hello from git") - assert.Len(t, content.Files, 2) - }) - - t.Run("git resolve failure returns 502", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - - gr := gitmocks.NewMockResolver(ctrl) - gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("clone failed")) - - svc := New(&storage.NoopSkillStore{}, WithGitResolver(gr)) - _, err := svc.GetContent(t.Context(), skills.ContentOptions{ - Reference: "git://github.com/test/my-skill", - }) - require.Error(t, err) - assert.Equal(t, http.StatusBadGateway, httperr.Code(err)) - assert.Contains(t, err.Error(), "resolving git skill") - }) - - t.Run("nil git resolver returns 500", func(t *testing.T) { - t.Parallel() - svc := &service{} - _, err := svc.GetContent(t.Context(), skills.ContentOptions{ - Reference: "git://github.com/test/my-skill", - }) - require.Error(t, err) - assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) - }) - - t.Run("registry name falls back to git resolver", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - - lookup := regmocks.NewMockProvider(ctrl) - lookup.EXPECT().SearchSkills("skill-creator").Return([]regtypes.Skill{ - { - Namespace: "io.github.stacklok", - Name: "skill-creator", - Packages: []regtypes.SkillPackage{ - { - RegistryType: "git", - URL: "https://github.com/stacklok/toolhive-catalog", - Subfolder: "registries/toolhive/skills/skill-creator", - }, - }, - }, - }, nil) - - gr := gitmocks.NewMockResolver(ctrl) - gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, ref *gitresolver.GitReference) (*gitresolver.ResolveResult, error) { - assert.Equal(t, "registries/toolhive/skills/skill-creator", ref.Path) - return &gitresolver.ResolveResult{ - SkillConfig: &skills.ParseResult{ - Name: "skill-creator", - Description: "creates skills", - Body: []byte("# Skill Creator"), - }, - Files: []gitresolver.FileEntry{ - {Path: "SKILL.md", Content: []byte("# Skill Creator"), Mode: 0644}, - }, - CommitHash: testCommitHash, - }, nil - }) - - svc := New(&storage.NoopSkillStore{}, - WithOCIStore(ociStore), - WithSkillLookup(lookup), - WithGitResolver(gr), - ) - content, err := svc.GetContent(t.Context(), skills.ContentOptions{ - Reference: "io.github.stacklok/skill-creator", - }) - require.NoError(t, err) - assert.Equal(t, "skill-creator", content.Name) - assert.Contains(t, content.Body, "Skill Creator") - }) - - t.Run("qualified namespace/name filters registry matches", func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - - lookup := regmocks.NewMockProvider(ctrl) - lookup.EXPECT().SearchSkills("my-skill").Return([]regtypes.Skill{ - {Namespace: "io.github.alice", Name: "my-skill", - Packages: []regtypes.SkillPackage{{RegistryType: "git", URL: "https://github.com/alice/repo"}}}, - {Namespace: "io.github.bob", Name: "my-skill", - Packages: []regtypes.SkillPackage{{RegistryType: "git", URL: "https://github.com/bob/repo"}}}, - }, nil) - - gr := gitmocks.NewMockResolver(ctrl) - gr.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&gitresolver.ResolveResult{ - SkillConfig: &skills.ParseResult{Name: "my-skill", Body: []byte("# Bob's skill")}, - Files: []gitresolver.FileEntry{{Path: "SKILL.md", Content: []byte("# Bob"), Mode: 0644}}, - CommitHash: testCommitHash, - }, nil) - - svc := New(&storage.NoopSkillStore{}, - WithOCIStore(ociStore), - WithSkillLookup(lookup), - WithGitResolver(gr), - ) - content, err := svc.GetContent(t.Context(), skills.ContentOptions{ - Reference: "io.github.bob/my-skill", - }) - require.NoError(t, err) - assert.Equal(t, "my-skill", content.Name) - }) -} - -func TestDeleteBuild(t *testing.T) { - t.Parallel() - - t.Run("nil oci store returns 500", func(t *testing.T) { - t.Parallel() - svc := New(&storage.NoopSkillStore{}) - err := svc.DeleteBuild(t.Context(), "my-skill") - require.Error(t, err) - assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) - }) - - t.Run("removes tag and blobs", func(t *testing.T) { - t.Parallel() - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - - d := buildTestArtifact(t, ociStore, "my-skill", "1.0.0") - require.NoError(t, ociStore.Tag(t.Context(), d, "my-skill")) - - svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) - require.NoError(t, svc.DeleteBuild(t.Context(), "my-skill")) - - // Tag should be gone — ListBuilds should return empty. - builds, listErr := svc.ListBuilds(t.Context()) - require.NoError(t, listErr) - assert.Empty(t, builds) - }) - - t.Run("tag does not exist returns 404", func(t *testing.T) { - t.Parallel() - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - - svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) - err = svc.DeleteBuild(t.Context(), "nonexistent") - require.Error(t, err) - assert.Equal(t, http.StatusNotFound, httperr.Code(err)) - }) - - t.Run("blobs retained when another tag shares the same digest", func(t *testing.T) { - t.Parallel() - ociStore, err := ociskills.NewStore(t.TempDir()) - require.NoError(t, err) - - d := buildTestArtifact(t, ociStore, "shared-skill", "1.0.0") - require.NoError(t, ociStore.Tag(t.Context(), d, "tag-a")) - require.NoError(t, ociStore.Tag(t.Context(), d, "tag-b")) - - svc := New(&storage.NoopSkillStore{}, WithOCIStore(ociStore)) - require.NoError(t, svc.DeleteBuild(t.Context(), "tag-a")) - - // tag-b still exists and the shared artifact is accessible. - builds, listErr := svc.ListBuilds(t.Context()) - require.NoError(t, listErr) - require.Len(t, builds, 1) - assert.Equal(t, "tag-b", builds[0].Tag) - }) -} - -func TestSplitQualifiedName(t *testing.T) { - t.Parallel() - - tests := []struct { - input string - wantNS string - wantName string - }{ - {"skill-creator", "", "skill-creator"}, - {"io.github.stacklok/skill-creator", "io.github.stacklok", "skill-creator"}, - {"deep/nested/name", "deep/nested", "name"}, - {"", "", ""}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - t.Parallel() - ns, name := splitQualifiedName(tt.input) - assert.Equal(t, tt.wantNS, ns) - assert.Equal(t, tt.wantName, name) - }) - } -} - -func TestResolveRegistryPackagesSubfolder(t *testing.T) { - t.Parallel() - - result, err := resolveRegistryPackages("my-skill", []regtypes.SkillPackage{ - { - RegistryType: "git", - URL: "https://github.com/org/repo", - Subfolder: "skills/my-skill", - }, - }) - require.NoError(t, err) - require.NotNil(t, result) - assert.Contains(t, result.GitURL, "#skills/my-skill") -} diff --git a/pkg/skills/skillsvc/testhelpers_test.go b/pkg/skills/skillsvc/testhelpers_test.go new file mode 100644 index 0000000000..d7c4a14579 --- /dev/null +++ b/pkg/skills/skillsvc/testhelpers_test.go @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + godigest "github.com/opencontainers/go-digest" + specs "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" + + ociskills "github.com/stacklok/toolhive-core/oci/skills" +) + +const testCommitHash = "abcdef1234567890abcdef1234567890abcdef12" + +func makeLayerData(t *testing.T) []byte { + t.Helper() + files := []ociskills.FileEntry{ + {Path: "SKILL.md", Content: []byte("---\nname: my-skill\ndescription: test\n---\n# Skill"), Mode: 0644}, + } + data, err := ociskills.CompressTar(files, ociskills.DefaultTarOptions(), ociskills.DefaultGzipOptions()) + require.NoError(t, err) + return data +} + +func tempDir(t *testing.T) string { + t.Helper() + realTmpDir, _ := filepath.EvalSymlinks(t.TempDir()) + return realTmpDir +} + +func makeProjectRoot(t *testing.T) string { + t.Helper() + dir := t.TempDir() + resolved, err := filepath.EvalSymlinks(dir) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Join(resolved, ".git"), 0o755)) + return resolved +} + +// buildTestArtifact creates a real OCI skill artifact in the store and returns +// the index digest. This uses the real Packager so the store has a proper index, +// manifest, config blob, and layer blob — identical to what a real pull would +// produce. +func buildTestArtifact(t *testing.T, store *ociskills.Store, skillName, version string) godigest.Digest { + t.Helper() + + // Create a temporary skill directory. + skillDir := filepath.Join(t.TempDir(), skillName) + require.NoError(t, os.MkdirAll(skillDir, 0o750)) + fm := fmt.Sprintf("---\nname: %s\ndescription: test skill\nversion: %s\n---\n# %s\nA test skill.\n", + skillName, version, skillName) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(fm), 0o600)) + + packager := ociskills.NewPackager(store) + result, err := packager.Package(t.Context(), skillDir, ociskills.DefaultPackageOptions()) + require.NoError(t, err) + return result.IndexDigest +} + +func buildManifestWithLayerSize(t *testing.T, store *ociskills.Store, skillName string, layerSize int64) godigest.Digest { + t.Helper() + + imgConfig := ocispec.Image{ + Config: ocispec.ImageConfig{ + Labels: map[string]string{ + ociskills.LabelSkillName: skillName, + ociskills.LabelSkillDescription: "test", + ociskills.LabelSkillVersion: "1.0.0", + }, + }, + } + configBytes, err := json.Marshal(imgConfig) + require.NoError(t, err) + configDigest, err := store.PutBlob(t.Context(), configBytes) + require.NoError(t, err) + + layerBytes := []byte("layer") + layerDigest, err := store.PutBlob(t.Context(), layerBytes) + require.NoError(t, err) + + manifest := ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageManifest, + ArtifactType: ociskills.ArtifactTypeSkill, + Config: ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageConfig, + Digest: configDigest, + Size: int64(len(configBytes)), + }, + Layers: []ocispec.Descriptor{ + { + MediaType: ocispec.MediaTypeImageLayerGzip, + Digest: layerDigest, + Size: layerSize, + }, + }, + } + manifestBytes, err := json.Marshal(manifest) + require.NoError(t, err) + manifestDigest, err := store.PutManifest(t.Context(), manifestBytes) + require.NoError(t, err) + + return manifestDigest +} + +func putTestManifest(t *testing.T, store *ociskills.Store) godigest.Digest { + t.Helper() + d, err := store.PutManifest(t.Context(), []byte(`{"schemaVersion":2}`)) + require.NoError(t, err) + return d +} diff --git a/pkg/skills/skillsvc/uninstall.go b/pkg/skills/skillsvc/uninstall.go new file mode 100644 index 0000000000..4c74d8ec98 --- /dev/null +++ b/pkg/skills/skillsvc/uninstall.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/skills" +) + +// Uninstall removes an installed skill and cleans up files for all clients. +func (s *service) Uninstall(ctx context.Context, opts skills.UninstallOptions) error { + if err := skills.ValidateSkillName(opts.Name); err != nil { + return httperr.WithCode(err, http.StatusBadRequest) + } + + scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) + if err != nil { + return err + } + scope = defaultScope(scope) + opts.ProjectRoot = projectRoot + + unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) + defer unlock() + + // Look up the existing record to find which clients have files. + existing, err := s.store.Get(ctx, opts.Name, scope, opts.ProjectRoot) + if err != nil { + return err + } + + // Determine the boundary directory for empty-parent cleanup. + stopDir := opts.ProjectRoot + if scope == skills.ScopeUser { + if homeDir, err := os.UserHomeDir(); err == nil { + stopDir = homeDir + } + } + + // Remove files for each client — best-effort: collect errors but don't + // abort on the first failure so we clean up as much as possible. + var cleanupErrs []error + if s.pathResolver != nil { + for _, clientType := range existing.Clients { + skillPath, pathErr := s.pathResolver.GetSkillPath(clientType, opts.Name, scope, opts.ProjectRoot) + if pathErr != nil { + cleanupErrs = append(cleanupErrs, fmt.Errorf("resolving path for client %q: %w", clientType, pathErr)) + continue + } + if rmErr := s.installer.Remove(skillPath); rmErr != nil { + cleanupErrs = append(cleanupErrs, fmt.Errorf("removing files for client %q: %w", clientType, rmErr)) + continue + } + if stopDir != "" { + skills.RemoveEmptyParents(filepath.Dir(skillPath), stopDir) + } + } + } + + if err := s.store.Delete(ctx, opts.Name, scope, opts.ProjectRoot); err != nil { + return err + } + + // Remove the skill from all groups — best-effort, same pattern as file cleanup. + if s.groupManager != nil { + if groupErr := groups.RemoveSkillFromAllGroups(ctx, s.groupManager, opts.Name); groupErr != nil { + cleanupErrs = append(cleanupErrs, fmt.Errorf("removing skill from groups: %w", groupErr)) + } + } + + return errors.Join(cleanupErrs...) +} diff --git a/pkg/skills/skillsvc/uninstall_test.go b/pkg/skills/skillsvc/uninstall_test.go new file mode 100644 index 0000000000..1daa495077 --- /dev/null +++ b/pkg/skills/skillsvc/uninstall_test.go @@ -0,0 +1,359 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/groups" + groupmocks "github.com/stacklok/toolhive/pkg/groups/mocks" + "github.com/stacklok/toolhive/pkg/skills" + skillsmocks "github.com/stacklok/toolhive/pkg/skills/mocks" + "github.com/stacklok/toolhive/pkg/storage" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestUninstall(t *testing.T) { + t.Parallel() + + projectRoot := makeProjectRoot(t) + + t.Run("success with file cleanup", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + + // Create a skill directory to be cleaned up + skillDir := filepath.Join(t.TempDir(), "my-skill") + require.NoError(t, os.MkdirAll(skillDir, 0750)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("test"), 0600)) + + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Scope: skills.ScopeUser, + Clients: []string{"claude-code"}, + } + + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(skillDir, nil) + store.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) + + svc := New(store, WithPathResolver(pr)) + err := svc.Uninstall(t.Context(), skills.UninstallOptions{Name: "my-skill"}) + require.NoError(t, err) + + // Verify directory was removed + _, statErr := os.Stat(skillDir) + assert.True(t, os.IsNotExist(statErr)) + }) + + t.Run("success without path resolver", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Scope: skills.ScopeUser, + } + + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + store.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) + + svc := New(store) + err := svc.Uninstall(t.Context(), skills.UninstallOptions{Name: "my-skill"}) + require.NoError(t, err) + }) + + t.Run("respects explicit scope", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Scope: skills.ScopeProject, + } + + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeProject, projectRoot).Return(existing, nil) + store.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeProject, projectRoot).Return(nil) + + svc := New(store) + err := svc.Uninstall(t.Context(), skills.UninstallOptions{ + Name: "my-skill", + Scope: skills.ScopeProject, + ProjectRoot: projectRoot, + }) + require.NoError(t, err) + }) + + t.Run("project scope requires project root", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + + svc := New(store) + err := svc.Uninstall(t.Context(), skills.UninstallOptions{ + Name: "my-skill", + Scope: skills.ScopeProject, + }) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("returns 404 when not found", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound) + + svc := New(store) + err := svc.Uninstall(t.Context(), skills.UninstallOptions{Name: "my-skill"}) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, httperr.Code(err)) + }) + + t.Run("rejects invalid name", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + + svc := New(store) + err := svc.Uninstall(t.Context(), skills.UninstallOptions{Name: "X"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("cleans up all clients", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + + dir1 := filepath.Join(t.TempDir(), "client1", "my-skill") + dir2 := filepath.Join(t.TempDir(), "client2", "my-skill") + require.NoError(t, os.MkdirAll(dir1, 0750)) + require.NoError(t, os.MkdirAll(dir2, 0750)) + + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Scope: skills.ScopeUser, + Clients: []string{"client-a", "client-b"}, + } + + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + pr.EXPECT().GetSkillPath("client-a", "my-skill", skills.ScopeUser, "").Return(dir1, nil) + pr.EXPECT().GetSkillPath("client-b", "my-skill", skills.ScopeUser, "").Return(dir2, nil) + store.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) + + svc := New(store, WithPathResolver(pr)) + err := svc.Uninstall(t.Context(), skills.UninstallOptions{Name: "my-skill"}) + require.NoError(t, err) + + _, statErr1 := os.Stat(dir1) + assert.True(t, os.IsNotExist(statErr1)) + _, statErr2 := os.Stat(dir2) + assert.True(t, os.IsNotExist(statErr2)) + }) + + t.Run("best-effort cleanup continues on remove error", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + inst := skillsmocks.NewMockInstaller(ctrl) + + existing := skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Scope: skills.ScopeUser, + Clients: []string{"client-a", "client-b"}, + } + + store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(existing, nil) + pr.EXPECT().GetSkillPath("client-a", "my-skill", skills.ScopeUser, "").Return("/some/dir-a", nil) + pr.EXPECT().GetSkillPath("client-b", "my-skill", skills.ScopeUser, "").Return("/some/dir-b", nil) + // First remove fails, but second should still be attempted + inst.EXPECT().Remove("/some/dir-a").Return(fmt.Errorf("permission denied")) + inst.EXPECT().Remove("/some/dir-b").Return(nil) + store.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) + + svc := New(store, WithPathResolver(pr), WithInstaller(inst)) + err := svc.Uninstall(t.Context(), skills.UninstallOptions{Name: "my-skill"}) + // Store deletion succeeds, but cleanup errors are returned + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") + }) +} + +func TestConcurrentInstallAndUninstall(t *testing.T) { + t.Parallel() + + layerData := makeLayerData(t) + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + pr := skillsmocks.NewMockPathResolver(ctrl) + + // Per-skill atomic counters verify that at most one goroutine is inside + // a critical section for a given skill at any time. + var inFlight sync.Map // skill name -> *int32 + + assertExclusive := func(name string) { + counter, _ := inFlight.LoadOrStore(name, new(int32)) + cnt := counter.(*int32) + cur := atomic.AddInt32(cnt, 1) + assert.Equal(t, int32(1), cur, "concurrent access detected for %s", name) + // Sleep briefly to widen the window for detecting overlap. + time.Sleep(time.Millisecond) + atomic.AddInt32(cnt, -1) + } + + // PathResolver returns unique temp directories per skill so extractions + // don't collide. Use a temp base that outlives individual subtests. + baseDir := tempDir(t) + pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"}).AnyTimes() + pr.EXPECT().GetSkillPath(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_, skillName string, _ skills.Scope, _ string) (string, error) { + return filepath.Join(baseDir, skillName), nil + }).AnyTimes() + + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assertExclusive(sk.Metadata.Name) + return nil + }).AnyTimes() + store.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, name string, _ skills.Scope, _ string) (skills.InstalledSkill, error) { + assertExclusive(name) + return skills.InstalledSkill{}, storage.ErrNotFound + }).AnyTimes() + store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, sk skills.InstalledSkill) error { + assertExclusive(sk.Metadata.Name) + return nil + }).AnyTimes() + store.EXPECT().Delete(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, name string, _ skills.Scope, _ string) error { + assertExclusive(name) + return nil + }).AnyTimes() + + svc := New(store, WithPathResolver(pr)) + + // Run concurrent install/uninstall pairs across multiple skill names. + // Different skills proceed independently; the same skill name is + // serialized by the per-skill lock. The atomic counters above detect + // any overlap within a skill's critical section. + skillNames := []string{"skill-a", "skill-b", "skill-c"} + const goroutinesPerSkill = 5 + + var wg sync.WaitGroup + wg.Add(len(skillNames) * goroutinesPerSkill) + + for _, name := range skillNames { + for range goroutinesPerSkill { + go func() { + defer wg.Done() + // Provide LayerData so Install exercises installWithExtraction. + _, _ = svc.Install(t.Context(), skills.InstallOptions{ + Name: name, + LayerData: layerData, + Digest: "sha256:concurrent-test", + }) + // Uninstall may fail (not found) — that's fine for concurrency testing. + _ = svc.Uninstall(t.Context(), skills.UninstallOptions{Name: name}) + }() + } + } + + wg.Wait() +} + +// ---------- group-integration tests ---------- + +func TestUninstallRemovesSkillFromGroups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts skills.UninstallOptions + setupStoreMock func(*storemocks.MockSkillStore) + setupGroupMock func(*groupmocks.MockManager) + wantErr string + }{ + { + name: "uninstall removes skill from all groups", + opts: skills.UninstallOptions{Name: "my-skill"}, + setupStoreMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, ""). + Return(skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Clients: []string{}, + }, nil) + s.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) + }, + setupGroupMock: func(gm *groupmocks.MockManager) { + gm.EXPECT().List(gomock.Any()).Return([]*groups.Group{ + {Name: "mygroup", Skills: []string{"my-skill"}}, + }, nil) + gm.EXPECT().Update(gomock.Any(), &groups.Group{Name: "mygroup", Skills: []string{}}). + Return(nil) + }, + }, + { + name: "uninstall with no group manager succeeds without group cleanup", + opts: skills.UninstallOptions{Name: "my-skill"}, + setupStoreMock: func(s *storemocks.MockSkillStore) { + s.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, ""). + Return(skills.InstalledSkill{ + Metadata: skills.SkillMetadata{Name: "my-skill"}, + Clients: []string{}, + }, nil) + s.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(nil) + }, + setupGroupMock: nil, // no group mock needed + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + tt.setupStoreMock(store) + + opts := []Option{} + if tt.setupGroupMock != nil { + gm := groupmocks.NewMockManager(ctrl) + tt.setupGroupMock(gm) + opts = append(opts, WithGroupManager(gm)) + } + + svc := New(store, opts...) + + err := svc.Uninstall(t.Context(), tt.opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} From b2f6717888668b3e4ec00ac38ea4254149be63b4 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio <ozz@stacklok.com> Date: Tue, 21 Apr 2026 12:38:46 +0300 Subject: [PATCH 097/117] Finish defaultTEIImage rename in embedding_manager_test.go (#4957) Commit c334edf5 ("vmcp: stop the TEI manager on Start failure and apply image default in Serve") renamed the unexported defaultTEIImage constant to the exported DefaultEmbeddingImage so Serve() can default the TEI image alongside DefaultEmbeddingModel. The production files were updated but embedding_manager_test.go was missed, leaving three references to the now-undefined identifier. Golangci-lint catches it as a typecheck error, which fails Lint Go Code and Test Go Code on every PR opened against main. Update the three remaining references so the package compiles and CI goes green. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- pkg/vmcp/cli/embedding_manager_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/vmcp/cli/embedding_manager_test.go b/pkg/vmcp/cli/embedding_manager_test.go index 4e1dab6a7e..2cc1e7a629 100644 --- a/pkg/vmcp/cli/embedding_manager_test.go +++ b/pkg/vmcp/cli/embedding_manager_test.go @@ -108,7 +108,7 @@ func TestNewEmbeddingServiceManager_DefaultImage(t *testing.T) { Model: "BAAI/bge-small-en-v1.5", }) require.NoError(t, err) - assert.Equal(t, defaultTEIImage, mgr.cfg.Image) + assert.Equal(t, DefaultEmbeddingImage, mgr.cfg.Image) assert.Equal(t, containerNameForModel("BAAI/bge-small-en-v1.5"), mgr.containerName) } @@ -198,7 +198,7 @@ func TestStart_DeployNewContainer(t *testing.T) { mockRT.EXPECT().IsWorkloadRunning(gomock.Any(), mgr.containerName).Return(false, nil) mockRT.EXPECT().DeployWorkload( gomock.Any(), - defaultTEIImage, + DefaultEmbeddingImage, mgr.containerName, []string{"--model-id", "BAAI/bge-small-en-v1.5"}, gomock.Nil(), @@ -247,7 +247,7 @@ func TestStart_DeployNewContainer_Kubernetes(t *testing.T) { mockRT.EXPECT().IsWorkloadRunning(gomock.Any(), mgr.containerName).Return(false, nil) mockRT.EXPECT().DeployWorkload( gomock.Any(), - defaultTEIImage, + DefaultEmbeddingImage, mgr.containerName, []string{"--model-id", "BAAI/bge-small-en-v1.5"}, gomock.Nil(), // no env vars From b437352a2a1136d3bb423e4788294104684dea91 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio <ozz@stacklok.com> Date: Tue, 21 Apr 2026 13:02:41 +0300 Subject: [PATCH 098/117] Upgrade default go builder image to golang:1.26-alpine (#4955) Bump the default go transport builder image from golang:1.25-alpine to golang:1.26-alpine so new go-scheme workloads build with the current stable toolchain. Users can still pin an older release via --runtime-image when a project needs it. Also correct two pre-existing doc references that claimed the Go runtime stage uses alpine:3.22 -- go.tmpl already pins alpine:3.23, so the docs were drifted. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- docs/arch/05-runconfig-and-permissions.md | 4 ++-- docs/runtime-version-customization.md | 6 +++--- docs/server/docs.go | 2 +- docs/server/swagger.json | 2 +- docs/server/swagger.yaml | 2 +- pkg/container/templates/runtime_config.go | 4 ++-- pkg/container/templates/runtime_config_test.go | 8 ++++---- pkg/container/templates/templates_test.go | 6 +++--- pkg/runner/protocol_test.go | 2 +- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/arch/05-runconfig-and-permissions.md b/docs/arch/05-runconfig-and-permissions.md index 41a59802fd..d5ab56e621 100644 --- a/docs/arch/05-runconfig-and-permissions.md +++ b/docs/arch/05-runconfig-and-permissions.md @@ -90,7 +90,7 @@ The complete `RunConfig` struct is defined in `pkg/runner/config.go`. **Fields:** - `builder_image`: Override the default base image for the builder stage - - Go: Default `golang:1.25-alpine` + - Go: Default `golang:1.26-alpine` - Node: Default `node:22-alpine` - Python: Default `python:3.13-slim` - `additional_packages`: Extra packages to install during the build and runtime stages (e.g., build tools, libraries) @@ -112,7 +112,7 @@ thv run uvx://mcp-server \ 2. User config file (`~/.toolhive/config.yaml` `runtimeConfigs` map) 3. Built-in defaults -**Note**: For Go workloads, only the builder image is configurable. The runtime stage always uses `alpine:3.22` for simplicity and security. +**Note**: For Go workloads, only the builder image is configurable. The runtime stage always uses `alpine:3.23` for simplicity and security. **Implementation**: `pkg/runner/config.go-198`, `pkg/container/templates/runtime_config.go` diff --git a/docs/runtime-version-customization.md b/docs/runtime-version-customization.md index 28cd8a74da..046359e034 100644 --- a/docs/runtime-version-customization.md +++ b/docs/runtime-version-customization.md @@ -6,7 +6,7 @@ This guide explains how to customize the base images and packages used when runn When you use protocol schemes like `thv run go://github.com/example/server`, ToolHive automatically generates a container image. By default, it uses: -- **Go**: `golang:1.25-alpine` (builder), `alpine:3.22` (runtime) +- **Go**: `golang:1.26-alpine` (builder), `alpine:3.23` (runtime) - **Node**: `node:22-alpine` (builder and runtime) - **Python**: `python:3.13-slim` (builder and runtime) @@ -28,7 +28,7 @@ Override the default base image for the builder stage. **Examples:** ```bash -# Use Go 1.23 instead of default 1.25 +# Use Go 1.23 instead of default 1.26 thv run go://github.com/example/server --runtime-image golang:1.23-alpine # Use Node 20 LTS instead of default 22 @@ -100,7 +100,7 @@ Runtime configurations are resolved in this order (highest priority first): ### Go Runtime Image -For Go workloads, **only the builder image is customizable**. The runtime stage always uses `alpine:3.22` because: +For Go workloads, **only the builder image is customizable**. The runtime stage always uses `alpine:3.23` because: - Go produces static binaries that don't require the Go toolchain at runtime - A minimal Alpine runtime keeps images small and secure diff --git a/docs/server/docs.go b/docs/server/docs.go index 866c0ab5b3..37ea895378 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -979,7 +979,7 @@ const docTemplate = `{ "uniqueItems": false }, "builder_image": { - "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.25-alpine\", \"node:22-alpine\", \"python:3.13-slim\"", + "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:22-alpine\", \"python:3.13-slim\"", "type": "string" } }, diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 4ddb45ea40..702475f709 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -972,7 +972,7 @@ "uniqueItems": false }, "builder_image": { - "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.25-alpine\", \"node:22-alpine\", \"python:3.13-slim\"", + "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:22-alpine\", \"python:3.13-slim\"", "type": "string" } }, diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 80942758f6..6276b31977 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -957,7 +957,7 @@ components: description: |- BuilderImage is the full image reference for the builder stage. An empty string signals "use the default for this transport type" during config merging. - Examples: "golang:1.25-alpine", "node:22-alpine", "python:3.13-slim" + Examples: "golang:1.26-alpine", "node:22-alpine", "python:3.13-slim" type: string type: object github_com_stacklok_toolhive_pkg_core.Workload: diff --git a/pkg/container/templates/runtime_config.go b/pkg/container/templates/runtime_config.go index a61477ecce..356018afc1 100644 --- a/pkg/container/templates/runtime_config.go +++ b/pkg/container/templates/runtime_config.go @@ -24,7 +24,7 @@ var packageNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._+\-]*$`) type RuntimeConfig struct { // BuilderImage is the full image reference for the builder stage. // An empty string signals "use the default for this transport type" during config merging. - // Examples: "golang:1.25-alpine", "node:22-alpine", "python:3.13-slim" + // Examples: "golang:1.26-alpine", "node:22-alpine", "python:3.13-slim" BuilderImage string `json:"builder_image" yaml:"builder_image"` // AdditionalPackages lists extra packages to install in the builder and @@ -75,7 +75,7 @@ func (rc *RuntimeConfig) Validate() error { // RuntimeDefaults provides default configurations for each runtime type var RuntimeDefaults = map[TransportType]RuntimeConfig{ TransportTypeGO: { - BuilderImage: "golang:1.25-alpine", + BuilderImage: "golang:1.26-alpine", AdditionalPackages: []string{"ca-certificates", "git"}, }, TransportTypeNPX: { diff --git a/pkg/container/templates/runtime_config_test.go b/pkg/container/templates/runtime_config_test.go index 590ed3e8e4..14f89d9cc1 100644 --- a/pkg/container/templates/runtime_config_test.go +++ b/pkg/container/templates/runtime_config_test.go @@ -23,7 +23,7 @@ func TestGetDefaultRuntimeConfig(t *testing.T) { { name: "Go default config", transportType: TransportTypeGO, - wantImage: "golang:1.25-alpine", + wantImage: "golang:1.26-alpine", wantPackages: []string{"ca-certificates", "git"}, }, { @@ -136,7 +136,7 @@ func TestGetDockerfileTemplateUsesDefaultWhenNil(t *testing.T) { } // Should use default Go version - if !strings.Contains(result, "FROM golang:1.25-alpine AS builder") { + if !strings.Contains(result, "FROM golang:1.26-alpine AS builder") { t.Error("Dockerfile does not contain default Go version") } } @@ -162,7 +162,7 @@ func TestRuntimeConfigValidate_ValidPackageNames(t *testing.T) { t.Parallel() rc := &RuntimeConfig{ - BuilderImage: "golang:1.25-alpine", + BuilderImage: "golang:1.26-alpine", AdditionalPackages: []string{pkg}, } assert.NoError(t, rc.Validate()) @@ -198,7 +198,7 @@ func TestRuntimeConfigValidate_InvalidPackageNames(t *testing.T) { t.Parallel() rc := &RuntimeConfig{ - BuilderImage: "golang:1.25-alpine", + BuilderImage: "golang:1.26-alpine", AdditionalPackages: []string{tt.pkg}, } err := rc.Validate() diff --git a/pkg/container/templates/templates_test.go b/pkg/container/templates/templates_test.go index 0e72fe8cd8..ac8d7e8116 100644 --- a/pkg/container/templates/templates_test.go +++ b/pkg/container/templates/templates_test.go @@ -423,7 +423,7 @@ func TestRuntimeStageInstallsAdditionalPackages(t *testing.T) { name: "GO runtime stage installs extra packages", transportType: TransportTypeGO, runtimeConfig: &RuntimeConfig{ - BuilderImage: "golang:1.25-alpine", + BuilderImage: "golang:1.26-alpine", AdditionalPackages: []string{"ca-certificates", "git", "curl"}, }, wantInRuntime: "curl", @@ -479,7 +479,7 @@ func TestEmptyAdditionalPackagesDoesNotBreakBuild(t *testing.T) { name: "GO with empty packages", transportType: TransportTypeGO, runtimeConfig: &RuntimeConfig{ - BuilderImage: "golang:1.25-alpine", + BuilderImage: "golang:1.26-alpine", AdditionalPackages: []string{}, }, }, @@ -503,7 +503,7 @@ func TestEmptyAdditionalPackagesDoesNotBreakBuild(t *testing.T) { name: "GO with nil packages", transportType: TransportTypeGO, runtimeConfig: &RuntimeConfig{ - BuilderImage: "golang:1.25-alpine", + BuilderImage: "golang:1.26-alpine", AdditionalPackages: nil, }, }, diff --git a/pkg/runner/protocol_test.go b/pkg/runner/protocol_test.go index e9493c02d2..d4edf1bd3b 100644 --- a/pkg/runner/protocol_test.go +++ b/pkg/runner/protocol_test.go @@ -373,7 +373,7 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: []string{"make"}, }, - wantImage: "golang:1.25-alpine", + wantImage: "golang:1.26-alpine", wantPackages: []string{"ca-certificates", "git", "make"}, }, { From ccf439d3b161281719fcd1062ba9396b823258c0 Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:03:54 +0200 Subject: [PATCH 099/117] Return 502 Bad Gateway when OCI skill pull fails (#4956) * Return 502 Bad Gateway when OCI content pull fails getContentFromOCI previously mapped registry.Pull failures to 400 Bad Request, but auth errors, unreachable registries, missing manifests, and network timeouts are upstream failures the caller cannot fix by changing their input. Align with getContentFromGit, which already returns 502 Bad Gateway for git resolve failures, so clients see consistent semantics across both backends. * Return 502 Bad Gateway when OCI install pull fails installFromOCI previously mapped registry.Pull failures to 400 Bad Request, which incorrectly blamed the caller for upstream registry problems (auth, network, manifest unknown). Return 502 Bad Gateway to match the behaviour just applied to GetContent and the existing git install path. --- pkg/skills/skillsvc/content.go | 2 +- pkg/skills/skillsvc/content_test.go | 4 ++-- pkg/skills/skillsvc/install_oci.go | 2 +- pkg/skills/skillsvc/install_oci_test.go | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/skills/skillsvc/content.go b/pkg/skills/skillsvc/content.go index 06f0c64e28..507cf6a13d 100644 --- a/pkg/skills/skillsvc/content.go +++ b/pkg/skills/skillsvc/content.go @@ -170,7 +170,7 @@ func (s *service) getContentFromOCI(ctx context.Context, ref string) (*skills.Sk if pullErr != nil { return nil, httperr.WithCode( fmt.Errorf("pulling OCI artifact %q: %w", qualifiedRef, pullErr), - http.StatusBadRequest, + http.StatusBadGateway, ) } } diff --git a/pkg/skills/skillsvc/content_test.go b/pkg/skills/skillsvc/content_test.go index c68406424e..2ee52b512a 100644 --- a/pkg/skills/skillsvc/content_test.go +++ b/pkg/skills/skillsvc/content_test.go @@ -125,7 +125,7 @@ func TestGetContent(t *testing.T) { assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) }) - t.Run("pull failure propagates as 400", func(t *testing.T) { + t.Run("pull failure propagates as 502", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) @@ -142,7 +142,7 @@ func TestGetContent(t *testing.T) { ) _, err = svc.GetContent(t.Context(), skills.ContentOptions{Reference: "ghcr.io/org/my-skill:v1"}) require.Error(t, err) - assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + assert.Equal(t, http.StatusBadGateway, httperr.Code(err)) assert.Contains(t, err.Error(), "registry unreachable") }) diff --git a/pkg/skills/skillsvc/install_oci.go b/pkg/skills/skillsvc/install_oci.go index e081b04338..bdd7bad17f 100644 --- a/pkg/skills/skillsvc/install_oci.go +++ b/pkg/skills/skillsvc/install_oci.go @@ -56,7 +56,7 @@ func (s *service) installFromOCI( if err != nil { return nil, httperr.WithCode( fmt.Errorf("pulling OCI artifact %q: %w", ociRef, err), - http.StatusBadRequest, + http.StatusBadGateway, ) } diff --git a/pkg/skills/skillsvc/install_oci_test.go b/pkg/skills/skillsvc/install_oci_test.go index 3cefda2b9a..9a51da7ac5 100644 --- a/pkg/skills/skillsvc/install_oci_test.go +++ b/pkg/skills/skillsvc/install_oci_test.go @@ -579,7 +579,7 @@ func TestInstallQualifiedNameOCIFallback(t *testing.T) { // No lookup mock — gomock will fail the test if SearchSkills is called. return nil, reg, ociStore, nil, store, pr }, - wantCode: http.StatusBadRequest, + wantCode: http.StatusBadGateway, wantErr: "auth required", }, { @@ -604,7 +604,7 @@ func TestInstallQualifiedNameOCIFallback(t *testing.T) { store := storemocks.NewMockSkillStore(ctrl) return lookup, reg, ociStore, nil, store, pr }, - wantCode: http.StatusBadRequest, + wantCode: http.StatusBadGateway, wantErr: "no such host", }, { @@ -625,7 +625,7 @@ func TestInstallQualifiedNameOCIFallback(t *testing.T) { // No lookup mock — gomock will fail the test if SearchSkills is called. return nil, reg, ociStore, nil, store, pr }, - wantCode: http.StatusBadRequest, + wantCode: http.StatusBadGateway, wantErr: "manifest unknown", }, { @@ -645,7 +645,7 @@ func TestInstallQualifiedNameOCIFallback(t *testing.T) { // No lookup mock — gomock will fail if SearchSkills is called. return nil, reg, ociStore, nil, store, pr }, - wantCode: http.StatusBadRequest, + wantCode: http.StatusBadGateway, wantErr: "auth required", }, { From f4724762bc85e88cc378e54e207fa4ec95410c2f Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio <ozz@stacklok.com> Date: Tue, 21 Apr 2026 13:22:56 +0300 Subject: [PATCH 100/117] Upgrade default uvx builder image to python:3.14-slim (#4953) Bump the default uvx transport builder image from python:3.13-slim to python:3.14-slim so new uvx-scheme workloads run on a supported Python release with the latest security updates. Users can still pin an older release via --runtime-image when a package needs it. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- docs/arch/05-runconfig-and-permissions.md | 2 +- docs/runtime-version-customization.md | 2 +- docs/server/docs.go | 2 +- docs/server/swagger.json | 2 +- docs/server/swagger.yaml | 2 +- pkg/container/templates/runtime_config.go | 4 ++-- pkg/container/templates/runtime_config_test.go | 4 ++-- pkg/container/templates/templates_test.go | 6 +++--- pkg/runner/protocol_test.go | 4 ++-- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/arch/05-runconfig-and-permissions.md b/docs/arch/05-runconfig-and-permissions.md index d5ab56e621..3a202c70cc 100644 --- a/docs/arch/05-runconfig-and-permissions.md +++ b/docs/arch/05-runconfig-and-permissions.md @@ -92,7 +92,7 @@ The complete `RunConfig` struct is defined in `pkg/runner/config.go`. - `builder_image`: Override the default base image for the builder stage - Go: Default `golang:1.26-alpine` - Node: Default `node:22-alpine` - - Python: Default `python:3.13-slim` + - Python: Default `python:3.14-slim` - `additional_packages`: Extra packages to install during the build and runtime stages (e.g., build tools, libraries) **CLI usage:** diff --git a/docs/runtime-version-customization.md b/docs/runtime-version-customization.md index 046359e034..34da9304a3 100644 --- a/docs/runtime-version-customization.md +++ b/docs/runtime-version-customization.md @@ -8,7 +8,7 @@ When you use protocol schemes like `thv run go://github.com/example/server`, Too - **Go**: `golang:1.26-alpine` (builder), `alpine:3.23` (runtime) - **Node**: `node:22-alpine` (builder and runtime) -- **Python**: `python:3.13-slim` (builder and runtime) +- **Python**: `python:3.14-slim` (builder and runtime) You can customize these base images to use different versions or add additional build and runtime packages. diff --git a/docs/server/docs.go b/docs/server/docs.go index 37ea895378..b01d3fc70d 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -979,7 +979,7 @@ const docTemplate = `{ "uniqueItems": false }, "builder_image": { - "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:22-alpine\", \"python:3.13-slim\"", + "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:22-alpine\", \"python:3.14-slim\"", "type": "string" } }, diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 702475f709..8283a71338 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -972,7 +972,7 @@ "uniqueItems": false }, "builder_image": { - "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:22-alpine\", \"python:3.13-slim\"", + "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:22-alpine\", \"python:3.14-slim\"", "type": "string" } }, diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 6276b31977..d9c51d8033 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -957,7 +957,7 @@ components: description: |- BuilderImage is the full image reference for the builder stage. An empty string signals "use the default for this transport type" during config merging. - Examples: "golang:1.26-alpine", "node:22-alpine", "python:3.13-slim" + Examples: "golang:1.26-alpine", "node:22-alpine", "python:3.14-slim" type: string type: object github_com_stacklok_toolhive_pkg_core.Workload: diff --git a/pkg/container/templates/runtime_config.go b/pkg/container/templates/runtime_config.go index 356018afc1..7201f5c108 100644 --- a/pkg/container/templates/runtime_config.go +++ b/pkg/container/templates/runtime_config.go @@ -24,7 +24,7 @@ var packageNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._+\-]*$`) type RuntimeConfig struct { // BuilderImage is the full image reference for the builder stage. // An empty string signals "use the default for this transport type" during config merging. - // Examples: "golang:1.26-alpine", "node:22-alpine", "python:3.13-slim" + // Examples: "golang:1.26-alpine", "node:22-alpine", "python:3.14-slim" BuilderImage string `json:"builder_image" yaml:"builder_image"` // AdditionalPackages lists extra packages to install in the builder and @@ -83,7 +83,7 @@ var RuntimeDefaults = map[TransportType]RuntimeConfig{ AdditionalPackages: []string{"git", "ca-certificates"}, }, TransportTypeUVX: { - BuilderImage: "python:3.13-slim", + BuilderImage: "python:3.14-slim", AdditionalPackages: []string{"ca-certificates", "git"}, }, } diff --git a/pkg/container/templates/runtime_config_test.go b/pkg/container/templates/runtime_config_test.go index 14f89d9cc1..d8d4e89e72 100644 --- a/pkg/container/templates/runtime_config_test.go +++ b/pkg/container/templates/runtime_config_test.go @@ -35,7 +35,7 @@ func TestGetDefaultRuntimeConfig(t *testing.T) { { name: "UVX default config", transportType: TransportTypeUVX, - wantImage: "python:3.13-slim", + wantImage: "python:3.14-slim", wantPackages: []string{"ca-certificates", "git"}, }, } @@ -215,7 +215,7 @@ func TestRuntimeConfigValidate_ValidBuilderImages(t *testing.T) { "golang:1.24-alpine", "docker.io/library/node:20-alpine", "ghcr.io/stacklok/builder:latest", - "python:3.13-slim", + "python:3.14-slim", "node:22-alpine", "mcr.microsoft.com/dotnet/sdk:8.0", "registry.example.com/myimage:v1.2.3", diff --git a/pkg/container/templates/templates_test.go b/pkg/container/templates/templates_test.go index ac8d7e8116..2793d0a9d8 100644 --- a/pkg/container/templates/templates_test.go +++ b/pkg/container/templates/templates_test.go @@ -414,7 +414,7 @@ func TestRuntimeStageInstallsAdditionalPackages(t *testing.T) { name: "UVX runtime stage installs extra packages", transportType: TransportTypeUVX, runtimeConfig: &RuntimeConfig{ - BuilderImage: "python:3.13-slim", + BuilderImage: "python:3.14-slim", AdditionalPackages: []string{"ca-certificates", "git", "curl"}, }, wantInRuntime: "curl", @@ -487,7 +487,7 @@ func TestEmptyAdditionalPackagesDoesNotBreakBuild(t *testing.T) { name: "UVX with empty packages", transportType: TransportTypeUVX, runtimeConfig: &RuntimeConfig{ - BuilderImage: "python:3.13-slim", + BuilderImage: "python:3.14-slim", AdditionalPackages: []string{}, }, }, @@ -511,7 +511,7 @@ func TestEmptyAdditionalPackagesDoesNotBreakBuild(t *testing.T) { name: "UVX with nil packages", transportType: TransportTypeUVX, runtimeConfig: &RuntimeConfig{ - BuilderImage: "python:3.13-slim", + BuilderImage: "python:3.14-slim", AdditionalPackages: nil, }, }, diff --git a/pkg/runner/protocol_test.go b/pkg/runner/protocol_test.go index d4edf1bd3b..86da6c1123 100644 --- a/pkg/runner/protocol_test.go +++ b/pkg/runner/protocol_test.go @@ -270,7 +270,7 @@ func TestBuildFromProtocolSchemeWithNameDryRun(t *testing.T) { "example-package", "--transport", "stdio", - "FROM python:3.13-slim", + "FROM python:3.14-slim", }, wantErr: false, }, @@ -383,7 +383,7 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: []string{"curl"}, }, - wantImage: "python:3.13-slim", + wantImage: "python:3.14-slim", wantPackages: []string{"ca-certificates", "git", "curl"}, }, } From b4f391b2632e6c60c2831ea0c5d3f3be6de121a5 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio <ozz@stacklok.com> Date: Tue, 21 Apr 2026 13:40:51 +0300 Subject: [PATCH 101/117] Upgrade default npx builder image to node:24-alpine (#4954) Bump the default npx transport builder image from node:22-alpine to node:24-alpine. Node 24 is the current active LTS release; Node 22 has moved to maintenance. New npx-scheme workloads built with defaults now run on the current LTS line. Users can still pin an older release via --runtime-image when a package needs it. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- docs/arch/05-runconfig-and-permissions.md | 2 +- docs/runtime-version-customization.md | 2 +- docs/server/docs.go | 2 +- docs/server/swagger.json | 2 +- docs/server/swagger.yaml | 2 +- pkg/container/templates/runtime_config.go | 4 ++-- pkg/container/templates/runtime_config_test.go | 6 +++--- pkg/container/templates/templates_test.go | 6 +++--- pkg/runner/protocol_test.go | 12 ++++++------ 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/arch/05-runconfig-and-permissions.md b/docs/arch/05-runconfig-and-permissions.md index 3a202c70cc..3127909f04 100644 --- a/docs/arch/05-runconfig-and-permissions.md +++ b/docs/arch/05-runconfig-and-permissions.md @@ -91,7 +91,7 @@ The complete `RunConfig` struct is defined in `pkg/runner/config.go`. **Fields:** - `builder_image`: Override the default base image for the builder stage - Go: Default `golang:1.26-alpine` - - Node: Default `node:22-alpine` + - Node: Default `node:24-alpine` - Python: Default `python:3.14-slim` - `additional_packages`: Extra packages to install during the build and runtime stages (e.g., build tools, libraries) diff --git a/docs/runtime-version-customization.md b/docs/runtime-version-customization.md index 34da9304a3..5fabc6683a 100644 --- a/docs/runtime-version-customization.md +++ b/docs/runtime-version-customization.md @@ -7,7 +7,7 @@ This guide explains how to customize the base images and packages used when runn When you use protocol schemes like `thv run go://github.com/example/server`, ToolHive automatically generates a container image. By default, it uses: - **Go**: `golang:1.26-alpine` (builder), `alpine:3.23` (runtime) -- **Node**: `node:22-alpine` (builder and runtime) +- **Node**: `node:24-alpine` (builder and runtime) - **Python**: `python:3.14-slim` (builder and runtime) You can customize these base images to use different versions or add additional build and runtime packages. diff --git a/docs/server/docs.go b/docs/server/docs.go index b01d3fc70d..2ae16ec553 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -979,7 +979,7 @@ const docTemplate = `{ "uniqueItems": false }, "builder_image": { - "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:22-alpine\", \"python:3.14-slim\"", + "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:24-alpine\", \"python:3.14-slim\"", "type": "string" } }, diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 8283a71338..b9228a80e1 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -972,7 +972,7 @@ "uniqueItems": false }, "builder_image": { - "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:22-alpine\", \"python:3.14-slim\"", + "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:24-alpine\", \"python:3.14-slim\"", "type": "string" } }, diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index d9c51d8033..cde28c83f7 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -957,7 +957,7 @@ components: description: |- BuilderImage is the full image reference for the builder stage. An empty string signals "use the default for this transport type" during config merging. - Examples: "golang:1.26-alpine", "node:22-alpine", "python:3.14-slim" + Examples: "golang:1.26-alpine", "node:24-alpine", "python:3.14-slim" type: string type: object github_com_stacklok_toolhive_pkg_core.Workload: diff --git a/pkg/container/templates/runtime_config.go b/pkg/container/templates/runtime_config.go index 7201f5c108..f8b32b44fb 100644 --- a/pkg/container/templates/runtime_config.go +++ b/pkg/container/templates/runtime_config.go @@ -24,7 +24,7 @@ var packageNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._+\-]*$`) type RuntimeConfig struct { // BuilderImage is the full image reference for the builder stage. // An empty string signals "use the default for this transport type" during config merging. - // Examples: "golang:1.26-alpine", "node:22-alpine", "python:3.14-slim" + // Examples: "golang:1.26-alpine", "node:24-alpine", "python:3.14-slim" BuilderImage string `json:"builder_image" yaml:"builder_image"` // AdditionalPackages lists extra packages to install in the builder and @@ -79,7 +79,7 @@ var RuntimeDefaults = map[TransportType]RuntimeConfig{ AdditionalPackages: []string{"ca-certificates", "git"}, }, TransportTypeNPX: { - BuilderImage: "node:22-alpine", + BuilderImage: "node:24-alpine", AdditionalPackages: []string{"git", "ca-certificates"}, }, TransportTypeUVX: { diff --git a/pkg/container/templates/runtime_config_test.go b/pkg/container/templates/runtime_config_test.go index d8d4e89e72..5e69ffc0d7 100644 --- a/pkg/container/templates/runtime_config_test.go +++ b/pkg/container/templates/runtime_config_test.go @@ -29,7 +29,7 @@ func TestGetDefaultRuntimeConfig(t *testing.T) { { name: "NPX default config", transportType: TransportTypeNPX, - wantImage: "node:22-alpine", + wantImage: "node:24-alpine", wantPackages: []string{"git", "ca-certificates"}, }, { @@ -215,8 +215,8 @@ func TestRuntimeConfigValidate_ValidBuilderImages(t *testing.T) { "golang:1.24-alpine", "docker.io/library/node:20-alpine", "ghcr.io/stacklok/builder:latest", - "python:3.14-slim", - "node:22-alpine", + "python:3.13-slim", + "node:24-alpine", "mcr.microsoft.com/dotnet/sdk:8.0", "registry.example.com/myimage:v1.2.3", } diff --git a/pkg/container/templates/templates_test.go b/pkg/container/templates/templates_test.go index 2793d0a9d8..0f9cc86944 100644 --- a/pkg/container/templates/templates_test.go +++ b/pkg/container/templates/templates_test.go @@ -405,7 +405,7 @@ func TestRuntimeStageInstallsAdditionalPackages(t *testing.T) { name: "NPX runtime stage installs extra packages", transportType: TransportTypeNPX, runtimeConfig: &RuntimeConfig{ - BuilderImage: "node:22-alpine", + BuilderImage: "node:24-alpine", AdditionalPackages: []string{"git", "ca-certificates", "curl"}, }, wantInRuntime: "curl", @@ -471,7 +471,7 @@ func TestEmptyAdditionalPackagesDoesNotBreakBuild(t *testing.T) { name: "NPX with empty packages", transportType: TransportTypeNPX, runtimeConfig: &RuntimeConfig{ - BuilderImage: "node:22-alpine", + BuilderImage: "node:24-alpine", AdditionalPackages: []string{}, }, }, @@ -495,7 +495,7 @@ func TestEmptyAdditionalPackagesDoesNotBreakBuild(t *testing.T) { name: "NPX with nil packages", transportType: TransportTypeNPX, runtimeConfig: &RuntimeConfig{ - BuilderImage: "node:22-alpine", + BuilderImage: "node:24-alpine", AdditionalPackages: nil, }, }, diff --git a/pkg/runner/protocol_test.go b/pkg/runner/protocol_test.go index 86da6c1123..56870200ea 100644 --- a/pkg/runner/protocol_test.go +++ b/pkg/runner/protocol_test.go @@ -258,7 +258,7 @@ func TestBuildFromProtocolSchemeWithNameDryRun(t *testing.T) { buildArgs: []string{"start"}, wantContains: []string{ `ENTRYPOINT ["npx", "@launchdarkly/mcp-server", "start"]`, - "FROM node:22-alpine", + "FROM node:24-alpine", }, wantErr: false, }, @@ -333,7 +333,7 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: []string{"curl"}, }, - wantImage: "node:22-alpine", + wantImage: "node:24-alpine", wantPackages: []string{"git", "ca-certificates", "curl"}, }, { @@ -353,7 +353,7 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: []string{"git"}, }, - wantImage: "node:22-alpine", + wantImage: "node:24-alpine", wantPackages: []string{"git", "ca-certificates"}, }, { @@ -363,7 +363,7 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: nil, }, - wantImage: "node:22-alpine", + wantImage: "node:24-alpine", wantPackages: []string{"git", "ca-certificates"}, }, { @@ -426,8 +426,8 @@ func TestLoadRuntimeConfigMergesOverrideWithDefaults(t *testing.T) { if got.BuilderImage == "" { t.Error("loadRuntimeConfig() returned empty BuilderImage — should fall back to default") } - if got.BuilderImage != "node:22-alpine" { - t.Errorf("BuilderImage = %q, want %q", got.BuilderImage, "node:22-alpine") + if got.BuilderImage != "node:24-alpine" { + t.Errorf("BuilderImage = %q, want %q", got.BuilderImage, "node:24-alpine") } } From 06d27460c47dedcde85251a43cf33f20bc25a72a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 06:41:35 -0400 Subject: [PATCH 102/117] Update module github.com/stacklok/toolhive-catalog to v0.20260421.0 (#4932) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Radoslav Dimitrov <radoslav@stacklok.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 814c1c71e5..4ded10462d 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/redis/go-redis/v9 v9.18.0 github.com/shirou/gopsutil/v4 v4.26.2 github.com/spf13/viper v1.21.0 - github.com/stacklok/toolhive-catalog v0.20260417.0 + github.com/stacklok/toolhive-catalog v0.20260421.0 github.com/stacklok/toolhive-core v0.0.16 github.com/stretchr/testify v1.11.1 github.com/swaggo/swag/v2 v2.0.0-rc5 diff --git a/go.sum b/go.sum index 17216637bd..239f4b3bb2 100644 --- a/go.sum +++ b/go.sum @@ -797,8 +797,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stacklok/toolhive-catalog v0.20260417.0 h1:LUNlpmMNORtcaYKmS7KNKo4mEq5JMXfyqlwOBGH6kd4= -github.com/stacklok/toolhive-catalog v0.20260417.0/go.mod h1:KveauhiKSukvUr2SYxDnlSeW3JmfdbXefuZxpiOwQ6A= +github.com/stacklok/toolhive-catalog v0.20260421.0 h1:aHHjwseUNq0RS1OltWFOgh6z+dik6bmEx2X/cBIVuls= +github.com/stacklok/toolhive-catalog v0.20260421.0/go.mod h1:KveauhiKSukvUr2SYxDnlSeW3JmfdbXefuZxpiOwQ6A= github.com/stacklok/toolhive-core v0.0.16 h1:Td/o/zrO3Pbr6tkx4r0k8Bqn9DySlwgah2vknE8tj5A= github.com/stacklok/toolhive-core v0.0.16/go.mod h1:iHp39rCHZxXzU5FmXKK9aoH48NE+K0ob+MtdTS9CvAk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= From 14fe9b69caeea39accc18ae154aa929e450c02ce Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:43:02 +0200 Subject: [PATCH 103/117] Document 502 Bad Gateway failure for installSkill in OpenAPI spec (#4960) Follow-up to #4956: installFromOCI now returns 502 on upstream pull failures (matching installFromGit), but the @Failure list on the installSkill swagger annotation still only advertised 400/409/500. Add @Failure 502 {string} string "Bad Gateway" and regenerate the spec so clients see the real contract for POST /api/v1beta/skills. --- docs/server/docs.go | 10 ++++++++++ docs/server/swagger.json | 10 ++++++++++ docs/server/swagger.yaml | 6 ++++++ pkg/api/v1/skills.go | 1 + 4 files changed, 27 insertions(+) diff --git a/docs/server/docs.go b/docs/server/docs.go index 2ae16ec553..acbccf0344 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -5420,6 +5420,16 @@ const docTemplate = `{ } }, "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Gateway" } }, "summary": "Install a skill", diff --git a/docs/server/swagger.json b/docs/server/swagger.json index b9228a80e1..1546a78db7 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -5413,6 +5413,16 @@ } }, "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Gateway" } }, "summary": "Install a skill", diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index cde28c83f7..623e00adb2 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -4325,6 +4325,12 @@ paths: schema: type: string description: Internal Server Error + "502": + content: + application/json: + schema: + type: string + description: Bad Gateway summary: Install a skill tags: - skills diff --git a/pkg/api/v1/skills.go b/pkg/api/v1/skills.go index 3ebd627837..23667222b1 100644 --- a/pkg/api/v1/skills.go +++ b/pkg/api/v1/skills.go @@ -87,6 +87,7 @@ func (s *SkillsRoutes) listSkills(w http.ResponseWriter, r *http.Request) error // @Failure 400 {string} string "Bad Request" // @Failure 409 {string} string "Conflict" // @Failure 500 {string} string "Internal Server Error" +// @Failure 502 {string} string "Bad Gateway" // @Router /api/v1beta/skills [post] func (s *SkillsRoutes) installSkill(w http.ResponseWriter, r *http.Request) error { var req installSkillRequest From 829c4bd07d69dbf1f0b57b862d5b8cb0d6c4fa1e Mon Sep 17 00:00:00 2001 From: Radoslav Dimitrov <radoslav@stacklok.com> Date: Tue, 21 Apr 2026 06:52:17 -0400 Subject: [PATCH 104/117] Add registry-aware workload creation (#4872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add registry-aware workload creation Add optional registry and server fields to POST /api/v1beta/workloads so clients can create workloads by referencing a registry server: {"registry": "default", "server": "io.github.stacklok/fetch"} thv resolves the server from the registry, extracts image/transport/ env vars/permissions/etc from metadata, and builds the RunConfig server-side. User-provided fields always override registry defaults. This eliminates client-side field extraction — Studio goes from GET+extract+POST to just POST. Ref: #4199 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Regenerate OpenAPI spec for registry-aware workload fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address review findings for registry-aware workload creation - Fix handler validation: accept registry+server as alternative to image/url (was rejecting before resolution could run) - Fix validation ordering: check registry+server pairing before attempting resolution - Validate registry name: only "default" is currently supported, reject unknown values with clear error - Regenerate OpenAPI spec Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Preserve registry metadata through BuildFullRunConfig Return ServerMetadata from resolveRegistryServer and assign it to the local serverMetadata/imageMetadata variables so downstream code (registry info on RunConfig, tool validation, remote auth config, proxy port) has access to it. Skip the imageRetriever re-lookup when metadata was already resolved from a registry reference — re-looking up by bare image ref would fail since the image ref doesn't match any server name. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Complete remote server metadata handling and address review feedback Fix remote server path to get the same treatment as image servers: - Extract ProxyPort from RemoteServerMetadata in registry assignment - Build full remoteAuthConfig from registry OAuthConfig (scopes, issuer, authorize/token URLs, PKCE, headers, env vars) - Previously only URL and Headers were copied; OAuth, EnvVars, and ProxyPort were silently dropped Other fixes: - Make resolveRegistryServer a method on WorkloadService using s.configProvider instead of creating a separate config.NewProvider() - Improve validation: check partial registry+server before the generic "image or url required" error for better DX Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix remote auth overwrite, add image provenance verification Critical fixes found by review: Remote server path: - Guard `if req.URL != ""` to skip createRequestToRemoteAuthConfig when metadata was resolved from registry (was unconditionally overwriting the registry-built remoteAuthConfig, dropping all OAuth fields, EnvVars, and ProxyPort) - Add ClientSecret/BearerToken handling from user request on top of registry-resolved OAuth config Image server path: - Add image provenance verification (VerifyImage) for registry- resolved images. The normal imageRetriever path calls this internally but registry references bypass it. - Export VerifyImage from retriever package for reuse Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add proper HTTP status codes to registry resolution errors - Unknown registry → 400 Bad Request (was 500) - Server not found → 404 Not Found (was 500) - Registry provider unavailable → 503 Service Unavailable (was 500) - Partial registry+server guard → 400 Bad Request (was 500) - Fix misleading error message: "not found in registry" → "failed to look up in registry" for non-not-found errors Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add unit tests for registry-aware workload helpers Cover applyImageDefaults (env var merge, user override precedence), applyRemoteDefaults, applyRegistryDefaults dispatch, and the resolveRegistryServer 400 path for unknown registries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Extract remote auth config builder helper The registry-resolved RemoteServerMetadata branch and the imageRetriever-returned remote branch in BuildFullRunConfig built remote.Config from registry metadata in identical ways. Extract buildRemoteAuthConfigFromMetadata so future changes to the OAuth config shape land in one place. Also drop the registry+server pairing guard from BuildFullRunConfig since the route handler already rejects partial pairs with a 400 before the service is called. Add unit tests covering nil OAuthConfig, full field population, resource precedence (user > metadata > URL-derived), and CLI-string formatting for user-provided ClientSecret and BearerToken. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- docs/server/docs.go | 8 + docs/server/swagger.json | 8 + docs/server/swagger.yaml | 11 + pkg/api/v1/workload_service.go | 230 ++++++++++++--- pkg/api/v1/workload_service_test.go | 421 ++++++++++++++++++++++++++++ pkg/api/v1/workload_types.go | 7 + pkg/api/v1/workloads.go | 13 +- pkg/runner/retriever/retriever.go | 8 +- 8 files changed, 659 insertions(+), 47 deletions(-) diff --git a/docs/server/docs.go b/docs/server/docs.go index acbccf0344..e24ca1e5df 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -2515,6 +2515,10 @@ const docTemplate = `{ "description": "Port for the HTTP proxy to listen on", "type": "integer" }, + "registry": { + "description": "Registry is the optional registry name to resolve the server from (e.g. \"default\").", + "type": "string" + }, "runtime_config": { "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_container_templates.RuntimeConfig" }, @@ -2526,6 +2530,10 @@ const docTemplate = `{ "type": "array", "uniqueItems": false }, + "server": { + "description": "Server is the optional server name in the registry (e.g. \"io.github.stacklok/fetch\").\nWhen both Registry and Server are set, thv resolves the server metadata\nserver-side, filling in image, transport, env vars, permissions, etc.\nUser-provided fields always override registry defaults.", + "type": "string" + }, "target_port": { "description": "Port to expose from the container", "type": "integer" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 1546a78db7..32bf65334b 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -2508,6 +2508,10 @@ "description": "Port for the HTTP proxy to listen on", "type": "integer" }, + "registry": { + "description": "Registry is the optional registry name to resolve the server from (e.g. \"default\").", + "type": "string" + }, "runtime_config": { "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_container_templates.RuntimeConfig" }, @@ -2519,6 +2523,10 @@ "type": "array", "uniqueItems": false }, + "server": { + "description": "Server is the optional server name in the registry (e.g. \"io.github.stacklok/fetch\").\nWhen both Registry and Server are set, thv resolves the server metadata\nserver-side, filling in image, transport, env vars, permissions, etc.\nUser-provided fields always override registry defaults.", + "type": "string" + }, "target_port": { "description": "Port to expose from the container", "type": "integer" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 623e00adb2..5fc9d24520 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -2242,6 +2242,10 @@ components: proxy_port: description: Port for the HTTP proxy to listen on type: integer + registry: + description: Registry is the optional registry name to resolve the server + from (e.g. "default"). + type: string runtime_config: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_container_templates.RuntimeConfig' secrets: @@ -2250,6 +2254,13 @@ components: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_secrets.SecretParameter' type: array uniqueItems: false + server: + description: |- + Server is the optional server name in the registry (e.g. "io.github.stacklok/fetch"). + When both Registry and Server are set, thv resolves the server metadata + server-side, filling in image, transport, env vars, permissions, etc. + User-provided fields always override registry defaults. + type: string target_port: description: Port to expose from the container type: integer diff --git a/pkg/api/v1/workload_service.go b/pkg/api/v1/workload_service.go index 70c7143b9a..63d2ecffab 100644 --- a/pkg/api/v1/workload_service.go +++ b/pkg/api/v1/workload_service.go @@ -8,11 +8,13 @@ import ( "errors" "fmt" "log/slog" + "net/http" "strings" "time" nameref "github.com/google/go-containerregistry/pkg/name" + "github.com/stacklok/toolhive-core/httperr" regtypes "github.com/stacklok/toolhive-core/registry/types" groupval "github.com/stacklok/toolhive-core/validation/group" httpval "github.com/stacklok/toolhive-core/validation/http" @@ -22,6 +24,7 @@ import ( "github.com/stacklok/toolhive/pkg/container/templates" "github.com/stacklok/toolhive/pkg/groups" "github.com/stacklok/toolhive/pkg/networking" + "github.com/stacklok/toolhive/pkg/registry" "github.com/stacklok/toolhive/pkg/runner" "github.com/stacklok/toolhive/pkg/runner/retriever" "github.com/stacklok/toolhive/pkg/secrets" @@ -142,6 +145,20 @@ func (s *WorkloadService) UpdateWorkloadFromRequest(ctx context.Context, name st func (s *WorkloadService) BuildFullRunConfig( ctx context.Context, req *createRequest, existingPort int, ) (*runner.RunConfig, error) { + // If registry+server specified, resolve from registry and fill defaults. + // The returned metadata is assigned to the local variables so the rest of + // BuildFullRunConfig (registry info, tool validation, remote auth, etc.) + // has access to it without re-looking up the server. The route handler + // already rejects partial registry+server pairs with a 400. + var registryResolvedMetadata regtypes.ServerMetadata + if req.Registry != "" && req.Server != "" { + var err error + registryResolvedMetadata, err = s.resolveRegistryServer(req) + if err != nil { + return nil, fmt.Errorf("failed to resolve server from registry: %w", err) + } + } + // Default proxy mode to streamable-http if not specified (SSE is deprecated) if !types.IsValidProxyMode(req.ProxyMode) { if req.ProxyMode == "" { @@ -190,25 +207,59 @@ func (s *WorkloadService) BuildFullRunConfig( var imageMetadata *regtypes.ImageMetadata var serverMetadata regtypes.ServerMetadata var registryProxyPort int + + // If we resolved metadata from a registry reference, assign it to the + // local variables so downstream code (registry info, tool validation, + // remote auth config, proxy port) picks it up automatically. + if registryResolvedMetadata != nil { + serverMetadata = registryResolvedMetadata + switch md := registryResolvedMetadata.(type) { + case *regtypes.ImageMetadata: + imageMetadata = md + imageURL = md.Image + case *regtypes.RemoteServerMetadata: + if req.ProxyPort == 0 && md.ProxyPort > 0 { + registryProxyPort = md.ProxyPort + } + remoteAuthConfig = buildRemoteAuthConfigFromMetadata(req, md) + } + } + + // Verify image provenance for registry-resolved image servers. + // The normal imageRetriever path calls verifyImage internally, but + // we bypass it for registry references, so we must verify here. + if imageMetadata != nil && registryResolvedMetadata != nil { + if err := retriever.VerifyImage(imageURL, imageMetadata, retriever.VerifyImageWarn); err != nil { + return nil, fmt.Errorf("image verification failed: %w", err) + } + } + runtimeConfigOverride := runtimeConfigFromRequest(req) retrievalRuntimeConfig, err := runtimeConfigForImageBuild(req, runtimeConfigOverride) if err != nil { return nil, fmt.Errorf("%w: %w", retriever.ErrInvalidRunConfig, err) } - if req.URL != "" { - // Configure remote authentication if OAuth config is provided + if req.URL != "" && registryResolvedMetadata == nil { + // Direct URL from user (not resolved from registry) — build auth from request fields. if req.Transport == "" { req.Transport = types.TransportTypeStreamableHTTP.String() } remoteAuthConfig = createRequestToRemoteAuthConfig(ctx, req) - } else { - // Create a dedicated context with longer timeout for image retrieval + } else if req.URL != "" && registryResolvedMetadata != nil { + // URL was filled by registry resolution — remoteAuthConfig was already built + // in the assignment block above. Just ensure transport has a default. + if req.Transport == "" { + req.Transport = types.TransportTypeStreamableHTTP.String() + } + } else if registryResolvedMetadata == nil { + // Only call imageRetriever if we didn't already resolve from a registry + // reference. When registry+server was used, serverMetadata and imageMetadata + // are already populated above and re-looking up by bare image ref would fail + // (the image ref doesn't match any server name in the registry). imageCtx, cancel := context.WithTimeout(ctx, imageRetrievalTimeout) defer cancel() - // Resolve the requested image from the registry without pulling it. - // The actual pull is deferred until after the policy check. imageURL, serverMetadata, err = s.imageRetriever( imageCtx, req.Image, @@ -231,41 +282,7 @@ func (s *WorkloadService) BuildFullRunConfig( if req.ProxyPort == 0 && remoteServerMetadata.ProxyPort > 0 { registryProxyPort = remoteServerMetadata.ProxyPort } - - if remoteServerMetadata.OAuthConfig != nil { - // Default resource: user-provided > registry metadata > derived from remote URL - resource := req.OAuthConfig.Resource - if resource == "" { - resource = remoteServerMetadata.OAuthConfig.Resource - } - if resource == "" && remoteServerMetadata.URL != "" { - resource = remote.DefaultResourceIndicator(remoteServerMetadata.URL) - } - - remoteAuthConfig = &remote.Config{ - ClientID: req.OAuthConfig.ClientID, - Scopes: remoteServerMetadata.OAuthConfig.Scopes, - CallbackPort: remoteServerMetadata.OAuthConfig.CallbackPort, - Issuer: remoteServerMetadata.OAuthConfig.Issuer, - AuthorizeURL: remoteServerMetadata.OAuthConfig.AuthorizeURL, - TokenURL: remoteServerMetadata.OAuthConfig.TokenURL, - UsePKCE: remoteServerMetadata.OAuthConfig.UsePKCE, - Resource: resource, - OAuthParams: remoteServerMetadata.OAuthConfig.OAuthParams, - Headers: remoteServerMetadata.Headers, - EnvVars: remoteServerMetadata.EnvVars, - } - - // Store the client secret in CLI format if provided - if req.OAuthConfig.ClientSecret != nil { - remoteAuthConfig.ClientSecret = req.OAuthConfig.ClientSecret.ToCLIString() - } - - // Store the bearer token in CLI format if provided - if req.OAuthConfig.BearerToken != nil { - remoteAuthConfig.BearerToken = req.OAuthConfig.BearerToken.ToCLIString() - } - } + remoteAuthConfig = buildRemoteAuthConfigFromMetadata(req, remoteServerMetadata) } // Handle server metadata - API only supports container servers. // Use type assertion with nil check to guard against typed nil pointers. @@ -397,6 +414,46 @@ func (s *WorkloadService) BuildFullRunConfig( return runConfig, nil } +// buildRemoteAuthConfigFromMetadata builds a remote.Config from registry +// RemoteServerMetadata, layering user-provided secrets (ClientSecret, +// BearerToken) and an optional user-provided Resource on top. Returns nil +// if the metadata has no OAuthConfig. +func buildRemoteAuthConfigFromMetadata(req *createRequest, md *regtypes.RemoteServerMetadata) *remote.Config { + if md.OAuthConfig == nil { + return nil + } + + // Default resource: user-provided > registry metadata > derived from remote URL + resource := req.OAuthConfig.Resource + if resource == "" { + resource = md.OAuthConfig.Resource + } + if resource == "" && md.URL != "" { + resource = remote.DefaultResourceIndicator(md.URL) + } + + cfg := &remote.Config{ + ClientID: req.OAuthConfig.ClientID, + Scopes: md.OAuthConfig.Scopes, + CallbackPort: md.OAuthConfig.CallbackPort, + Issuer: md.OAuthConfig.Issuer, + AuthorizeURL: md.OAuthConfig.AuthorizeURL, + TokenURL: md.OAuthConfig.TokenURL, + UsePKCE: md.OAuthConfig.UsePKCE, + Resource: resource, + OAuthParams: md.OAuthConfig.OAuthParams, + Headers: md.Headers, + EnvVars: md.EnvVars, + } + if req.OAuthConfig.ClientSecret != nil { + cfg.ClientSecret = req.OAuthConfig.ClientSecret.ToCLIString() + } + if req.OAuthConfig.BearerToken != nil { + cfg.BearerToken = req.OAuthConfig.BearerToken.ToCLIString() + } + return cfg +} + // createRequestToRemoteAuthConfig converts API request to runner RemoteAuthConfig func createRequestToRemoteAuthConfig( _ context.Context, @@ -557,3 +614,94 @@ func (s *WorkloadService) GetWorkloadNamesFromRequest(ctx context.Context, req b return workloadNames, nil } + +// resolveRegistryServer resolves a server from the registry and fills in +// default values on the request. User-provided fields are not overwritten. +func (s *WorkloadService) resolveRegistryServer(req *createRequest) (regtypes.ServerMetadata, error) { + // Only "default" registry is currently supported. + if req.Registry != "default" { + return nil, httperr.WithCode( + fmt.Errorf("unknown registry %q; only \"default\" is currently supported", req.Registry), + http.StatusBadRequest, + ) + } + + provider, err := registry.GetDefaultProviderWithConfig( + s.configProvider, + registry.WithInteractive(false), + ) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("failed to get registry provider: %w", err), + http.StatusServiceUnavailable, + ) + } + + metadata, err := provider.GetServer(req.Server) + if err != nil { + if errors.Is(err, registry.ErrServerNotFound) { + return nil, httperr.WithCode( + fmt.Errorf("server %q not found in registry: %w", req.Server, err), + http.StatusNotFound, + ) + } + return nil, httperr.WithCode( + fmt.Errorf("failed to look up server %q in registry: %w", req.Server, err), + http.StatusServiceUnavailable, + ) + } + + applyRegistryDefaults(req, metadata) + return metadata, nil +} + +func applyRegistryDefaults(req *createRequest, metadata regtypes.ServerMetadata) { + if req.Transport == "" { + req.Transport = metadata.GetTransport() + } + if req.Name == "" { + req.Name = metadata.GetName() + } + + switch md := metadata.(type) { + case *regtypes.ImageMetadata: + applyImageDefaults(req, md) + case *regtypes.RemoteServerMetadata: + applyRemoteDefaults(req, md) + } +} + +func applyImageDefaults(req *createRequest, md *regtypes.ImageMetadata) { + if req.Image == "" { + req.Image = md.Image + } + if req.TargetPort == 0 && md.TargetPort != 0 { + req.TargetPort = md.TargetPort + } + if len(req.CmdArguments) == 0 && len(md.Args) > 0 { + req.CmdArguments = md.Args + } + if req.PermissionProfile == nil && md.Permissions != nil { + req.PermissionProfile = md.Permissions + } + // Merge env vars: registry defaults first, user overrides take precedence + if req.EnvVars == nil { + req.EnvVars = make(map[string]string) + } + for _, ev := range md.EnvVars { + if ev.Default != "" { + if _, userSet := req.EnvVars[ev.Name]; !userSet { + req.EnvVars[ev.Name] = ev.Default + } + } + } +} + +func applyRemoteDefaults(req *createRequest, md *regtypes.RemoteServerMetadata) { + if req.URL == "" { + req.URL = md.URL + } + if len(req.Headers) == 0 && len(md.Headers) > 0 { + req.Headers = md.Headers + } +} diff --git a/pkg/api/v1/workload_service_test.go b/pkg/api/v1/workload_service_test.go index 6cba5048ea..ea920a32e1 100644 --- a/pkg/api/v1/workload_service_test.go +++ b/pkg/api/v1/workload_service_test.go @@ -6,6 +6,7 @@ package v1 import ( "context" "errors" + "net/http" "os" "testing" @@ -13,10 +14,14 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive-core/permissions" + regtypes "github.com/stacklok/toolhive-core/registry/types" "github.com/stacklok/toolhive/pkg/config" "github.com/stacklok/toolhive/pkg/container/templates" groupsmocks "github.com/stacklok/toolhive/pkg/groups/mocks" "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/secrets" workloadsmocks "github.com/stacklok/toolhive/pkg/workloads/mocks" ) @@ -427,3 +432,419 @@ func TestCreateWorkloadFromRequest_PolicyGateDenied(t *testing.T) { require.Error(t, err) require.ErrorIs(t, err, sentinel) } + +func TestApplyImageDefaults(t *testing.T) { + t.Parallel() + + permProfile := &permissions.Profile{} + + baseMetadata := func() *regtypes.ImageMetadata { + return ®types.ImageMetadata{ + Image: "ghcr.io/stacklok/fetch:latest", + TargetPort: 8080, + Args: []string{"--listen", "0.0.0.0"}, + Permissions: permProfile, + EnvVars: []*regtypes.EnvVar{ + {Name: "LOG_LEVEL", Default: "info"}, + {Name: "REGION", Default: "us-east-1"}, + {Name: "API_KEY"}, // no default — should not be inserted + }, + } + } + + tests := []struct { + name string + req *createRequest + wantImage string + wantTarget int + wantArgs []string + wantPermSet bool + wantEnvVars map[string]string + }{ + { + name: "empty request fills all defaults", + req: &createRequest{}, + wantImage: "ghcr.io/stacklok/fetch:latest", + wantTarget: 8080, + wantArgs: []string{"--listen", "0.0.0.0"}, + wantPermSet: true, + wantEnvVars: map[string]string{ + "LOG_LEVEL": "info", + "REGION": "us-east-1", + }, + }, + { + name: "user image takes precedence over registry image", + req: &createRequest{ + updateRequest: updateRequest{Image: "my-registry/custom:v1"}, + }, + wantImage: "my-registry/custom:v1", + wantTarget: 8080, + wantArgs: []string{"--listen", "0.0.0.0"}, + wantPermSet: true, + wantEnvVars: map[string]string{ + "LOG_LEVEL": "info", + "REGION": "us-east-1", + }, + }, + { + name: "user target port takes precedence", + req: &createRequest{ + updateRequest: updateRequest{TargetPort: 9090}, + }, + wantImage: "ghcr.io/stacklok/fetch:latest", + wantTarget: 9090, + wantArgs: []string{"--listen", "0.0.0.0"}, + wantPermSet: true, + wantEnvVars: map[string]string{ + "LOG_LEVEL": "info", + "REGION": "us-east-1", + }, + }, + { + name: "user cmd arguments take precedence", + req: &createRequest{ + updateRequest: updateRequest{CmdArguments: []string{"--debug"}}, + }, + wantImage: "ghcr.io/stacklok/fetch:latest", + wantTarget: 8080, + wantArgs: []string{"--debug"}, + wantPermSet: true, + wantEnvVars: map[string]string{ + "LOG_LEVEL": "info", + "REGION": "us-east-1", + }, + }, + { + name: "user env var override preserved, other defaults filled", + req: &createRequest{ + updateRequest: updateRequest{ + EnvVars: map[string]string{"LOG_LEVEL": "debug"}, + }, + }, + wantImage: "ghcr.io/stacklok/fetch:latest", + wantTarget: 8080, + wantArgs: []string{"--listen", "0.0.0.0"}, + wantPermSet: true, + wantEnvVars: map[string]string{ + "LOG_LEVEL": "debug", + "REGION": "us-east-1", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + applyImageDefaults(tt.req, baseMetadata()) + + assert.Equal(t, tt.wantImage, tt.req.Image) + assert.Equal(t, tt.wantTarget, tt.req.TargetPort) + assert.Equal(t, tt.wantArgs, tt.req.CmdArguments) + if tt.wantPermSet { + assert.NotNil(t, tt.req.PermissionProfile) + } + assert.Equal(t, tt.wantEnvVars, tt.req.EnvVars) + }) + } +} + +func TestApplyImageDefaults_UserPermissionProfilePreserved(t *testing.T) { + t.Parallel() + + userProfile := &permissions.Profile{Name: "user-provided"} + registryProfile := &permissions.Profile{Name: "registry-default"} + + req := &createRequest{ + updateRequest: updateRequest{PermissionProfile: userProfile}, + } + md := ®types.ImageMetadata{Permissions: registryProfile} + + applyImageDefaults(req, md) + + assert.Same(t, userProfile, req.PermissionProfile, + "user-provided permission profile must not be replaced by the registry default") +} + +func TestApplyRemoteDefaults(t *testing.T) { + t.Parallel() + + baseMetadata := func() *regtypes.RemoteServerMetadata { + return ®types.RemoteServerMetadata{ + URL: "https://mcp.example.com/mcp", + Headers: []*regtypes.Header{ + {Name: "X-API-Key"}, + }, + } + } + + tests := []struct { + name string + req *createRequest + wantURL string + wantHeaders int + }{ + { + name: "empty request fills URL and Headers", + req: &createRequest{}, + wantURL: "https://mcp.example.com/mcp", + wantHeaders: 1, + }, + { + name: "user URL takes precedence", + req: &createRequest{ + updateRequest: updateRequest{URL: "https://override.example.com/mcp"}, + }, + wantURL: "https://override.example.com/mcp", + wantHeaders: 1, + }, + { + name: "user headers take precedence over registry headers", + req: &createRequest{ + updateRequest: updateRequest{ + Headers: []*regtypes.Header{{Name: "Authorization"}}, + }, + }, + wantURL: "https://mcp.example.com/mcp", + wantHeaders: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + applyRemoteDefaults(tt.req, baseMetadata()) + + assert.Equal(t, tt.wantURL, tt.req.URL) + assert.Len(t, tt.req.Headers, tt.wantHeaders) + }) + } +} + +func TestBuildRemoteAuthConfigFromMetadata(t *testing.T) { + t.Parallel() + + baseMetadata := func() *regtypes.RemoteServerMetadata { + return ®types.RemoteServerMetadata{ + URL: "https://mcp.example.com/mcp", + ProxyPort: 4444, + Headers: []*regtypes.Header{{Name: "X-API-Key"}}, + EnvVars: []*regtypes.EnvVar{{Name: "REGION", Default: "us-east-1"}}, + OAuthConfig: ®types.OAuthConfig{ + Issuer: "https://issuer.example.com", + AuthorizeURL: "https://issuer.example.com/authorize", + TokenURL: "https://issuer.example.com/token", + Scopes: []string{"openid", "profile"}, + UsePKCE: true, + CallbackPort: 1234, + OAuthParams: map[string]string{"prompt": "consent"}, + Resource: "https://resource.example.com", + }, + } + } + + t.Run("returns nil when metadata has no OAuthConfig", func(t *testing.T) { + t.Parallel() + + md := baseMetadata() + md.OAuthConfig = nil + + cfg := buildRemoteAuthConfigFromMetadata(&createRequest{}, md) + + assert.Nil(t, cfg) + }) + + t.Run("populates all OAuth fields from metadata", func(t *testing.T) { + t.Parallel() + + req := &createRequest{ + updateRequest: updateRequest{ + OAuthConfig: remoteOAuthConfig{ClientID: "user-client-id"}, + }, + } + + cfg := buildRemoteAuthConfigFromMetadata(req, baseMetadata()) + + require.NotNil(t, cfg) + assert.Equal(t, "user-client-id", cfg.ClientID) + assert.Equal(t, []string{"openid", "profile"}, cfg.Scopes) + assert.Equal(t, 1234, cfg.CallbackPort) + assert.Equal(t, "https://issuer.example.com", cfg.Issuer) + assert.Equal(t, "https://issuer.example.com/authorize", cfg.AuthorizeURL) + assert.Equal(t, "https://issuer.example.com/token", cfg.TokenURL) + assert.True(t, cfg.UsePKCE) + assert.Equal(t, map[string]string{"prompt": "consent"}, cfg.OAuthParams) + assert.Len(t, cfg.Headers, 1) + assert.Len(t, cfg.EnvVars, 1) + }) + + t.Run("resource precedence: user value wins over metadata and URL", func(t *testing.T) { + t.Parallel() + + req := &createRequest{ + updateRequest: updateRequest{ + OAuthConfig: remoteOAuthConfig{Resource: "https://user.example.com"}, + }, + } + + cfg := buildRemoteAuthConfigFromMetadata(req, baseMetadata()) + + require.NotNil(t, cfg) + assert.Equal(t, "https://user.example.com", cfg.Resource) + }) + + t.Run("resource precedence: metadata wins over URL when user unset", func(t *testing.T) { + t.Parallel() + + cfg := buildRemoteAuthConfigFromMetadata(&createRequest{}, baseMetadata()) + + require.NotNil(t, cfg) + assert.Equal(t, "https://resource.example.com", cfg.Resource) + }) + + t.Run("resource derived from URL when both user and metadata unset", func(t *testing.T) { + t.Parallel() + + md := baseMetadata() + md.OAuthConfig.Resource = "" + + cfg := buildRemoteAuthConfigFromMetadata(&createRequest{}, md) + + require.NotNil(t, cfg) + assert.NotEmpty(t, cfg.Resource, "resource should be derived from md.URL") + }) + + t.Run("user ClientSecret is applied in CLI string format", func(t *testing.T) { + t.Parallel() + + secret := &secrets.SecretParameter{Name: "oauth-secret", Target: "CLIENT_SECRET"} + req := &createRequest{ + updateRequest: updateRequest{ + OAuthConfig: remoteOAuthConfig{ClientSecret: secret}, + }, + } + + cfg := buildRemoteAuthConfigFromMetadata(req, baseMetadata()) + + require.NotNil(t, cfg) + assert.Equal(t, "oauth-secret,target=CLIENT_SECRET", cfg.ClientSecret) + }) + + t.Run("user BearerToken is applied in CLI string format", func(t *testing.T) { + t.Parallel() + + token := &secrets.SecretParameter{Name: "bearer", Target: "TOKEN"} + req := &createRequest{ + updateRequest: updateRequest{ + OAuthConfig: remoteOAuthConfig{BearerToken: token}, + }, + } + + cfg := buildRemoteAuthConfigFromMetadata(req, baseMetadata()) + + require.NotNil(t, cfg) + assert.Equal(t, "bearer,target=TOKEN", cfg.BearerToken) + }) +} + +func TestApplyRegistryDefaults(t *testing.T) { + t.Parallel() + + t.Run("fills transport and name from metadata", func(t *testing.T) { + t.Parallel() + + req := &createRequest{} + md := ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{ + Name: "io.github.stacklok/fetch", + Transport: "stdio", + }, + Image: "ghcr.io/stacklok/fetch:latest", + } + + applyRegistryDefaults(req, md) + + assert.Equal(t, "stdio", req.Transport) + assert.Equal(t, "io.github.stacklok/fetch", req.Name) + assert.Equal(t, "ghcr.io/stacklok/fetch:latest", req.Image) + }) + + t.Run("user transport and name take precedence", func(t *testing.T) { + t.Parallel() + + req := &createRequest{ + Name: "my-workload", + updateRequest: updateRequest{ + Transport: "streamable-http", + }, + } + md := ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{ + Name: "io.github.stacklok/fetch", + Transport: "stdio", + }, + } + + applyRegistryDefaults(req, md) + + assert.Equal(t, "streamable-http", req.Transport) + assert.Equal(t, "my-workload", req.Name) + }) + + t.Run("dispatches to remote defaults for RemoteServerMetadata", func(t *testing.T) { + t.Parallel() + + req := &createRequest{} + md := ®types.RemoteServerMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{ + Name: "remote-server", + Transport: "streamable-http", + }, + URL: "https://remote.example.com/mcp", + } + + applyRegistryDefaults(req, md) + + assert.Equal(t, "streamable-http", req.Transport) + assert.Equal(t, "remote-server", req.Name) + assert.Equal(t, "https://remote.example.com/mcp", req.URL) + }) + + t.Run("dispatches to image defaults for ImageMetadata", func(t *testing.T) { + t.Parallel() + + req := &createRequest{} + md := ®types.ImageMetadata{ + BaseServerMetadata: regtypes.BaseServerMetadata{ + Transport: "stdio", + }, + Image: "ghcr.io/stacklok/fetch:latest", + TargetPort: 8080, + } + + applyRegistryDefaults(req, md) + + assert.Equal(t, "ghcr.io/stacklok/fetch:latest", req.Image) + assert.Equal(t, 8080, req.TargetPort) + }) +} + +func TestWorkloadService_ResolveRegistryServer_UnknownRegistry(t *testing.T) { + t.Parallel() + + service := &WorkloadService{configProvider: config.NewDefaultProvider()} + + req := &createRequest{ + Registry: "nonexistent", + Server: "some-server", + } + + metadata, err := service.resolveRegistryServer(req) + + require.Error(t, err) + assert.Nil(t, metadata) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + assert.Contains(t, err.Error(), `unknown registry "nonexistent"`) +} diff --git a/pkg/api/v1/workload_types.go b/pkg/api/v1/workload_types.go index a87fa44b72..180e4e5045 100644 --- a/pkg/api/v1/workload_types.go +++ b/pkg/api/v1/workload_types.go @@ -154,6 +154,13 @@ type createRequest struct { updateRequest // Name of the workload Name string `json:"name"` + // Registry is the optional registry name to resolve the server from (e.g. "default"). + Registry string `json:"registry,omitempty"` + // Server is the optional server name in the registry (e.g. "io.github.stacklok/fetch"). + // When both Registry and Server are set, thv resolves the server metadata + // server-side, filling in image, transport, env vars, permissions, etc. + // User-provided fields always override registry defaults. + Server string `json:"server,omitempty"` } // oidcOptions represents OIDC configuration options diff --git a/pkg/api/v1/workloads.go b/pkg/api/v1/workloads.go index 60b540fcad..f10c4eb9ec 100644 --- a/pkg/api/v1/workloads.go +++ b/pkg/api/v1/workloads.go @@ -287,10 +287,17 @@ func (s *WorkloadRoutes) createWorkload(w http.ResponseWriter, r *http.Request) ) } - // Validate that image or URL is provided - if req.Image == "" && req.URL == "" { + // Validate that image, URL, or registry+server is provided. + // Check partial registry+server first for a more specific error message. + if (req.Registry != "" && req.Server == "") || (req.Registry == "" && req.Server != "") { return httperr.WithCode( - fmt.Errorf("either 'image' or 'url' field is required"), + fmt.Errorf("both 'registry' and 'server' must be specified together"), + http.StatusBadRequest, + ) + } + if req.Image == "" && req.URL == "" && req.Registry == "" { + return httperr.WithCode( + fmt.Errorf("either 'image', 'url', or 'registry'+'server' fields are required"), http.StatusBadRequest, ) } diff --git a/pkg/runner/retriever/retriever.go b/pkg/runner/retriever/retriever.go index d73437b8c4..0235236a37 100644 --- a/pkg/runner/retriever/retriever.go +++ b/pkg/runner/retriever/retriever.go @@ -117,7 +117,7 @@ func ResolveMCPServer( } // Verify the image against the expected provenance info (if applicable) - if err := verifyImage(imageToUse, imageMetadata, verificationType); err != nil { + if err := VerifyImage(imageToUse, imageMetadata, verificationType); err != nil { return "", nil, err } @@ -341,8 +341,10 @@ func resolveCACertPath(flagValue string) string { return "" } -// verifyImage verifies the image using the specified verification setting (warn, enabled, or disabled) -func verifyImage(image string, server *types.ImageMetadata, verifySetting string) error { +// VerifyImage checks the provenance/signature of a container image. +// The verifySetting controls behavior: VerifyImageDisabled skips checks, +// VerifyImageWarn logs warnings but continues, VerifyImageEnabled fails on issues. +func VerifyImage(image string, server *types.ImageMetadata, verifySetting string) error { switch verifySetting { case VerifyImageDisabled: slog.Warn("Image verification is disabled") From cb37b8ba0d5f95934e1e1e5b843ab3549cdff094 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek <jakub.hrozek@posteo.se> Date: Tue, 21 Apr 2026 11:59:09 +0100 Subject: [PATCH 105/117] Add unit and integration tests for Cedar group-based authorization (#4964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dual-claim extraction, dot-notation traversal, and merge-order fix from 88937fce lacked direct unit tests for helper functions and had no integration tests exercising group-based Cedar policies through the middleware stack. This closes both gaps. Tier 1 — unit tests for parseCedarEntityID, sanitizeURIForCedar, extractClientIDFromClaims, preprocessClaims, preprocessArguments, mergeContexts, and IsAuthorized entity merge priority. Adds edge cases to existing TestResolveNestedClaim (ambiguity) and TestAuthorizeWithJWTClaims_DualClaim (same claim, missing JWT key, non-array graceful handling, both dot-notation). Tier 2 — integration tests for group-based list filtering (Entra dual, Entra groups, Okta URI, Keycloak nested, prompts, resources), non-list operations (tool call, prompt get, resource read), transitive THVGroup→THVRole hierarchy through middleware, and upstream provider group extraction. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> --- pkg/authz/authorizers/cedar/core_test.go | 418 +++++++++++++++ pkg/authz/integration_test.go | 626 +++++++++++++++++++++++ 2 files changed, 1044 insertions(+) diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index 17c07c97f2..446da73e45 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "testing" + cedar "github.com/cedar-policy/cedar-go" "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1924,6 +1925,17 @@ func TestResolveNestedClaim(t *testing.T) { path: "a..b", want: nil, }, + { + name: "exact_match_wins_over_dot_traversal", + claims: jwt.MapClaims{ + "realm_access.roles": []interface{}{"literal-match"}, + "realm_access": map[string]interface{}{ + "roles": []interface{}{"nested-match"}, + }, + }, + path: "realm_access.roles", + want: []interface{}{"literal-match"}, + }, } for _, tt := range tests { @@ -2189,6 +2201,50 @@ func TestAuthorizeWithJWTClaims_DualClaim(t *testing.T) { }, wantAuthorize: true, }, + { + name: "same_claim_for_both_dedup", + groupClaim: "groups", + roleClaim: "groups", + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"platform", "devs"}, + }, + wantAuthorize: true, + }, + { + name: "group_claim_missing_from_jwt_role_claim_matches", + groupClaim: "groups", + roleClaim: "roles", + claims: jwt.MapClaims{ + "sub": "user1", + "roles": []interface{}{"platform"}, + }, + wantAuthorize: true, + }, + { + name: "non_array_group_claim_role_claim_still_works", + groupClaim: "groups", + roleClaim: "roles", + claims: jwt.MapClaims{ + "sub": "user1", + "groups": "not-an-array", + "roles": []interface{}{"platform"}, + }, + wantAuthorize: true, + }, + { + name: "both_claims_use_dot_notation", + groupClaim: "custom.groups", + roleClaim: "custom.roles", + claims: jwt.MapClaims{ + "sub": "user1", + "custom": map[string]interface{}{ + "groups": []interface{}{"devs"}, + "roles": []interface{}{"platform"}, + }, + }, + wantAuthorize: true, + }, } for _, tt := range tests { @@ -2303,6 +2359,368 @@ func TestAuthorizeWithJWTClaims_BackwardCompat(t *testing.T) { } } +// TestParseCedarEntityID tests the parseCedarEntityID helper function. +func TestParseCedarEntityID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantType string + wantID string + wantErr bool + }{ + { + name: "valid_client", + input: "Client::user123", + wantType: "Client", + wantID: "user123", + }, + { + name: "valid_action", + input: "Action::call_tool", + wantType: "Action", + wantID: "call_tool", + }, + { + name: "valid_thvgroup", + input: "THVGroup::engineering", + wantType: "THVGroup", + wantID: "engineering", + }, + { + name: "id_contains_double_colon", + input: "A::B::C", + wantType: "A", + wantID: "B::C", + }, + { + name: "no_separator", + input: "nodoublecolon", + wantErr: true, + }, + { + name: "empty_string", + input: "", + wantErr: true, + }, + { + name: "empty_type", + input: "::id", + wantType: "", + wantID: "id", + }, + { + name: "empty_id", + input: "Type::", + wantType: "Type", + wantID: "", + }, + { + name: "single_colon_no_match", + input: "Type:ID", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gotType, gotID, err := parseCedarEntityID(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantType, gotType) + assert.Equal(t, tt.wantID, gotID) + }) + } +} + +// TestSanitizeURIForCedar tests the sanitizeURIForCedar helper function. +func TestSanitizeURIForCedar(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {name: "empty_string", input: "", want: ""}, + {name: "already_clean", input: "simple_resource", want: "simple_resource"}, + {name: "colon", input: "a:b", want: "a_b"}, + {name: "forward_slash", input: "a/b", want: "a_b"}, + {name: "backslash", input: `a\b`, want: "a_b"}, + {name: "question_mark", input: "a?b", want: "a_b"}, + {name: "ampersand", input: "a&b", want: "a_b"}, + {name: "equals", input: "a=b", want: "a_b"}, + {name: "hash", input: "a#b", want: "a_b"}, + {name: "space", input: "a b", want: "a_b"}, + {name: "dot", input: "a.b", want: "a_b"}, + { + name: "complex_uri", + input: "https://api.example.com/v1/data?key=val&other=123#fragment", + want: "https___api_example_com_v1_data_key_val_other_123_fragment", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := sanitizeURIForCedar(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestExtractClientIDFromClaims tests the extractClientIDFromClaims helper. +func TestExtractClientIDFromClaims(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + claims jwt.MapClaims + wantID string + wantOK bool + }{ + { + name: "valid_sub", + claims: jwt.MapClaims{"sub": "user123"}, + wantID: "user123", + wantOK: true, + }, + { + name: "empty_sub", + claims: jwt.MapClaims{"sub": ""}, + wantID: "", + wantOK: false, + }, + { + name: "missing_sub", + claims: jwt.MapClaims{"name": "John"}, + wantID: "", + wantOK: false, + }, + { + name: "empty_claims", + claims: jwt.MapClaims{}, + wantID: "", + wantOK: false, + }, + { + name: "non_string_sub", + claims: jwt.MapClaims{"sub": 42}, + wantID: "", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + id, ok := extractClientIDFromClaims(tt.claims) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantID, id) + }) + } +} + +// TestPreprocessClaims tests the preprocessClaims helper. +func TestPreprocessClaims(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + claims jwt.MapClaims + want map[string]interface{} + }{ + { + name: "standard_claims_get_prefix", + claims: jwt.MapClaims{"sub": "user1", "role": "admin"}, + want: map[string]interface{}{"claim_sub": "user1", "claim_role": "admin"}, + }, + { + name: "empty_map", + claims: jwt.MapClaims{}, + want: map[string]interface{}{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := preprocessClaims(tt.claims) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestPreprocessArguments tests the preprocessArguments helper. +func TestPreprocessArguments(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args map[string]interface{} + want map[string]interface{} + }{ + { + name: "simple_types_get_prefix", + args: map[string]interface{}{"name": "test", "count": 5, "flag": true}, + want: map[string]interface{}{"arg_name": "test", "arg_count": 5, "arg_flag": true}, + }, + { + name: "complex_type_gets_present_marker", + args: map[string]interface{}{"data": map[string]interface{}{"nested": true}}, + want: map[string]interface{}{"arg_data_present": true}, + }, + { + name: "nil_input_returns_nil", + args: nil, + want: nil, + }, + { + name: "float_preserved", + args: map[string]interface{}{"score": float64(9.5)}, + want: map[string]interface{}{"arg_score": float64(9.5)}, + }, + { + name: "int64_preserved", + args: map[string]interface{}{"id": int64(42)}, + want: map[string]interface{}{"arg_id": int64(42)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := preprocessArguments(tt.args) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestMergeContexts tests the mergeContexts helper. +func TestMergeContexts(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + maps []map[string]interface{} + want map[string]interface{} + }{ + { + name: "non_overlapping_merge", + maps: []map[string]interface{}{ + {"a": 1}, + {"b": 2}, + }, + want: map[string]interface{}{"a": 1, "b": 2}, + }, + { + name: "overlapping_later_wins", + maps: []map[string]interface{}{ + {"a": 1, "b": 2}, + {"b": 3, "c": 4}, + }, + want: map[string]interface{}{"a": 1, "b": 3, "c": 4}, + }, + { + name: "nil_maps_skipped", + maps: []map[string]interface{}{ + {"a": 1}, + nil, + {"b": 2}, + }, + want: map[string]interface{}{"a": 1, "b": 2}, + }, + { + name: "all_nil_returns_empty", + maps: []map[string]interface{}{nil, nil}, + want: map[string]interface{}{}, + }, + { + name: "single_map", + maps: []map[string]interface{}{{"a": 1}}, + want: map[string]interface{}{"a": 1}, + }, + { + name: "no_maps", + maps: []map[string]interface{}{}, + want: map[string]interface{}{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := mergeContexts(tt.maps...) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestIsAuthorized_EntityMergePriority verifies that when a request entity has +// the same UID as a global entity, the request entity wins. This documents the +// merge contract: request entities are applied after global entities in the merge. +func TestIsAuthorized_EntityMergePriority(t *testing.T) { + t.Parallel() + + // Policy: permit only when resource.tier == "silver". + policy := ` + permit( + principal, + action == Action::"call_tool", + resource == Tool::"weather" + ) + when { + resource.tier == "silver" + }; + ` + + // Global entity has tier = "gold" — policy should deny with global entity alone. + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{policy}, + EntitiesJSON: `[ + {"uid": {"type": "Tool", "id": "weather"}, "attrs": {"tier": "gold"}, "parents": []}, + {"uid": {"type": "Client", "id": "user1"}, "attrs": {}, "parents": []}, + {"uid": {"type": "Action", "id": "call_tool"}, "attrs": {}, "parents": []} + ]`, + }, "") + require.NoError(t, err) + + cedarAuthz, ok := authorizer.(*Authorizer) + require.True(t, ok) + + // Verify global entity alone denies (tier = "gold" != "silver"). + denied, err := cedarAuthz.IsAuthorized( + "Client::user1", "Action::call_tool", "Tool::weather", nil, + ) + require.NoError(t, err) + assert.False(t, denied, "global entity tier=gold should not match policy requiring tier=silver") + + // Request entity: same UID but tier = "silver". + requestEntities := make(cedar.EntityMap) + uid := cedar.NewEntityUID("Tool", cedar.String("weather")) + requestEntities[uid] = cedar.Entity{ + UID: uid, + Attributes: cedar.NewRecord(cedar.RecordMap{ + cedar.String("tier"): cedar.String("silver"), + }), + Parents: cedar.NewEntityUIDSet(), + Tags: cedar.NewRecord(cedar.RecordMap{}), + } + + // Request entity should overwrite global entity → policy matches. + allowed, err := cedarAuthz.IsAuthorized( + "Client::user1", "Action::call_tool", "Tool::weather", + nil, requestEntities, + ) + require.NoError(t, err) + assert.True(t, allowed, "request entity (tier=silver) must overwrite global entity (tier=gold)") +} + // TestConfigOptionsRoleClaimNameJSON verifies JSON marshal/unmarshal of the // RoleClaimName field, including backward compatibility when the field is absent. func TestConfigOptionsRoleClaimNameJSON(t *testing.T) { diff --git a/pkg/authz/integration_test.go b/pkg/authz/integration_test.go index 4c17bd6dc3..96ea1438ea 100644 --- a/pkg/authz/integration_test.go +++ b/pkg/authz/integration_test.go @@ -21,6 +21,16 @@ import ( mcpparser "github.com/stacklok/toolhive/pkg/mcp" ) +// makeUnsignedJWT creates a JWT with the given claims using the "none" algorithm. +// Used only in tests; mirrors the helper in the cedar package tests. +func makeUnsignedJWT(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodNone, claims) + signed, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType) + require.NoError(t, err, "makeUnsignedJWT") + return signed +} + // TestIntegrationListFiltering demonstrates the complete authorization flow // with realistic policies and shows how list responses are filtered func TestIntegrationListFiltering(t *testing.T) { @@ -444,3 +454,619 @@ func TestIntegrationNonListOperations(t *testing.T) { }) } } + +// TestIntegrationGroupBasedListFiltering tests the complete middleware pipeline +// with group-based Cedar policies (principal in THVGroup::"...") and realistic +// IDP claim formats. This exercises a fundamentally different Cedar evaluation +// path than TestIntegrationListFiltering: entity hierarchy vs context record. +func TestIntegrationGroupBasedListFiltering(t *testing.T) { + t.Parallel() + + // marshalMockResponse builds the JSON-RPC response bytes for a given result type. + marshalMockResponse := func(t *testing.T, result interface{}) []byte { + t.Helper() + responseData, err := json.Marshal(result) + require.NoError(t, err) + jsonrpcResponse := &jsonrpc2.Response{ID: jsonrpc2.Int64ID(1), Result: json.RawMessage(responseData)} + responseBytes, err := jsonrpc2.EncodeMessage(jsonrpcResponse) + require.NoError(t, err) + return responseBytes + } + + allTools := mcp.ListToolsResult{ + Tools: []mcp.Tool{ + {Name: "deploy", Description: "Deploy service"}, + {Name: "rollback", Description: "Rollback deployment"}, + {Name: "logs", Description: "View logs"}, + }, + } + allPrompts := mcp.ListPromptsResult{ + Prompts: []mcp.Prompt{ + {Name: "greeting", Description: "Generate greetings"}, + {Name: "farewell", Description: "Generate farewells"}, + }, + } + allResources := mcp.ListResourcesResult{ + Resources: []mcp.Resource{ + {URI: "public_data", Name: "Public Data"}, + {URI: "private_data", Name: "Private Data"}, + }, + } + + testCases := []struct { + name string + groupClaim string + roleClaim string + entitiesJSON string + policies []string + claims jwt.MapClaims + method string + mockResponseBytes []byte + expectedItems []string + }{ + { + name: "Entra-like dual claim: role grants access", + groupClaim: "groups", + roleClaim: "roles", + policies: []string{ + `permit(principal in THVGroup::"developer", action == Action::"call_tool", resource == Tool::"deploy");`, + }, + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}, + "roles": []interface{}{"developer"}, + }, + method: string(mcp.MethodToolsList), + expectedItems: []string{"deploy"}, + }, + { + name: "Entra groups only: UUID match", + groupClaim: "groups", + policies: []string{ + `permit(principal in THVGroup::"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", action == Action::"call_tool", resource);`, + }, + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "11111111-2222-3333-4444-555555555555"}, + }, + method: string(mcp.MethodToolsList), + expectedItems: []string{"deploy", "rollback", "logs"}, + }, + { + name: "Okta-like: URI claim with display names", + groupClaim: "https://example.com/groups", + policies: []string{ + `permit(principal in THVGroup::"platform", action == Action::"call_tool", resource == Tool::"deploy");`, + `permit(principal in THVGroup::"platform", action == Action::"call_tool", resource == Tool::"logs");`, + }, + claims: jwt.MapClaims{ + "sub": "user1", + "https://example.com/groups": []interface{}{"platform", "frontend"}, + }, + method: string(mcp.MethodToolsList), + expectedItems: []string{"deploy", "logs"}, + }, + { + name: "Keycloak-like: nested dot-notation claim", + groupClaim: "realm_access.roles", + policies: []string{ + `permit(principal in THVGroup::"editor", action == Action::"call_tool", resource);`, + }, + claims: jwt.MapClaims{ + "sub": "user1", + "realm_access": map[string]interface{}{ + "roles": []interface{}{"editor", "viewer"}, + }, + }, + method: string(mcp.MethodToolsList), + expectedItems: []string{"deploy", "rollback", "logs"}, + }, + { + name: "No matching group: empty filtered list", + groupClaim: "groups", + policies: []string{ + `permit(principal in THVGroup::"admin", action == Action::"call_tool", resource);`, + }, + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"viewer"}, + }, + method: string(mcp.MethodToolsList), + expectedItems: []string{}, + }, + { + name: "Prompts list: group grants access to specific prompt", + groupClaim: "groups", + policies: []string{ + `permit(principal in THVGroup::"devs", action == Action::"get_prompt", resource == Prompt::"greeting");`, + }, + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"devs"}, + }, + method: string(mcp.MethodPromptsList), + expectedItems: []string{"greeting"}, + }, + { + name: "Resources list: group grants access to specific resource", + groupClaim: "groups", + policies: []string{ + `permit(principal in THVGroup::"devs", action == Action::"read_resource", resource == Resource::"public_data");`, + }, + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"devs"}, + }, + method: string(mcp.MethodResourcesList), + expectedItems: []string{"public_data"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + entitiesJSON := tc.entitiesJSON + if entitiesJSON == "" { + entitiesJSON = "[]" + } + + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ + Policies: tc.policies, + EntitiesJSON: entitiesJSON, + GroupClaimName: tc.groupClaim, + RoleClaimName: tc.roleClaim, + }, "") + require.NoError(t, err) + + // Build mock response based on method type if not already provided. + responseBytes := tc.mockResponseBytes + if responseBytes == nil { + switch tc.method { + case string(mcp.MethodPromptsList): + responseBytes = marshalMockResponse(t, allPrompts) + case string(mcp.MethodResourcesList): + responseBytes = marshalMockResponse(t, allResources) + default: + responseBytes = marshalMockResponse(t, allTools) + } + } + + mockHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(responseBytes) + }) + + // Build request. + listReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(1), tc.method, json.RawMessage(`{}`)) + require.NoError(t, err) + reqJSON, err := jsonrpc2.EncodeMessage(listReq) + require.NoError(t, err) + + httpReq, err := http.NewRequest(http.MethodPost, "/messages", bytes.NewBuffer(reqJSON)) + require.NoError(t, err) + httpReq.Header.Set("Content-Type", "application/json") + + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Subject: tc.claims["sub"].(string), Claims: tc.claims}, + } + httpReq = httpReq.WithContext(auth.WithIdentity(httpReq.Context(), identity)) + + rr := httptest.NewRecorder() + middleware := mcpparser.ParsingMiddleware(Middleware(authorizer, mockHandler, nil)) + middleware.ServeHTTP(rr, httpReq) + + assert.Equal(t, http.StatusOK, rr.Code) + + msg, err := jsonrpc2.DecodeMessage(rr.Body.Bytes()) + require.NoError(t, err) + resp, ok := msg.(*jsonrpc2.Response) + require.True(t, ok) + require.Nil(t, resp.Error) + + // Parse and verify the filtered items based on the method type. + switch tc.method { + case string(mcp.MethodToolsList): + var result mcp.ListToolsResult + err = json.Unmarshal(resp.Result, &result) + require.NoError(t, err) + + actualNames := make([]string, len(result.Tools)) + for i, tool := range result.Tools { + actualNames[i] = tool.Name + } + assert.ElementsMatch(t, tc.expectedItems, actualNames) + + case string(mcp.MethodPromptsList): + var result mcp.ListPromptsResult + err = json.Unmarshal(resp.Result, &result) + require.NoError(t, err) + + actualNames := make([]string, len(result.Prompts)) + for i, prompt := range result.Prompts { + actualNames[i] = prompt.Name + } + assert.ElementsMatch(t, tc.expectedItems, actualNames) + + case string(mcp.MethodResourcesList): + var result mcp.ListResourcesResult + err = json.Unmarshal(resp.Result, &result) + require.NoError(t, err) + + actualURIs := make([]string, len(result.Resources)) + for i, resource := range result.Resources { + actualURIs[i] = resource.URI + } + assert.ElementsMatch(t, tc.expectedItems, actualURIs) + } + }) + } +} + +// TestIntegrationGroupBasedNonListOperations tests group-based allow/deny +// decisions for tool calls, prompt gets, and resource reads through the +// full middleware stack. +func TestIntegrationGroupBasedNonListOperations(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + groupClaim string + roleClaim string + policies []string + claims jwt.MapClaims + method string + params map[string]interface{} + expectAllowed bool + }{ + { + name: "Entra dual claim: role grants tool call", + groupClaim: "groups", + roleClaim: "roles", + policies: []string{`permit(principal in THVGroup::"developer", action == Action::"call_tool", resource == Tool::"deploy");`}, + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"some-uuid"}, + "roles": []interface{}{"developer"}, + }, + method: string(mcp.MethodToolsCall), + params: map[string]interface{}{"name": "deploy", "arguments": map[string]interface{}{}}, + expectAllowed: true, + }, + { + name: "Entra groups: UUID match allows", + groupClaim: "groups", + policies: []string{`permit(principal in THVGroup::"aaa-bbb", action == Action::"call_tool", resource);`}, + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"aaa-bbb"}, + }, + method: string(mcp.MethodToolsCall), + params: map[string]interface{}{"name": "anything", "arguments": map[string]interface{}{}}, + expectAllowed: true, + }, + { + name: "Okta URI claim: display name match", + groupClaim: "https://example.com/groups", + policies: []string{`permit(principal in THVGroup::"platform", action == Action::"call_tool", resource == Tool::"deploy");`}, + claims: jwt.MapClaims{ + "sub": "user1", + "https://example.com/groups": []interface{}{"platform"}, + }, + method: string(mcp.MethodToolsCall), + params: map[string]interface{}{"name": "deploy", "arguments": map[string]interface{}{}}, + expectAllowed: true, + }, + { + name: "Keycloak nested: dot-notation grants access", + groupClaim: "realm_access.roles", + policies: []string{`permit(principal in THVGroup::"editor", action == Action::"call_tool", resource);`}, + claims: jwt.MapClaims{ + "sub": "user1", + "realm_access": map[string]interface{}{ + "roles": []interface{}{"editor"}, + }, + }, + method: string(mcp.MethodToolsCall), + params: map[string]interface{}{"name": "any-tool", "arguments": map[string]interface{}{}}, + expectAllowed: true, + }, + { + name: "Wrong group: tool call denied", + groupClaim: "groups", + policies: []string{`permit(principal in THVGroup::"admin", action == Action::"call_tool", resource);`}, + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"viewer"}, + }, + method: string(mcp.MethodToolsCall), + params: map[string]interface{}{"name": "deploy", "arguments": map[string]interface{}{}}, + expectAllowed: false, + }, + { + name: "No groups claim: tool call denied", + groupClaim: "groups", + policies: []string{`permit(principal in THVGroup::"admin", action == Action::"call_tool", resource);`}, + claims: jwt.MapClaims{ + "sub": "user1", + }, + method: string(mcp.MethodToolsCall), + params: map[string]interface{}{"name": "deploy", "arguments": map[string]interface{}{}}, + expectAllowed: false, + }, + { + name: "Group grants prompt get", + groupClaim: "groups", + policies: []string{`permit(principal in THVGroup::"devs", action == Action::"get_prompt", resource == Prompt::"greeting");`}, + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"devs"}, + }, + method: string(mcp.MethodPromptsGet), + params: map[string]interface{}{"name": "greeting"}, + expectAllowed: true, + }, + { + name: "Group grants resource read", + groupClaim: "groups", + policies: []string{`permit(principal in THVGroup::"devs", action == Action::"read_resource", resource);`}, + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"devs"}, + }, + method: string(mcp.MethodResourcesRead), + params: map[string]interface{}{"uri": "public_data"}, + expectAllowed: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ + Policies: tc.policies, + EntitiesJSON: "[]", + GroupClaimName: tc.groupClaim, + RoleClaimName: tc.roleClaim, + }, "") + require.NoError(t, err) + + paramsJSON, err := json.Marshal(tc.params) + require.NoError(t, err) + callReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(1), tc.method, json.RawMessage(paramsJSON)) + require.NoError(t, err) + reqJSON, err := jsonrpc2.EncodeMessage(callReq) + require.NoError(t, err) + + httpReq, err := http.NewRequest(http.MethodPost, "/messages", bytes.NewBuffer(reqJSON)) + require.NoError(t, err) + httpReq.Header.Set("Content-Type", "application/json") + + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Subject: tc.claims["sub"].(string), Claims: tc.claims}, + } + httpReq = httpReq.WithContext(auth.WithIdentity(httpReq.Context(), identity)) + + rr := httptest.NewRecorder() + var handlerCalled bool + mockHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"result": "ok"}`)) + }) + + middleware := mcpparser.ParsingMiddleware(Middleware(authorizer, mockHandler, nil)) + middleware.ServeHTTP(rr, httpReq) + + if tc.expectAllowed { + assert.Equal(t, http.StatusOK, rr.Code) + assert.True(t, handlerCalled, "handler should be called for allowed request") + } else { + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.False(t, handlerCalled, "handler should not be called for denied request") + } + }) + } +} + +// TestIntegrationTransitiveGroupHierarchy tests that the full middleware stack +// preserves the transitive entity hierarchy from entities_json. When +// THVGroup::"eng" is a child of THVRole::"developer" in static entities, a +// policy requiring "principal in THVRole::developer" must still work for a +// user whose JWT contains groups: ["eng"]. +func TestIntegrationTransitiveGroupHierarchy(t *testing.T) { + t.Parallel() + + entitiesJSON := `[ + { + "uid": {"type": "THVGroup", "id": "eng"}, + "attrs": {}, + "parents": [{"type": "THVRole", "id": "developer"}] + }, + { + "uid": {"type": "THVRole", "id": "developer"}, + "attrs": {}, + "parents": [] + } + ]` + + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ + Policies: []string{ + `permit(principal in THVRole::"developer", action == Action::"call_tool", resource == Tool::"deploy");`, + }, + EntitiesJSON: entitiesJSON, + GroupClaimName: "groups", + }, "") + require.NoError(t, err) + + tests := []struct { + name string + groups []interface{} + expectAllowed bool + }{ + { + name: "eng_group_reaches_developer_role_transitively", + groups: []interface{}{"eng"}, + expectAllowed: true, + }, + { + name: "wrong_group_denied", + groups: []interface{}{"marketing"}, + expectAllowed: false, + }, + { + name: "no_groups_denied", + groups: nil, + expectAllowed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + claims := jwt.MapClaims{"sub": "user1"} + if tt.groups != nil { + claims["groups"] = tt.groups + } + + params, err := json.Marshal(map[string]interface{}{"name": "deploy", "arguments": map[string]interface{}{}}) + require.NoError(t, err) + callReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(1), string(mcp.MethodToolsCall), json.RawMessage(params)) + require.NoError(t, err) + reqJSON, err := jsonrpc2.EncodeMessage(callReq) + require.NoError(t, err) + + httpReq, err := http.NewRequest(http.MethodPost, "/messages", bytes.NewBuffer(reqJSON)) + require.NoError(t, err) + httpReq.Header.Set("Content-Type", "application/json") + + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{Subject: "user1", Claims: claims}, + } + httpReq = httpReq.WithContext(auth.WithIdentity(httpReq.Context(), identity)) + + rr := httptest.NewRecorder() + var handlerCalled bool + mockHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"result": "ok"}`)) + }) + + middleware := mcpparser.ParsingMiddleware(Middleware(authorizer, mockHandler, nil)) + middleware.ServeHTTP(rr, httpReq) + + if tt.expectAllowed { + assert.Equal(t, http.StatusOK, rr.Code) + assert.True(t, handlerCalled) + } else { + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.False(t, handlerCalled) + } + }) + } +} + +// TestIntegrationUpstreamProviderGroupAuth verifies that when +// PrimaryUpstreamProvider is configured, group extraction uses the upstream +// token's claims — not the direct request claims — through the full middleware. +func TestIntegrationUpstreamProviderGroupAuth(t *testing.T) { + t.Parallel() + + const providerName = "idp" + + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ + Policies: []string{ + `permit(principal in THVGroup::"platform-eng", action == Action::"call_tool", resource);`, + }, + EntitiesJSON: "[]", + GroupClaimName: "groups", + PrimaryUpstreamProvider: providerName, + }, "") + require.NoError(t, err) + + tests := []struct { + name string + directClaims jwt.MapClaims + upstreamToken string + expectAllowed bool + }{ + { + name: "upstream_groups_authorize", + directClaims: jwt.MapClaims{"sub": "thv-user"}, + upstreamToken: makeUnsignedJWT(t, jwt.MapClaims{ + "sub": "upstream-user", + "groups": []interface{}{"platform-eng"}, + }), + expectAllowed: true, + }, + { + name: "upstream_wrong_group_denies", + directClaims: jwt.MapClaims{"sub": "thv-user"}, + upstreamToken: makeUnsignedJWT(t, jwt.MapClaims{ + "sub": "upstream-user", + "groups": []interface{}{"marketing"}, + }), + expectAllowed: false, + }, + { + name: "direct_groups_ignored_when_upstream_configured", + directClaims: jwt.MapClaims{ + "sub": "thv-user", + "groups": []interface{}{"platform-eng"}, + }, + upstreamToken: makeUnsignedJWT(t, jwt.MapClaims{ + "sub": "upstream-user", + "groups": []interface{}{"other"}, + }), + expectAllowed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params, err := json.Marshal(map[string]interface{}{"name": "deploy", "arguments": map[string]interface{}{}}) + require.NoError(t, err) + callReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(1), string(mcp.MethodToolsCall), json.RawMessage(params)) + require.NoError(t, err) + reqJSON, err := jsonrpc2.EncodeMessage(callReq) + require.NoError(t, err) + + httpReq, err := http.NewRequest(http.MethodPost, "/messages", bytes.NewBuffer(reqJSON)) + require.NoError(t, err) + httpReq.Header.Set("Content-Type", "application/json") + + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: tt.directClaims["sub"].(string), + Claims: tt.directClaims, + }, + UpstreamTokens: map[string]string{providerName: tt.upstreamToken}, + } + httpReq = httpReq.WithContext(auth.WithIdentity(httpReq.Context(), identity)) + + rr := httptest.NewRecorder() + var handlerCalled bool + mockHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"result": "ok"}`)) + }) + + middleware := mcpparser.ParsingMiddleware(Middleware(authorizer, mockHandler, nil)) + middleware.ServeHTTP(rr, httpReq) + + if tt.expectAllowed { + assert.Equal(t, http.StatusOK, rr.Code) + assert.True(t, handlerCalled) + } else { + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.False(t, handlerCalled) + } + }) + } +} From 108f6fa9c81b7cd82c42a15d150e8321f5230421 Mon Sep 17 00:00:00 2001 From: Yolanda Robla Mota <yolanda@stacklok.com> Date: Tue, 21 Apr 2026 13:04:22 +0200 Subject: [PATCH 106/117] Add e2e tests for thv vmcp CLI (#4941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add E2E tests for thv vmcp CLI quick mode and config-file mode Add test/e2e/vmcp_cli_test.go covering the two serve modes: quick mode (--group) and config-file mode (init → validate → serve --config), each with an MCP client connectivity assertion that backend tools are exposed. Includes a binding test confirming the server listens only on 127.0.0.1, and negative tests for missing flags and a nonexistent config path. Closes #4888 * fixes from review --------- Co-authored-by: taskbot <taskbot@users.noreply.github.com> --- .github/workflows/e2e-tests.yml | 9 +- test/e2e/vmcp_cli_features_test.go | 497 +++++++++++++++++++++++++++++ test/e2e/vmcp_cli_helpers_test.go | 99 ++++++ test/e2e/vmcp_cli_test.go | 247 ++++++++++++++ 4 files changed, 851 insertions(+), 1 deletion(-) create mode 100644 test/e2e/vmcp_cli_features_test.go create mode 100644 test/e2e/vmcp_cli_helpers_test.go create mode 100644 test/e2e/vmcp_cli_test.go diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 367be110c2..61f710730e 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -74,6 +74,9 @@ jobs: - title: api-misc label_filter: api-misc artifact: e2e-test-results-api-misc + - title: vmcp + label_filter: vmcp + artifact: e2e-test-results-vmcp steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -115,9 +118,13 @@ jobs: docker pull ghcr.io/stackloklabs/osv-mcp/server:0.1.0 & docker pull ghcr.io/stackloklabs/gofetch/server:1.0.2 & docker pull ghcr.io/stacklok/toolhive/egress-proxy:latest & + # yardstick is only needed for the vmcp test suite + if [ "${{ matrix.label_filter }}" = "vmcp" ]; then + docker pull ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 & + fi wait echo "Pre-pulled images:" - docker images --format '{{.Repository}}:{{.Tag}}' | grep -E 'osv-mcp|gofetch|egress-proxy' + docker images --format '{{.Repository}}:{{.Tag}}' | grep -E 'osv-mcp|gofetch|egress-proxy|yardstick' - name: Run E2E tests (${{ matrix.title }}) env: diff --git a/test/e2e/vmcp_cli_features_test.go b/test/e2e/vmcp_cli_features_test.go new file mode 100644 index 0000000000..5351eeca4d --- /dev/null +++ b/test/e2e/vmcp_cli_features_test.go @@ -0,0 +1,497 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// TODO: Follow-up issue tracks infra-heavy tests that need JWT/OIDC, circuit +// breaker, Redis session, and Tier-2 TEI embedding infrastructure: +// https://github.com/stacklok/toolhive/issues/4944 + +package e2e_test + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/mark3labs/mcp-go/mcp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + + thvjson "github.com/stacklok/toolhive/pkg/json" + "github.com/stacklok/toolhive/pkg/vmcp" + vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" + "github.com/stacklok/toolhive/test/e2e" +) + +// modifyVMCPConfig reads a vMCP YAML config file, calls fn to mutate it, then +// writes it back. Used to layer feature-specific settings on top of a base +// config generated by `thv vmcp init`. +func modifyVMCPConfig(path string, fn func(*vmcpconfig.Config)) error { + data, err := os.ReadFile(path) //nolint:gosec // path is test-controlled + if err != nil { + return fmt.Errorf("reading vmcp config %s: %w", path, err) + } + var cfg vmcpconfig.Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("unmarshalling vmcp config: %w", err) + } + fn(&cfg) + out, err := yaml.Marshal(&cfg) + if err != nil { + return fmt.Errorf("marshalling vmcp config: %w", err) + } + if err := os.WriteFile(path, out, 0o600); err != nil { + return fmt.Errorf("writing vmcp config %s: %w", path, err) + } + return nil +} + +// singleBackendFixture holds shared state for feature contexts that use one +// yardstick backend. Call setup in BeforeEach and teardown in AfterEach. +type singleBackendFixture struct { + cfg *e2e.TestConfig + groupName string + backendName string + vMCPCmd *exec.Cmd + vMCPPort int + tmpDir string // empty when withTmpDir is false +} + +func (f *singleBackendFixture) setup(groupPrefix, backendPrefix, tmpDirPattern string) { + f.cfg = e2e.NewTestConfig() + f.groupName = e2e.GenerateUniqueServerName(groupPrefix) + f.backendName = e2e.GenerateUniqueServerName(backendPrefix) + f.vMCPPort = allocateVMCPPort() + + if tmpDirPattern != "" { + var err error + f.tmpDir, err = os.MkdirTemp("", tmpDirPattern) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { _ = os.RemoveAll(f.tmpDir) }) + } + + Expect(e2e.CheckTHVBinaryAvailable(f.cfg)).To(Succeed()) + + e2e.NewTHVCommand(f.cfg, "group", "create", f.groupName).ExpectSuccess() + startYardstick(f.cfg, f.groupName, f.backendName) +} + +func (f *singleBackendFixture) teardown() { + stopVMCPProcess(f.vMCPCmd) + if f.cfg.CleanupAfter { + if err := e2e.StopAndRemoveMCPServer(f.cfg, f.backendName); err != nil { + GinkgoWriter.Printf("cleanup: StopAndRemoveMCPServer(%s) failed: %v\n", f.backendName, err) + } + if err := e2e.RemoveGroup(f.cfg, f.groupName); err != nil { + GinkgoWriter.Printf("cleanup: RemoveGroup(%s) failed: %v\n", f.groupName, err) + } + } +} + +// twoBackendFixture holds shared state for feature contexts that use two +// yardstick backends. +// +// Backend lifecycle (group + containers) is separated from per-test state +// (vMCP port, process, tmpDir) so that multiple test contexts can share the +// same running backends via BeforeAll/AfterAll while each test still gets its +// own vMCP serve process. +type twoBackendFixture struct { + cfg *e2e.TestConfig + groupName string + backendAName string + backendBName string + vMCPCmd *exec.Cmd + vMCPPort int + tmpDir string +} + +// setupBackends starts the group and both yardstick containers. Call once in +// BeforeAll when sharing backends across multiple tests. +func (f *twoBackendFixture) setupBackends(groupPrefix string) { + f.cfg = e2e.NewTestConfig() + f.groupName = e2e.GenerateUniqueServerName(groupPrefix) + f.backendAName = e2e.GenerateUniqueServerName("yardstick-a") + f.backendBName = e2e.GenerateUniqueServerName("yardstick-b") + + Expect(e2e.CheckTHVBinaryAvailable(f.cfg)).To(Succeed()) + + By("creating group and two yardstick backends") + e2e.NewTHVCommand(f.cfg, "group", "create", f.groupName).ExpectSuccess() + // Use different ports so both containers can bind successfully. + // yardstick does not honour MCP_PORT; its listening port must be set + // explicitly via the -port flag and matched by --target-port. + startYardstickOnPort(f.cfg, f.groupName, f.backendAName, 8080) + startYardstickOnPort(f.cfg, f.groupName, f.backendBName, 8081) +} + +// teardownBackends stops and removes the group and both backends. Call in +// AfterAll to match setupBackends. +func (f *twoBackendFixture) teardownBackends() { + if f.cfg.CleanupAfter { + if err := e2e.StopAndRemoveMCPServer(f.cfg, f.backendAName); err != nil { + GinkgoWriter.Printf("cleanup: StopAndRemoveMCPServer(%s) failed: %v\n", f.backendAName, err) + } + if err := e2e.StopAndRemoveMCPServer(f.cfg, f.backendBName); err != nil { + GinkgoWriter.Printf("cleanup: StopAndRemoveMCPServer(%s) failed: %v\n", f.backendBName, err) + } + if err := e2e.RemoveGroup(f.cfg, f.groupName); err != nil { + GinkgoWriter.Printf("cleanup: RemoveGroup(%s) failed: %v\n", f.groupName, err) + } + } +} + +// setupPerTest allocates a fresh port and tmpDir for one test. Call in +// BeforeEach when backends are already running. +func (f *twoBackendFixture) setupPerTest(tmpDirPattern string) { + f.vMCPPort = allocateVMCPPort() + var err error + f.tmpDir, err = os.MkdirTemp("", tmpDirPattern) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { _ = os.RemoveAll(f.tmpDir) }) +} + +// teardownPerTest stops the vMCP serve process. Call in AfterEach. +func (f *twoBackendFixture) teardownPerTest() { + stopVMCPProcess(f.vMCPCmd) + f.vMCPCmd = nil +} + +var _ = Describe("vMCP CLI features", Label("vmcp", "e2e", "features"), func() { + + // ------------------------------------------------------------------------- + // Contexts 1 & 2: Two-backend scenarios. + // Both tests share the same group and yardstick containers (started once + // in BeforeAll) to avoid paying the container-startup cost twice. + // Each test still gets its own vMCP port, process, and tmpDir. + // ------------------------------------------------------------------------- + Describe("two-backend scenarios", Ordered, func() { + var fx twoBackendFixture + + BeforeAll(func() { fx.setupBackends("vmcp-feat-shared") }) + AfterAll(func() { fx.teardownBackends() }) + + // Context 1: Conflict resolution — prefix strategy + // Two identical yardstick backends expose the same tool names. With prefix + // strategy enabled, both sets of tools appear with backend-name prefixes. + Context("conflict resolution (prefix strategy)", func() { + BeforeEach(func() { fx.setupPerTest("vmcp-feat-conflict-*") }) + AfterEach(func() { fx.teardownPerTest() }) + + It("prefixes tool names with backend names so both echo tools are visible", func() { + configPath := filepath.Join(fx.tmpDir, "vmcp.yaml") + initVMCPConfig(fx.cfg, fx.groupName, configPath) + + Expect(modifyVMCPConfig(configPath, func(c *vmcpconfig.Config) { + if c.Aggregation == nil { + c.Aggregation = &vmcpconfig.AggregationConfig{} + } + c.Aggregation.ConflictResolution = vmcp.ConflictStrategyPrefix + c.Aggregation.ConflictResolutionConfig = &vmcpconfig.ConflictResolutionConfig{ + PrefixFormat: "{workload}_", + } + })).To(Succeed()) + + By("starting vMCP serve with prefix config") + fx.vMCPCmd = e2e.StartLongRunningTHVCommand(fx.cfg, + "vmcp", "serve", + "--config", configPath, + "--port", fmt.Sprintf("%d", fx.vMCPPort), + ) + vMCPURL := fmt.Sprintf("http://127.0.0.1:%d/mcp", fx.vMCPPort) + Expect(e2e.WaitForMCPServerReady(fx.cfg, vMCPURL, "streamable-http", 60*time.Second)).To(Succeed()) + + By("listing tools and verifying prefix strategy") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + mcpClient, err := e2e.NewMCPClientForStreamableHTTP(fx.cfg, vMCPURL) + Expect(err).ToNot(HaveOccurred()) + defer func() { _ = mcpClient.Close() }() + Expect(mcpClient.Initialize(ctx)).To(Succeed()) + + tools, err := mcpClient.ListTools(ctx) + Expect(err).ToNot(HaveOccurred()) + + var echoTools []string + for _, t := range tools.Tools { + if strings.Contains(t.Name, "echo") { + echoTools = append(echoTools, t.Name) + } + } + By(fmt.Sprintf("found echo tools: %v", echoTools)) + Expect(echoTools).To(HaveLen(2), "prefix strategy should expose one echo tool per backend") + + hasA := false + hasB := false + for _, name := range echoTools { + if strings.HasPrefix(name, fx.backendAName+"_") { + hasA = true + } + if strings.HasPrefix(name, fx.backendBName+"_") { + hasB = true + } + } + Expect(hasA).To(BeTrue(), fmt.Sprintf("expected echo tool prefixed with %s_", fx.backendAName)) + Expect(hasB).To(BeTrue(), fmt.Sprintf("expected echo tool prefixed with %s_", fx.backendBName)) + }) + }) + + // Context 2: Tool filtering — per-workload allow-list + ExcludeAll + // backendA exposes only "echo"; backendB is completely hidden. + Context("tool filtering (per-workload filter + ExcludeAll)", func() { + BeforeEach(func() { fx.setupPerTest("vmcp-feat-filter-*") }) + AfterEach(func() { fx.teardownPerTest() }) + + It("only exposes the filtered tool from backendA; backendB tools are hidden", func() { + configPath := filepath.Join(fx.tmpDir, "vmcp.yaml") + initVMCPConfig(fx.cfg, fx.groupName, configPath) + + Expect(modifyVMCPConfig(configPath, func(c *vmcpconfig.Config) { + if c.Aggregation == nil { + c.Aggregation = &vmcpconfig.AggregationConfig{} + } + // ConflictResolution is required by the aggregation validator even when + // per-workload filters are the focus of the test. + c.Aggregation.ConflictResolution = vmcp.ConflictStrategyPrefix + c.Aggregation.ConflictResolutionConfig = &vmcpconfig.ConflictResolutionConfig{ + PrefixFormat: "{workload}_", + } + c.Aggregation.Tools = []*vmcpconfig.WorkloadToolConfig{ + {Workload: fx.backendAName, Filter: []string{"echo"}}, + {Workload: fx.backendBName, ExcludeAll: true}, + } + })).To(Succeed()) + + By("starting vMCP serve with filter config") + fx.vMCPCmd = e2e.StartLongRunningTHVCommand(fx.cfg, + "vmcp", "serve", + "--config", configPath, + "--port", fmt.Sprintf("%d", fx.vMCPPort), + ) + vMCPURL := fmt.Sprintf("http://127.0.0.1:%d/mcp", fx.vMCPPort) + Expect(e2e.WaitForMCPServerReady(fx.cfg, vMCPURL, "streamable-http", 60*time.Second)).To(Succeed()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + mcpClient, err := e2e.NewMCPClientForStreamableHTTP(fx.cfg, vMCPURL) + Expect(err).ToNot(HaveOccurred()) + defer func() { _ = mcpClient.Close() }() + Expect(mcpClient.Initialize(ctx)).To(Succeed()) + + tools, err := mcpClient.ListTools(ctx) + Expect(err).ToNot(HaveOccurred()) + + By(fmt.Sprintf("verifying tool visibility; got %d tools", len(tools.Tools))) + var echoCount int + for _, t := range tools.Tools { + Expect(t.Name).ToNot(ContainSubstring(fx.backendBName), + "backendB tools must be hidden via ExcludeAll") + if strings.Contains(t.Name, "echo") { + echoCount++ + } + } + Expect(echoCount).To(Equal(1), "only backendA echo should be visible") + }) + }) + }) + + // ------------------------------------------------------------------------- + // Context 3: Global ExcludeAll — all backend tools hidden + // tools/list must succeed but return an empty slice. + // ------------------------------------------------------------------------- + Context("global ExcludeAll (all tools hidden)", func() { + var fx singleBackendFixture + + BeforeEach(func() { fx.setup("vmcp-feat-excludeall", "yardstick", "vmcp-feat-excludeall-*") }) + AfterEach(func() { fx.teardown() }) + + It("returns an empty tools list when ExcludeAllTools is true", func() { + configPath := filepath.Join(fx.tmpDir, "vmcp.yaml") + initVMCPConfig(fx.cfg, fx.groupName, configPath) + + Expect(modifyVMCPConfig(configPath, func(c *vmcpconfig.Config) { + if c.Aggregation == nil { + c.Aggregation = &vmcpconfig.AggregationConfig{} + } + c.Aggregation.ConflictResolution = vmcp.ConflictStrategyPrefix + c.Aggregation.ExcludeAllTools = true + })).To(Succeed()) + + By("starting vMCP serve with ExcludeAllTools") + fx.vMCPCmd = e2e.StartLongRunningTHVCommand(fx.cfg, + "vmcp", "serve", + "--config", configPath, + "--port", fmt.Sprintf("%d", fx.vMCPPort), + ) + vMCPURL := fmt.Sprintf("http://127.0.0.1:%d/mcp", fx.vMCPPort) + Expect(e2e.WaitForMCPServerReady(fx.cfg, vMCPURL, "streamable-http", 60*time.Second)).To(Succeed()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + mcpClient, err := e2e.NewMCPClientForStreamableHTTP(fx.cfg, vMCPURL) + Expect(err).ToNot(HaveOccurred()) + defer func() { _ = mcpClient.Close() }() + Expect(mcpClient.Initialize(ctx)).To(Succeed()) + + tools, err := mcpClient.ListTools(ctx) + Expect(err).ToNot(HaveOccurred(), "tools/list must succeed even when all tools are hidden") + Expect(tools.Tools).To(BeEmpty(), "ExcludeAllTools should hide all backend tools") + }) + }) + + // ------------------------------------------------------------------------- + // Context 4: Composite sequential tool + // An "echo_twice" composite calls yardstick's echo tool twice in sequence. + // Verifies that: (a) the composite appears in tools/list, (b) calling it + // with a message argument succeeds and returns non-empty content. + // ------------------------------------------------------------------------- + Context("composite sequential tool", func() { + var fx singleBackendFixture + + BeforeEach(func() { fx.setup("vmcp-feat-composite", "yardstick", "vmcp-feat-composite-*") }) + AfterEach(func() { fx.teardown() }) + + It("exposes the composite tool in tools/list and executes it successfully", func() { + configPath := filepath.Join(fx.tmpDir, "vmcp.yaml") + initVMCPConfig(fx.cfg, fx.groupName, configPath) + + Expect(modifyVMCPConfig(configPath, func(c *vmcpconfig.Config) { + if c.Aggregation == nil { + c.Aggregation = &vmcpconfig.AggregationConfig{} + } + // ConflictResolution is required by the aggregation validator. + c.Aggregation.ConflictResolution = vmcp.ConflictStrategyPrefix + + c.CompositeTools = []vmcpconfig.CompositeToolConfig{ + { + Name: "echo_twice", + Description: "Echoes the input message twice in sequence", + Parameters: thvjson.NewMap(map[string]any{ + "type": "object", + "properties": map[string]any{ + "message": map[string]any{ + "type": "string", + "description": "The message to echo twice", + }, + }, + "required": []any{"message"}, + }), + Steps: []vmcpconfig.WorkflowStepConfig{ + { + ID: "first_echo", + Type: "tool", + Tool: fmt.Sprintf("%s.echo", fx.backendName), + Arguments: thvjson.NewMap(map[string]any{ + "input": "{{ .params.message }}", + }), + }, + { + ID: "second_echo", + Type: "tool", + Tool: fmt.Sprintf("%s.echo", fx.backendName), + DependsOn: []string{"first_echo"}, + Arguments: thvjson.NewMap(map[string]any{ + "input": "{{ .params.message }}", + }), + }, + }, + }, + } + })).To(Succeed()) + + By("starting vMCP serve with composite tool config") + fx.vMCPCmd = e2e.StartLongRunningTHVCommand(fx.cfg, + "vmcp", "serve", + "--config", configPath, + "--port", fmt.Sprintf("%d", fx.vMCPPort), + ) + vMCPURL := fmt.Sprintf("http://127.0.0.1:%d/mcp", fx.vMCPPort) + Expect(e2e.WaitForMCPServerReady(fx.cfg, vMCPURL, "streamable-http", 60*time.Second)).To(Succeed()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + mcpClient, err := e2e.NewMCPClientForStreamableHTTP(fx.cfg, vMCPURL) + Expect(err).ToNot(HaveOccurred()) + defer func() { _ = mcpClient.Close() }() + Expect(mcpClient.Initialize(ctx)).To(Succeed()) + + By("verifying echo_twice appears in tools/list") + tools, err := mcpClient.ListTools(ctx) + Expect(err).ToNot(HaveOccurred()) + + var foundComposite bool + for _, t := range tools.Tools { + if t.Name == "echo_twice" { + foundComposite = true + Expect(t.Description).To(Equal("Echoes the input message twice in sequence")) + break + } + } + Expect(foundComposite).To(BeTrue(), "echo_twice composite tool must appear in tools/list") + + By("calling echo_twice and verifying non-empty result") + result, err := mcpClient.CallTool(ctx, "echo_twice", map[string]any{ + "message": "hellocomposite", + }) + Expect(err).ToNot(HaveOccurred(), "composite tool call must succeed") + Expect(result.IsError).To(BeFalse(), "composite tool must not return an error result") + Expect(result.Content).ToNot(BeEmpty(), "composite tool must return content") + Expect(mcp.GetTextFromContent(result.Content[0])).To(ContainSubstring("hellocomposite"), + "composite tool result must contain the echoed message") + }) + }) + + // ------------------------------------------------------------------------- + // Context 5: Tier-1 optimizer (FTS5) — quick mode + // `thv vmcp serve --group <name> --optimizer` must expose exactly two tools: + // find_tool and call_tool. Calling find_tool with a query must return results. + // ------------------------------------------------------------------------- + Context("Tier-1 optimizer (--optimizer flag, quick mode)", func() { + var fx singleBackendFixture + + BeforeEach(func() { fx.setup("vmcp-feat-optimizer", "yardstick", "") }) + AfterEach(func() { fx.teardown() }) + + It("exposes only find_tool and call_tool when --optimizer is set", func() { + By("starting vMCP serve in quick mode with --optimizer") + fx.vMCPCmd = e2e.StartLongRunningTHVCommand(fx.cfg, + "vmcp", "serve", + "--group", fx.groupName, + "--optimizer", + "--port", fmt.Sprintf("%d", fx.vMCPPort), + ) + vMCPURL := fmt.Sprintf("http://127.0.0.1:%d/mcp", fx.vMCPPort) + Expect(e2e.WaitForMCPServerReady(fx.cfg, vMCPURL, "streamable-http", 60*time.Second)).To(Succeed()) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + mcpClient, err := e2e.NewMCPClientForStreamableHTTP(fx.cfg, vMCPURL) + Expect(err).ToNot(HaveOccurred()) + defer func() { _ = mcpClient.Close() }() + Expect(mcpClient.Initialize(ctx)).To(Succeed()) + + By("verifying only find_tool and call_tool are exposed") + tools, err := mcpClient.ListTools(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(tools.Tools).To(HaveLen(2), "optimizer mode must expose exactly 2 tools") + + names := make([]string, len(tools.Tools)) + for i, t := range tools.Tools { + names[i] = t.Name + } + Expect(names).To(ConsistOf("find_tool", "call_tool")) + + By("calling find_tool to verify it returns results") + result, err := mcpClient.CallTool(ctx, "find_tool", map[string]any{ + "tool_description": "echo a message", + }) + Expect(err).ToNot(HaveOccurred(), "find_tool must succeed") + Expect(result.IsError).To(BeFalse(), "find_tool must not return an error result") + Expect(result.Content).ToNot(BeEmpty(), "find_tool must return tool suggestions") + }) + }) + +}) // end Describe("vMCP CLI features") diff --git a/test/e2e/vmcp_cli_helpers_test.go b/test/e2e/vmcp_cli_helpers_test.go new file mode 100644 index 0000000000..4d5bbcc41a --- /dev/null +++ b/test/e2e/vmcp_cli_helpers_test.go @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package e2e_test + +import ( + "fmt" + "net" + "os/exec" + "strconv" + "syscall" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stacklok/toolhive/test/e2e" + "github.com/stacklok/toolhive/test/e2e/images" +) + +// allocateVMCPPort returns a free TCP port on 127.0.0.1 for use by thv vmcp serve. +func allocateVMCPPort() int { + l, err := net.Listen("tcp", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred(), "should be able to allocate a free port") + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +// stopVMCPProcess sends SIGINT to a running vmcp serve process and waits for it +// to exit. If the process does not exit within 5 seconds, SIGKILL is sent to +// prevent the test suite from hanging. Safe to call on a nil cmd or on a cmd +// whose process has already exited. +func stopVMCPProcess(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + _ = cmd.Process.Signal(syscall.SIGINT) + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + select { + case <-done: + // process exited cleanly + case <-time.After(5 * time.Second): + GinkgoWriter.Printf("vmcp process did not exit after SIGINT; sending SIGKILL\n") + _ = cmd.Process.Kill() + <-done // wait for the goroutine to finish after Kill + } +} + +// launchYardstick starts a yardstick backend on port 8080 in the given group +// but does not wait for it to become ready. +func launchYardstick(config *e2e.TestConfig, groupName, backendName string) { + launchYardstickOnPort(config, groupName, backendName, 8080) +} + +// launchYardstickOnPort starts a yardstick backend on the given port in the +// given group but does not wait for it to become ready. The port is passed both +// as --target-port (so thv's proxy maps to it) and as the -port flag to the +// yardstick binary (so the server actually listens on that port). +func launchYardstickOnPort(config *e2e.TestConfig, groupName, backendName string, port int) { + portStr := strconv.Itoa(port) + e2e.NewTHVCommand(config, + "run", images.YardstickServerImage, + "--name", backendName, + "--group", groupName, + "--transport", "streamable-http", + "--target-port", portStr, + "--env", "TRANSPORT=streamable-http", + "--", "-port", portStr, "-transport", "streamable-http", + ).ExpectSuccess() +} + +// startYardstick runs a yardstick backend on port 8080 in the given group and +// waits for it to be ready. +func startYardstick(config *e2e.TestConfig, groupName, backendName string) { + launchYardstick(config, groupName, backendName) + err := e2e.WaitForMCPServer(config, backendName, 120*time.Second) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("yardstick backend %q should become running", backendName)) +} + +// startYardstickOnPort runs a yardstick backend on the given port in the given +// group and waits for it to be ready. +func startYardstickOnPort(config *e2e.TestConfig, groupName, backendName string, port int) { + launchYardstickOnPort(config, groupName, backendName, port) + err := e2e.WaitForMCPServer(config, backendName, 120*time.Second) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("yardstick backend %q should become running", backendName)) +} + +// initVMCPConfig generates a starter YAML config for the given group into path +// using `thv vmcp init`. +func initVMCPConfig(config *e2e.TestConfig, groupName, path string) { + e2e.NewTHVCommand(config, + "vmcp", "init", + "--group", groupName, + "--config", path, + ).ExpectSuccess() +} diff --git a/test/e2e/vmcp_cli_test.go b/test/e2e/vmcp_cli_test.go new file mode 100644 index 0000000000..2a9eee4ddf --- /dev/null +++ b/test/e2e/vmcp_cli_test.go @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package e2e_test + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stacklok/toolhive/test/e2e" +) + +var _ = Describe("vMCP CLI", Label("vmcp", "e2e"), func() { + var ( + config *e2e.TestConfig + groupName string + backendName string + vMCPCmd *exec.Cmd + vMCPPort int + ) + + BeforeEach(func() { + config = e2e.NewTestConfig() + groupName = e2e.GenerateUniqueServerName("vmcp-e2e-group") + backendName = e2e.GenerateUniqueServerName("vmcp-e2e-backend") + vMCPCmd = nil + vMCPPort = allocateVMCPPort() + + err := e2e.CheckTHVBinaryAvailable(config) + Expect(err).ToNot(HaveOccurred(), "thv binary should be available") + }) + + AfterEach(func() { + stopVMCPProcess(vMCPCmd) + vMCPCmd = nil + + if config.CleanupAfter { + if err := e2e.StopAndRemoveMCPServer(config, backendName); err != nil { + GinkgoWriter.Printf("cleanup: StopAndRemoveMCPServer(%s) failed: %v\n", backendName, err) + } + if err := e2e.RemoveGroup(config, groupName); err != nil { + GinkgoWriter.Printf("cleanup: RemoveGroup(%s) failed: %v\n", groupName, err) + } + } + }) + + // ------------------------------------------------------------------------- + // Quick mode: thv vmcp serve --group <name> + // ------------------------------------------------------------------------- + Context("quick mode (--group, no --config)", func() { + BeforeEach(func() { + By("creating group and starting backend workload") + e2e.NewTHVCommand(config, "group", "create", groupName).ExpectSuccess() + startYardstick(config, groupName, backendName) + }) + + It("starts vMCP and exposes backend tools via MCP", func() { + By("starting thv vmcp serve in quick mode") + vMCPCmd = e2e.StartLongRunningTHVCommand(config, + "vmcp", "serve", + "--group", groupName, + "--port", fmt.Sprintf("%d", vMCPPort), + ) + vMCPURL := fmt.Sprintf("http://127.0.0.1:%d/mcp", vMCPPort) + By("waiting for vMCP endpoint to be ready") + err := e2e.WaitForMCPServerReady(config, vMCPURL, "streamable-http", 60*time.Second) + Expect(err).ToNot(HaveOccurred(), "vMCP server should become ready") + + By("connecting MCP client and listing tools") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + mcpClient, err := e2e.NewMCPClientForStreamableHTTP(config, vMCPURL) + Expect(err).ToNot(HaveOccurred()) + defer func() { _ = mcpClient.Close() }() + + Expect(mcpClient.Initialize(ctx)).To(Succeed()) + tools, err := mcpClient.ListTools(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(tools.Tools).ToNot(BeEmpty(), "vMCP should expose at least one tool from the backend") + }) + + It("binds only to 127.0.0.1", func() { + By("starting thv vmcp serve in quick mode") + vMCPCmd = e2e.StartLongRunningTHVCommand(config, + "vmcp", "serve", + "--group", groupName, + "--port", fmt.Sprintf("%d", vMCPPort), + ) + vMCPURL := fmt.Sprintf("http://127.0.0.1:%d/mcp", vMCPPort) + By("waiting for vMCP endpoint to be ready on loopback") + err := e2e.WaitForMCPServerReady(config, vMCPURL, "streamable-http", 60*time.Second) + Expect(err).ToNot(HaveOccurred(), "vMCP server should be reachable on 127.0.0.1") + + By("verifying the listener is bound only to 127.0.0.1") + // Use OS-level socket inspection to confirm the bind address rather than + // dialing, which is ambiguous (0.0.0.0 dials succeed on Linux loopback). + portStr := fmt.Sprintf("%d", vMCPPort) + switch runtime.GOOS { + case "darwin": + // lsof -nP -i TCP:<port> -sTCP:LISTEN prints the listen socket. + // A 127.0.0.1 listener shows "127.0.0.1:<port>"; a wildcard shows "*:<port>". + if _, lookErr := exec.LookPath("lsof"); lookErr != nil { + Skip("lsof not available; skipping bind-address verification") + } + out, err := exec.Command("lsof", "-nP", + fmt.Sprintf("-iTCP:%s", portStr), "-sTCP:LISTEN").Output() + Expect(err).ToNot(HaveOccurred(), "lsof must succeed") + Expect(string(out)).To(ContainSubstring("127.0.0.1:"+portStr), + "listener must be bound to 127.0.0.1") + Expect(string(out)).ToNot(ContainSubstring("*:"+portStr), + "listener must not be bound to all interfaces") + case "linux": + // ss -tlnH 'sport = :<port>' prints one row per listen socket. + // Local address column is "127.0.0.1:<port>" for loopback or "0.0.0.0:<port>" for wildcard. + if _, lookErr := exec.LookPath("ss"); lookErr != nil { + Skip("ss not available; skipping bind-address verification") + } + out, err := exec.Command("ss", "-tlnH", + fmt.Sprintf("sport = :%s", portStr)).Output() + Expect(err).ToNot(HaveOccurred(), "ss must succeed") + Expect(string(out)).To(ContainSubstring("127.0.0.1:"+portStr), + "listener must be bound to 127.0.0.1") + Expect(string(out)).ToNot(ContainSubstring("0.0.0.0:"+portStr), + "listener must not be bound to all interfaces") + default: + Skip(fmt.Sprintf("bind-address verification not supported on %s", runtime.GOOS)) + } + }) + }) + + // ------------------------------------------------------------------------- + // Config-file mode: thv vmcp init → validate → serve --config + // ------------------------------------------------------------------------- + Context("config-file mode (init → validate → serve --config)", func() { + var configFilePath string + + BeforeEach(func() { + By("creating group and starting backend workload") + e2e.NewTHVCommand(config, "group", "create", groupName).ExpectSuccess() + startYardstick(config, groupName, backendName) + + tmpDir, err := os.MkdirTemp("", "vmcp-e2e-config-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { _ = os.RemoveAll(tmpDir) }) + configFilePath = filepath.Join(tmpDir, "vmcp.yaml") + }) + + It("init generates a non-empty valid config file", func() { + By("running thv vmcp init") + e2e.NewTHVCommand(config, + "vmcp", "init", + "--group", groupName, + "--config", configFilePath, + ).ExpectSuccess() + + By("checking the generated file is non-empty") + info, err := os.Stat(configFilePath) + Expect(err).ToNot(HaveOccurred(), "config file should exist") + Expect(info.Size()).To(BeNumerically(">", 0), "config file should be non-empty") + }) + + It("validate accepts the config generated by init", func() { + By("running thv vmcp init") + e2e.NewTHVCommand(config, + "vmcp", "init", + "--group", groupName, + "--config", configFilePath, + ).ExpectSuccess() + + By("running thv vmcp validate") + e2e.NewTHVCommand(config, + "vmcp", "validate", + "--config", configFilePath, + ).ExpectSuccess() + }) + + It("serve --config starts vMCP and exposes backend tools", func() { + By("generating config with thv vmcp init") + e2e.NewTHVCommand(config, + "vmcp", "init", + "--group", groupName, + "--config", configFilePath, + ).ExpectSuccess() + + By("validating the generated config") + e2e.NewTHVCommand(config, + "vmcp", "validate", + "--config", configFilePath, + ).ExpectSuccess() + + By("starting thv vmcp serve --config") + vMCPCmd = e2e.StartLongRunningTHVCommand(config, + "vmcp", "serve", + "--config", configFilePath, + "--port", fmt.Sprintf("%d", vMCPPort), + ) + vMCPURL := fmt.Sprintf("http://127.0.0.1:%d/mcp", vMCPPort) + By("waiting for vMCP endpoint to be ready") + err := e2e.WaitForMCPServerReady(config, vMCPURL, "streamable-http", 60*time.Second) + Expect(err).ToNot(HaveOccurred(), "vMCP server should become ready") + + By("connecting MCP client and listing tools") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + mcpClient, err := e2e.NewMCPClientForStreamableHTTP(config, vMCPURL) + Expect(err).ToNot(HaveOccurred()) + defer func() { _ = mcpClient.Close() }() + + Expect(mcpClient.Initialize(ctx)).To(Succeed()) + tools, err := mcpClient.ListTools(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(tools.Tools).ToNot(BeEmpty(), "vMCP should expose at least one tool from the backend") + }) + }) + + // ------------------------------------------------------------------------- + // Error cases + // ------------------------------------------------------------------------- + Context("error cases", func() { + It("exits non-zero when neither --config nor --group is provided", func() { + By("running thv vmcp serve with no flags") + stdout, stderr, err := e2e.NewTHVCommand(config, "vmcp", "serve"). + RunWithTimeout(10 * time.Second) + Expect(err).To(HaveOccurred(), "serve should fail without --config or --group") + combined := stdout + stderr + Expect(combined).To(ContainSubstring("either --config or --group must be specified"), + "error message should guide the user toward --config or --group") + }) + + It("validate exits non-zero for a non-existent config file", func() { + By("running thv vmcp validate with a non-existent path") + _, _, err := e2e.NewTHVCommand(config, + "vmcp", "validate", + "--config", "/nonexistent/path/vmcp.yaml", + ).RunWithTimeout(10 * time.Second) + Expect(err).To(HaveOccurred(), "validate should fail for a non-existent config file") + }) + }) +}) From f6cdfc29a679258a2294ae3e445da548d2dfb2f0 Mon Sep 17 00:00:00 2001 From: Radoslav Dimitrov <radoslav@stacklok.com> Date: Tue, 21 Apr 2026 07:33:12 -0400 Subject: [PATCH 107/117] Thread image verification mode through workload service (#4966) The registry-resolved path in BuildFullRunConfig hardcoded retriever.VerifyImageWarn while the imageRetriever path threaded verification through its argument list. Keeping two inline literals means the paths can silently drift if one is later made configurable. Store the mode on WorkloadService (still defaulting to warn) and read it in both call sites so changing behavior is a single-line edit. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- pkg/api/v1/workload_service.go | 24 ++++++++------ pkg/api/v1/workload_service_test.go | 49 +++++++++++++++++++++++++++++ pkg/api/v1/workloads_test.go | 48 +++++++++++++++------------- 3 files changed, 90 insertions(+), 31 deletions(-) diff --git a/pkg/api/v1/workload_service.go b/pkg/api/v1/workload_service.go index 63d2ecffab..ceb304a91f 100644 --- a/pkg/api/v1/workload_service.go +++ b/pkg/api/v1/workload_service.go @@ -66,6 +66,10 @@ type WorkloadService struct { imageRetriever retriever.Retriever imagePuller retriever.ImagePuller configProvider config.Provider + // imageVerification is the mode (warn/enabled/disabled) used when verifying + // image provenance for both the registry-resolved path and the imageRetriever + // path. Kept as a single field so the two paths can't drift. + imageVerification string } // NewWorkloadService creates a new WorkloadService instance @@ -76,13 +80,14 @@ func NewWorkloadService( debugMode bool, ) *WorkloadService { return &WorkloadService{ - workloadManager: workloadManager, - groupManager: groupManager, - containerRuntime: containerRuntime, - debugMode: debugMode, - imageRetriever: retriever.ResolveMCPServer, - imagePuller: retriever.PullMCPServerImage, - configProvider: config.NewProvider(), + workloadManager: workloadManager, + groupManager: groupManager, + containerRuntime: containerRuntime, + debugMode: debugMode, + imageRetriever: retriever.ResolveMCPServer, + imagePuller: retriever.PullMCPServerImage, + configProvider: config.NewProvider(), + imageVerification: retriever.VerifyImageWarn, } } @@ -228,8 +233,9 @@ func (s *WorkloadService) BuildFullRunConfig( // Verify image provenance for registry-resolved image servers. // The normal imageRetriever path calls verifyImage internally, but // we bypass it for registry references, so we must verify here. + // Both paths use s.imageVerification so their behavior stays in sync. if imageMetadata != nil && registryResolvedMetadata != nil { - if err := retriever.VerifyImage(imageURL, imageMetadata, retriever.VerifyImageWarn); err != nil { + if err := retriever.VerifyImage(imageURL, imageMetadata, s.imageVerification); err != nil { return nil, fmt.Errorf("image verification failed: %w", err) } } @@ -264,7 +270,7 @@ func (s *WorkloadService) BuildFullRunConfig( imageCtx, req.Image, "", // We do not let the user specify a CA cert path here. - retriever.VerifyImageWarn, + s.imageVerification, "", // Registry-based group lookups are not supported retrievalRuntimeConfig, ) diff --git a/pkg/api/v1/workload_service_test.go b/pkg/api/v1/workload_service_test.go index ea920a32e1..4387e0593e 100644 --- a/pkg/api/v1/workload_service_test.go +++ b/pkg/api/v1/workload_service_test.go @@ -21,6 +21,7 @@ import ( "github.com/stacklok/toolhive/pkg/container/templates" groupsmocks "github.com/stacklok/toolhive/pkg/groups/mocks" "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/runner/retriever" "github.com/stacklok/toolhive/pkg/secrets" workloadsmocks "github.com/stacklok/toolhive/pkg/workloads/mocks" ) @@ -159,6 +160,54 @@ func TestNewWorkloadService(t *testing.T) { require.NotNil(t, service) assert.NotNil(t, service.configProvider, "configProvider must be initialized so config is read fresh on each call, not snapshotted at construction") + assert.Equal(t, retriever.VerifyImageWarn, service.imageVerification, + "imageVerification must default to warn so registry-resolved and imageRetriever paths stay consistent") +} + +// TestBuildFullRunConfig_ThreadsImageVerification verifies the imageRetriever path +// uses s.imageVerification rather than a hardcoded value. Paired with the registry- +// resolved path's direct call to retriever.VerifyImage(imageURL, imageMetadata, +// s.imageVerification), this ensures both paths read the mode from the same field. +func TestBuildFullRunConfig_ThreadsImageVerification(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGroupManager := groupsmocks.NewMockManager(ctrl) + mockGroupManager.EXPECT().Exists(gomock.Any(), "default").Return(true, nil) + + const testImage = "test-image" + + var observed string + mockRetriever := func( + _ context.Context, + _ string, _ string, + verificationType string, + _ string, + _ *templates.RuntimeConfig, + ) (string, regtypes.ServerMetadata, error) { + observed = verificationType + return testImage, ®types.ImageMetadata{Image: testImage}, nil + } + + service := &WorkloadService{ + groupManager: mockGroupManager, + imageRetriever: mockRetriever, + imagePuller: func(_ context.Context, _ string) error { return nil }, + configProvider: config.NewDefaultProvider(), + imageVerification: retriever.VerifyImageDisabled, + } + + req := &createRequest{ + Name: "testserver", + updateRequest: updateRequest{Image: testImage}, + } + + _, err := service.BuildFullRunConfig(context.Background(), req, 0) + require.NoError(t, err) + assert.Equal(t, retriever.VerifyImageDisabled, observed, + "imageRetriever must receive s.imageVerification verbatim") } // writeFactorySentinelConfig writes a YAML config file with DisableUsageMetrics: true diff --git a/pkg/api/v1/workloads_test.go b/pkg/api/v1/workloads_test.go index 7c6abedc50..6091879908 100644 --- a/pkg/api/v1/workloads_test.go +++ b/pkg/api/v1/workloads_test.go @@ -279,11 +279,12 @@ func TestCreateWorkload(t *testing.T) { groupManager: mockGroupManager, debugMode: false, workloadService: &WorkloadService{ - groupManager: mockGroupManager, - workloadManager: mockWorkloadManager, - imageRetriever: mockRetriever, - imagePuller: func(_ context.Context, _ string) error { return nil }, - configProvider: config.NewDefaultProvider(), + groupManager: mockGroupManager, + workloadManager: mockWorkloadManager, + imageRetriever: mockRetriever, + imagePuller: func(_ context.Context, _ string) error { return nil }, + configProvider: config.NewDefaultProvider(), + imageVerification: retriever.VerifyImageWarn, }, } @@ -499,11 +500,12 @@ func TestUpdateWorkload(t *testing.T) { groupManager: mockGroupManager, debugMode: false, workloadService: &WorkloadService{ - groupManager: mockGroupManager, - workloadManager: mockWorkloadManager, - imageRetriever: mockRetriever, - imagePuller: func(_ context.Context, _ string) error { return nil }, - configProvider: config.NewDefaultProvider(), + groupManager: mockGroupManager, + workloadManager: mockWorkloadManager, + imageRetriever: mockRetriever, + imagePuller: func(_ context.Context, _ string) error { return nil }, + configProvider: config.NewDefaultProvider(), + imageVerification: retriever.VerifyImageWarn, }, } @@ -622,12 +624,13 @@ func TestUpdateWorkload_PortReuse(t *testing.T) { groupManager: mockGroupManager, debugMode: false, workloadService: &WorkloadService{ - groupManager: mockGroupManager, - workloadManager: mockWorkloadManager, - containerRuntime: mockRuntime, - imageRetriever: mockRetriever, - imagePuller: func(_ context.Context, _ string) error { return nil }, - configProvider: config.NewDefaultProvider(), + groupManager: mockGroupManager, + workloadManager: mockWorkloadManager, + containerRuntime: mockRuntime, + imageRetriever: mockRetriever, + imagePuller: func(_ context.Context, _ string) error { return nil }, + configProvider: config.NewDefaultProvider(), + imageVerification: retriever.VerifyImageWarn, }, } @@ -688,12 +691,13 @@ func TestUpdateWorkload_PortReuse(t *testing.T) { groupManager: mockGroupManager, debugMode: false, workloadService: &WorkloadService{ - groupManager: mockGroupManager, - workloadManager: mockWorkloadManager, - containerRuntime: mockRuntime, - imageRetriever: mockRetriever, - imagePuller: func(_ context.Context, _ string) error { return nil }, - configProvider: config.NewDefaultProvider(), + groupManager: mockGroupManager, + workloadManager: mockWorkloadManager, + containerRuntime: mockRuntime, + imageRetriever: mockRetriever, + imagePuller: func(_ context.Context, _ string) error { return nil }, + configProvider: config.NewDefaultProvider(), + imageVerification: retriever.VerifyImageWarn, }, } From e516be42b5fdbc5b6169c8745865de5600ded3a5 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio <ozz@stacklok.com> Date: Tue, 21 Apr 2026 14:33:39 +0300 Subject: [PATCH 108/117] Hide thv vmcp command from CLI help (#4967) The vmcp subcommand is not ready for release. Mark the parent command Hidden so it does not appear in help output or shell completion, while remaining functional for internal testing. Regenerated CLI docs drop the now-hidden pages. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- cmd/thv/app/vmcp.go | 1 + docs/cli/thv.md | 1 - docs/cli/thv_vmcp.md | 40 ------------------------- docs/cli/thv_vmcp_init.md | 50 ------------------------------- docs/cli/thv_vmcp_serve.md | 56 ----------------------------------- docs/cli/thv_vmcp_validate.md | 44 --------------------------- 6 files changed, 1 insertion(+), 191 deletions(-) delete mode 100644 docs/cli/thv_vmcp.md delete mode 100644 docs/cli/thv_vmcp_init.md delete mode 100644 docs/cli/thv_vmcp_serve.md delete mode 100644 docs/cli/thv_vmcp_validate.md diff --git a/cmd/thv/app/vmcp.go b/cmd/thv/app/vmcp.go index ef3cf72e81..15fb12e92f 100644 --- a/cmd/thv/app/vmcp.go +++ b/cmd/thv/app/vmcp.go @@ -20,6 +20,7 @@ func newVMCPCommand() *cobra.Command { Long: `The vmcp command provides subcommands to run and validate a Virtual MCP Server (vMCP) locally without Kubernetes. A vMCP aggregates multiple MCP servers from a ToolHive group into a single unified endpoint.`, + Hidden: true, } cmd.AddCommand(newVMCPServeCommand()) cmd.AddCommand(newVMCPValidateCommand()) diff --git a/docs/cli/thv.md b/docs/cli/thv.md index 21660ca8e3..9f6b4ac6db 100644 --- a/docs/cli/thv.md +++ b/docs/cli/thv.md @@ -57,5 +57,4 @@ thv [flags] * [thv status](thv_status.md) - Show detailed status of an MCP server * [thv stop](thv_stop.md) - Stop one or more MCP servers * [thv version](thv_version.md) - Show the version of ToolHive -* [thv vmcp](thv_vmcp.md) - Run and manage a Virtual MCP Server locally diff --git a/docs/cli/thv_vmcp.md b/docs/cli/thv_vmcp.md deleted file mode 100644 index d9d0ece7f4..0000000000 --- a/docs/cli/thv_vmcp.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: thv vmcp -hide_title: true -description: Reference for ToolHive CLI command `thv vmcp` -last_update: - author: autogenerated -slug: thv_vmcp -mdx: - format: md ---- - -## thv vmcp - -Run and manage a Virtual MCP Server locally - -### Synopsis - -The vmcp command provides subcommands to run and validate a Virtual MCP -Server (vMCP) locally without Kubernetes. A vMCP aggregates multiple MCP -servers from a ToolHive group into a single unified endpoint. - -### Options - -``` - -h, --help help for vmcp -``` - -### Options inherited from parent commands - -``` - --debug Enable debug mode -``` - -### SEE ALSO - -* [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers -* [thv vmcp init](thv_vmcp_init.md) - Generate a starter vMCP configuration file -* [thv vmcp serve](thv_vmcp_serve.md) - Start the Virtual MCP Server -* [thv vmcp validate](thv_vmcp_validate.md) - Validate a vMCP configuration file - diff --git a/docs/cli/thv_vmcp_init.md b/docs/cli/thv_vmcp_init.md deleted file mode 100644 index 5f5c420e9e..0000000000 --- a/docs/cli/thv_vmcp_init.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: thv vmcp init -hide_title: true -description: Reference for ToolHive CLI command `thv vmcp init` -last_update: - author: autogenerated -slug: thv_vmcp_init -mdx: - format: md ---- - -## thv vmcp init - -Generate a starter vMCP configuration file - -### Synopsis - -Discover running workloads in a ToolHive group and generate a starter -vMCP YAML configuration file pre-populated with one backend entry per -accessible workload. - -The generated file can be reviewed and customized, then passed to -'thv vmcp validate --config' to check it and 'thv vmcp serve --config' -to start the aggregated server. - -If neither --output nor --config is provided, the generated YAML is written to stdout. - -``` -thv vmcp init [flags] -``` - -### Options - -``` - -c, --config string Output file path for the generated config; alias for --output - -g, --group string ToolHive group name to discover workloads from (required) - -h, --help help for init - -o, --output string Output file path for the generated config (default: stdout) -``` - -### Options inherited from parent commands - -``` - --debug Enable debug mode -``` - -### SEE ALSO - -* [thv vmcp](thv_vmcp.md) - Run and manage a Virtual MCP Server locally - diff --git a/docs/cli/thv_vmcp_serve.md b/docs/cli/thv_vmcp_serve.md deleted file mode 100644 index f20718f6a4..0000000000 --- a/docs/cli/thv_vmcp_serve.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: thv vmcp serve -hide_title: true -description: Reference for ToolHive CLI command `thv vmcp serve` -last_update: - author: autogenerated -slug: thv_vmcp_serve -mdx: - format: md ---- - -## thv vmcp serve - -Start the Virtual MCP Server - -### Synopsis - -Start the Virtual MCP Server to aggregate and proxy multiple MCP servers. - -The server reads the configuration file specified by --config and starts -listening for MCP client connections, aggregating tools, resources, and -prompts from all configured backend MCP servers. - -When --config is omitted, --group enables zero-config quick mode: a minimal -in-memory configuration is generated from the named ToolHive group, so no -configuration file is needed for the common case of aggregating a local group. - -``` -thv vmcp serve [flags] -``` - -### Options - -``` - -c, --config string Path to vMCP configuration file - --embedding-image string TEI container image (Tier 2) (default "ghcr.io/huggingface/text-embeddings-inference:cpu-latest") - --embedding-model string HuggingFace model name for semantic search (Tier 2) (default "BAAI/bge-small-en-v1.5") - --enable-audit Enable audit logging with default configuration - --group string ToolHive group name (zero-config quick mode when --config is omitted) - -h, --help help for serve - --host string Host address to bind to (default "127.0.0.1") - --optimizer Enable FTS5 keyword optimizer (Tier 1): exposes find_tool and call_tool instead of all backend tools - --optimizer-embedding Enable managed TEI semantic optimizer (Tier 2); implies --optimizer - --port int Port to listen on (default 4483) -``` - -### Options inherited from parent commands - -``` - --debug Enable debug mode -``` - -### SEE ALSO - -* [thv vmcp](thv_vmcp.md) - Run and manage a Virtual MCP Server locally - diff --git a/docs/cli/thv_vmcp_validate.md b/docs/cli/thv_vmcp_validate.md deleted file mode 100644 index 0f88282be0..0000000000 --- a/docs/cli/thv_vmcp_validate.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: thv vmcp validate -hide_title: true -description: Reference for ToolHive CLI command `thv vmcp validate` -last_update: - author: autogenerated -slug: thv_vmcp_validate -mdx: - format: md ---- - -## thv vmcp validate - -Validate a vMCP configuration file - -### Synopsis - -Validate the vMCP configuration file for syntax and semantic errors. - -This command checks YAML syntax, required field presence, middleware -configuration correctness, and backend configuration validity. Exits 0 -for valid configurations, non-zero with a descriptive error otherwise. - -``` -thv vmcp validate [flags] -``` - -### Options - -``` - -c, --config string Path to vMCP configuration file (required) - -h, --help help for validate -``` - -### Options inherited from parent commands - -``` - --debug Enable debug mode -``` - -### SEE ALSO - -* [thv vmcp](thv_vmcp.md) - Run and manage a Virtual MCP Server locally - From ee39caff9af461177af138c0edc90b9f09b8e2e8 Mon Sep 17 00:00:00 2001 From: Stacklok Bot <140063061+stacklokbot@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:22:33 -0400 Subject: [PATCH 109/117] Release v0.22.0 (#4968) * Update VERSION for release Release-Triggered-By: JAORMX * Update Chart.yaml for release Release-Triggered-By: JAORMX * Update Chart.yaml for release Release-Triggered-By: JAORMX * Update Chart.yaml for release Release-Triggered-By: JAORMX * Update Chart.yaml for release Release-Triggered-By: JAORMX * Update values.yaml for release Release-Triggered-By: JAORMX * Update values.yaml for release Release-Triggered-By: JAORMX * Update values.yaml for release Release-Triggered-By: JAORMX * Update VERSION for release Release-Triggered-By: JAORMX * Update Chart.yaml for release Release-Triggered-By: JAORMX * Update README.md for release Release-Triggered-By: JAORMX * Update Chart.yaml for release Release-Triggered-By: JAORMX * Update README.md for release Release-Triggered-By: JAORMX * Update values.yaml for release Release-Triggered-By: JAORMX --- VERSION | 2 +- deploy/charts/operator-crds/Chart.yaml | 4 ++-- deploy/charts/operator-crds/README.md | 2 +- deploy/charts/operator/Chart.yaml | 4 ++-- deploy/charts/operator/README.md | 10 +++++----- deploy/charts/operator/values.yaml | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index 885415662f..2157409059 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.21.0 +0.22.0 diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index c670e4bcd0..5cf2fd9043 100644 --- a/deploy/charts/operator-crds/Chart.yaml +++ b/deploy/charts/operator-crds/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator-crds description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. type: application -version: 0.21.0 -appVersion: "v0.21.0" +version: 0.22.0 +appVersion: "v0.22.0" diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index 22358cc82a..91c7267233 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -1,6 +1,6 @@ # ToolHive Operator CRDs Helm Chart -![Version: 0.21.0](https://img.shields.io/badge/Version-0.21.0-informational?style=flat-square) +![Version: 0.22.0](https://img.shields.io/badge/Version-0.22.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. diff --git a/deploy/charts/operator/Chart.yaml b/deploy/charts/operator/Chart.yaml index ffff9b3260..0002b4dd6f 100644 --- a/deploy/charts/operator/Chart.yaml +++ b/deploy/charts/operator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator description: A Helm chart for deploying the ToolHive Operator into Kubernetes. type: application -version: 0.21.0 -appVersion: "v0.21.0" +version: 0.22.0 +appVersion: "v0.22.0" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index 5d155b1c33..d4ed74f634 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -1,6 +1,6 @@ # ToolHive Operator Helm Chart -![Version: 0.21.0](https://img.shields.io/badge/Version-0.21.0-informational?style=flat-square) +![Version: 0.22.0](https://img.shields.io/badge/Version-0.22.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for deploying the ToolHive Operator into Kubernetes. @@ -46,7 +46,7 @@ The command removes all the Kubernetes components associated with the chart and |-----|------|---------|-------------| | fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources | | nameOverride | string | `""` | Override the name of the chart | -| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"env":[],"features":{"experimental":false,"registry":true,"server":true,"virtualMCP":true},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.21.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.21.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.21.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | +| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"env":[],"features":{"experimental":false,"registry":true,"server":true,"virtualMCP":true},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.22.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.22.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.22.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | | operator.affinity | object | `{}` | Affinity settings for the operator pod | | operator.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | Configuration for horizontal pod autoscaling | | operator.autoscaling.enabled | bool | `false` | Enable autoscaling for the operator | @@ -62,7 +62,7 @@ The command removes all the Kubernetes components associated with the chart and | operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container | | operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container | | operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container | -| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.21.0"` | Container image for the operator | +| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.22.0"` | Container image for the operator | | operator.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy for the operator container | | operator.imagePullSecrets | list | `[]` | List of image pull secrets to use | | operator.leaderElectionRole | object | `{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]}` | Leader election role configuration | @@ -89,8 +89,8 @@ The command removes all the Kubernetes components associated with the chart and | operator.serviceAccount.labels | object | `{}` | Labels to add to the service account | | operator.serviceAccount.name | string | `"toolhive-operator"` | The name of the service account to use. If not set and create is true, a name is generated. | | operator.tolerations | list | `[]` | Tolerations for the operator pod | -| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.21.0"` | Image to use for Toolhive runners | -| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.21.0"` | Image to use for Virtual MCP Server (vMCP) deployments | +| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.22.0"` | Image to use for Toolhive runners | +| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.22.0"` | Image to use for Virtual MCP Server (vMCP) deployments | | operator.volumeMounts | list | `[]` | Additional volume mounts on the operator container | | operator.volumes | list | `[]` | Additional volumes to mount on the operator pod | | registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.1.1"}` | All values for the registry API deployment and associated resources | diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index 556fa9b3fc..cea477a4ef 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -26,15 +26,15 @@ operator: # -- List of image pull secrets to use imagePullSecrets: [] # -- Container image for the operator - image: ghcr.io/stacklok/toolhive/operator:v0.21.0 + image: ghcr.io/stacklok/toolhive/operator:v0.22.0 # -- Image pull policy for the operator container imagePullPolicy: IfNotPresent # -- Image to use for Toolhive runners - toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.21.0 + toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.22.0 # -- Image to use for Virtual MCP Server (vMCP) deployments - vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.21.0 + vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.22.0 # -- Host for the proxy deployed by the operator proxyHost: 0.0.0.0 From f3c9f241b46e34727e75be60e82de166770b231c Mon Sep 17 00:00:00 2001 From: Jakub Hrozek <jakub.hrozek@posteo.se> Date: Tue, 21 Apr 2026 14:42:53 +0100 Subject: [PATCH 110/117] Set MCP parent on resource entities for server-scoped policies (#4965) When serverName is configured on the Cedar authorizer, resource entities (Tool, Prompt, Resource) need an MCP parent so that server-scoped Cedar policies like `resource in MCP::"github"` evaluate correctly. Without this parent, those policies silently match nothing and deny all requests. Add serverName parameter to CreateEntitiesForRequest and build an MCP::"<serverName>" parent UID when non-empty. Thread a.serverName through all four authorize* methods in core.go. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> --- pkg/authz/authorizers/cedar/core.go | 16 +++- pkg/authz/authorizers/cedar/core_test.go | 62 ++++++++++++++ pkg/authz/authorizers/cedar/entity.go | 15 +++- pkg/authz/authorizers/cedar/entity_test.go | 94 +++++++++++++++++++++- 4 files changed, 181 insertions(+), 6 deletions(-) diff --git a/pkg/authz/authorizers/cedar/core.go b/pkg/authz/authorizers/cedar/core.go index 5194d9714b..1ca68310dd 100644 --- a/pkg/authz/authorizers/cedar/core.go +++ b/pkg/authz/authorizers/cedar/core.go @@ -567,7 +567,9 @@ func (a *Authorizer) authorizeToolCall( }) // Create Cedar entities - entities, err := a.entityFactory.CreateEntitiesForRequest(principal, action, resource, claimsMap, attributes, groups) + entities, err := a.entityFactory.CreateEntitiesForRequest( + principal, action, resource, claimsMap, attributes, groups, a.serverName, + ) if err != nil { return false, fmt.Errorf("failed to create Cedar entities: %w", err) } @@ -604,7 +606,9 @@ func (a *Authorizer) authorizePromptGet( }, attrsMap) // Create Cedar entities - entities, err := a.entityFactory.CreateEntitiesForRequest(principal, action, resource, claimsMap, attributes, groups) + entities, err := a.entityFactory.CreateEntitiesForRequest( + principal, action, resource, claimsMap, attributes, groups, a.serverName, + ) if err != nil { return false, fmt.Errorf("failed to create Cedar entities: %w", err) } @@ -644,7 +648,9 @@ func (a *Authorizer) authorizeResourceRead( }, attrsMap) // Create Cedar entities - entities, err := a.entityFactory.CreateEntitiesForRequest(principal, action, resource, claimsMap, attributes, groups) + entities, err := a.entityFactory.CreateEntitiesForRequest( + principal, action, resource, claimsMap, attributes, groups, a.serverName, + ) if err != nil { return false, fmt.Errorf("failed to create Cedar entities: %w", err) } @@ -682,7 +688,9 @@ func (a *Authorizer) authorizeFeatureList( }, attrsMap) // Create Cedar entities - entities, err := a.entityFactory.CreateEntitiesForRequest(principal, action, resource, claimsMap, attributes, groups) + entities, err := a.entityFactory.CreateEntitiesForRequest( + principal, action, resource, claimsMap, attributes, groups, a.serverName, + ) if err != nil { return false, fmt.Errorf("failed to create Cedar entities: %w", err) } diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index 446da73e45..de0fbbb825 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -1053,6 +1053,7 @@ func TestIsAuthorizedWithEntities(t *testing.T) { map[string]interface{}{"name": "Test User"}, map[string]interface{}{"name": "weather"}, nil, + "", ) require.NoError(t, err) @@ -1068,6 +1069,67 @@ func TestIsAuthorizedWithEntities(t *testing.T) { assert.True(t, authorized) } +// TestServerScopedPolicyWithMCPParent verifies end-to-end Cedar evaluation +// with a server-scoped policy. When the authorizer has a serverName, resource +// entities get an MCP parent and `resource in MCP::"<server>"` matches. +// When serverName is empty, the same policy denies because there is no parent. +func TestServerScopedPolicyWithMCPParent(t *testing.T) { + t.Parallel() + + policy := `permit( + principal, + action == Action::"call_tool", + resource in MCP::"test-server" + );` + + // The MCP entity must be present in the entity store for Cedar's `in` + // operator to traverse the parent chain. In production this comes from + // entities_json managed by the enterprise controller. + mcpEntity := `[{"uid":{"type":"MCP","id":"test-server"},"parents":[],"attrs":{}}]` + + tests := []struct { + name string + serverName string + wantAllow bool + }{ + { + name: "serverName_matches_policy_permits", + serverName: "test-server", + wantAllow: true, + }, + { + name: "empty_serverName_policy_denies", + serverName: "", + wantAllow: false, + }, + { + name: "wrong_serverName_policy_denies", + serverName: "other-server", + wantAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{policy}, + EntitiesJSON: mcpEntity, + }, tt.serverName) + require.NoError(t, err) + + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "testuser", Claims: map[string]interface{}{"sub": "testuser"}}} + ctx := auth.WithIdentity(context.Background(), identity) + + authorized, err := authorizer.AuthorizeWithJWTClaims(ctx, authorizers.MCPFeatureTool, authorizers.MCPOperationCall, "weather", nil) + assert.NoError(t, err) + assert.Equal(t, tt.wantAllow, authorized, + "serverName=%q: expected allow=%v", tt.serverName, tt.wantAllow) + }) + } +} + // TestParseUpstreamJWTClaims tests the parseUpstreamJWTClaims helper. func TestParseUpstreamJWTClaims(t *testing.T) { t.Parallel() diff --git a/pkg/authz/authorizers/cedar/entity.go b/pkg/authz/authorizers/cedar/entity.go index ee7307f1ee..2439b5d807 100644 --- a/pkg/authz/authorizers/cedar/entity.go +++ b/pkg/authz/authorizers/cedar/entity.go @@ -106,11 +106,17 @@ func (*EntityFactory) CreateResourceEntity( // Cedar's `in` operator works for group-based policies. Unlike the pre-refactor // code, no separate THVGroup entities are inserted into the entity map — those // must come from entities_json to preserve the role hierarchy. +// +// When serverName is non-empty, resource entities include an MCP parent UID so +// that server-scoped Cedar policies (e.g. `resource in MCP::"github"`) evaluate +// correctly via Cedar's `in` operator. When serverName is empty, resource +// entities have no parents, preserving backward compatibility. func (f *EntityFactory) CreateEntitiesForRequest( principal, action, resource string, claimsMap map[string]interface{}, attributes map[string]interface{}, groups []string, + serverName string, ) (cedar.EntityMap, error) { // Parse principal, action, and resource principalType, principalID, err := parseCedarEntityID(principal) @@ -149,8 +155,15 @@ func (f *EntityFactory) CreateEntitiesForRequest( actionUID, actionEntity := f.CreateActionEntity(actionType, actionID, nil) entities[actionUID] = actionEntity + // Build MCP parent for resource entity when serverName is set so that + // server-scoped policies (e.g. resource in MCP::"github") can match. + var resourceParents []cedar.EntityUID + if serverName != "" { + resourceParents = append(resourceParents, cedar.NewEntityUID("MCP", cedar.String(serverName))) + } + // Create resource entity - resourceUID, resourceEntity := f.CreateResourceEntity(resourceType, resourceID, attributes) + resourceUID, resourceEntity := f.CreateResourceEntity(resourceType, resourceID, attributes, resourceParents...) entities[resourceUID] = resourceEntity return entities, nil diff --git a/pkg/authz/authorizers/cedar/entity_test.go b/pkg/authz/authorizers/cedar/entity_test.go index c971627aa6..d67927b380 100644 --- a/pkg/authz/authorizers/cedar/entity_test.go +++ b/pkg/authz/authorizers/cedar/entity_test.go @@ -270,6 +270,7 @@ func TestCreateEntitiesForRequest_GroupsAsParents(t *testing.T) { map[string]interface{}{"sub": "user1"}, map[string]interface{}{"name": "weather"}, tt.groups, + "", ) require.NoError(t, err) require.NotNil(t, entities) @@ -387,7 +388,7 @@ func TestCreateCedarEntities(t *testing.T) { factory := NewEntityFactory() // Create Cedar entities (no groups for these test cases) - entities, err := factory.CreateEntitiesForRequest(tc.principal, tc.action, tc.resource, tc.claimsMap, tc.attributes, nil) + entities, err := factory.CreateEntitiesForRequest(tc.principal, tc.action, tc.resource, tc.claimsMap, tc.attributes, nil, "") // Check error expectations if tc.expectErr { @@ -463,3 +464,94 @@ func TestCreateCedarEntities(t *testing.T) { }) } } + +// TestCreateEntitiesForRequest_MCPParent verifies that CreateEntitiesForRequest +// sets an MCP parent UID on the resource entity when serverName is non-empty, +// and leaves the resource parentless when serverName is empty. +func TestCreateEntitiesForRequest_MCPParent(t *testing.T) { + t.Parallel() + + factory := NewEntityFactory() + + tests := []struct { + name string + resource string + serverName string + wantParentCount int + wantMCPParentID string + }{ + { + name: "empty_serverName_no_parent", + resource: "Tool::weather", + serverName: "", + wantParentCount: 0, + }, + { + name: "serverName_sets_MCP_parent_on_Tool", + resource: "Tool::weather", + serverName: "github", + wantParentCount: 1, + wantMCPParentID: "github", + }, + { + name: "serverName_sets_MCP_parent_on_Prompt", + resource: "Prompt::greeting", + serverName: "github", + wantParentCount: 1, + wantMCPParentID: "github", + }, + { + name: "serverName_sets_MCP_parent_on_Resource", + resource: "Resource::readme", + serverName: "github", + wantParentCount: 1, + wantMCPParentID: "github", + }, + { + name: "serverName_with_special_characters", + resource: "Tool::weather", + serverName: "my-server.example.com", + wantParentCount: 1, + wantMCPParentID: "my-server.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + entities, err := factory.CreateEntitiesForRequest( + "Client::user1", + "Action::call_tool", + tt.resource, + map[string]interface{}{"sub": "user1"}, + map[string]interface{}{}, + nil, + tt.serverName, + ) + require.NoError(t, err) + require.NotNil(t, entities) + + // Find the resource entity by scanning for a non-Client, non-Action UID. + var resourceEntity cedar.Entity + var found bool + for uid, ent := range entities { + if string(uid.Type) != "Client" && string(uid.Type) != "Action" { + resourceEntity = ent + found = true + break + } + } + require.True(t, found, "resource entity not found in map") + + assert.Equal(t, tt.wantParentCount, resourceEntity.Parents.Len(), + "unexpected number of parents on resource entity") + + if tt.wantMCPParentID != "" { + mcpUID := cedar.NewEntityUID("MCP", cedar.String(tt.wantMCPParentID)) + assert.True(t, resourceEntity.Parents.Contains(mcpUID), + "expected MCP::%q in resource.Parents", tt.wantMCPParentID) + } + }) + } +} From 117be0406a99c0e9d31574b4b40a761822b9a37f Mon Sep 17 00:00:00 2001 From: Trey <tgrunnagle@gmail.com> Date: Tue, 21 Apr 2026 07:25:47 -0700 Subject: [PATCH 111/117] Default redirectUri on upstream providers as documented in CRD (#4905) * Default redirectUri on upstream providers to {resourceUrl}/oauth/callback The MCPExternalAuthConfig CRD documents that redirectUri defaults to {resourceUrl}/oauth/callback when not specified, but the operator was passing the empty string through to the proxy runner, which then rejected it with "redirect_uri is required". Pass resourceURL through BuildAuthServerRunConfig to buildUpstreamRunConfig so empty RedirectURI fields on both OIDC and OAuth2 upstream providers are resolved to the documented default before generating the runconfig. This applies to both MCPServer and VirtualMCPServer code paths. Fixes #4874 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add unit test for deriveResourceURL in converter_test.go Address code review feedback: add TestDeriveResourceURL table-driven test matching the convention established by TestDeriveAllowedAudiences and TestDeriveScopesSupported. Covers nil IncomingAuth, nil OIDC, empty Resource, and populated Resource cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../pkg/controllerutil/authserver.go | 35 ++++- .../pkg/controllerutil/authserver_test.go | 143 +++++++++++++++++- cmd/thv-operator/pkg/vmcpconfig/converter.go | 11 ++ .../pkg/vmcpconfig/converter_test.go | 54 +++++++ 4 files changed, 233 insertions(+), 10 deletions(-) diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go index d1f204d5a0..a8e86fb412 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver.go @@ -403,6 +403,7 @@ func AddEmbeddedAuthServerConfigOptions( embeddedConfig, err := BuildAuthServerRunConfig( namespace, mcpServerName, authServerConfig, []string{oidcConfig.ResourceURL}, oidcConfig.Scopes, + oidcConfig.ResourceURL, ) if err != nil { return fmt.Errorf("failed to build embedded auth server config: %w", err) @@ -453,15 +454,19 @@ func validateOIDCConfigForEmbeddedAuthServer(oidcConfig *oidc.OIDCConfig) error // BuildAuthServerRunConfig converts CRD EmbeddedAuthServerConfig to authserver.RunConfig. // The RunConfig is serializable and contains file paths for secrets (not the secrets themselves). // -// AllowedAudiences and ScopesSupported are caller-provided because different controllers -// derive them from different sources (MCPServer uses oidcConfig.ResourceURL/Scopes; +// AllowedAudiences, ScopesSupported, and resourceURL are caller-provided because different +// controllers derive them from different sources (MCPServer uses oidcConfig.ResourceURL/Scopes; // VirtualMCPServer derives from the resolved vmcp Config). +// +// resourceURL is used to default the RedirectURI on upstream providers when not explicitly set. +// The default is {resourceURL}/oauth/callback as documented in the MCPExternalAuthConfig CRD. func BuildAuthServerRunConfig( namespace string, name string, authConfig *mcpv1alpha1.EmbeddedAuthServerConfig, allowedAudiences []string, scopesSupported []string, + resourceURL string, ) (*authserver.RunConfig, error) { config := &authserver.RunConfig{ SchemaVersion: authserver.CurrentSchemaVersion, @@ -506,7 +511,7 @@ func BuildAuthServerRunConfig( bindings := buildUpstreamSecretBindings(authConfig.UpstreamProviders) config.Upstreams = make([]authserver.UpstreamRunConfig, 0, len(bindings)) for _, b := range bindings { - config.Upstreams = append(config.Upstreams, *buildUpstreamRunConfig(b.Provider, b.EnvVarName)) + config.Upstreams = append(config.Upstreams, *buildUpstreamRunConfig(b.Provider, b.EnvVarName, resourceURL)) } // Build storage configuration @@ -632,12 +637,21 @@ func resolveSentinelAddrs( return []string{dnsName}, nil } +// defaultRedirectURI returns the default redirect URI for an upstream provider +// when one is not explicitly configured. The default is {resourceURL}/oauth/callback +// as documented in the MCPExternalAuthConfig CRD. +func defaultRedirectURI(resourceURL string) string { + return strings.TrimRight(resourceURL, "/") + "/oauth/callback" +} + // buildUpstreamRunConfig converts CRD UpstreamProviderConfig to authserver.UpstreamRunConfig. // The envVarName is computed by buildUpstreamSecretBindings to keep Pod env -// and runtime config in sync. +// and runtime config in sync. When a provider's RedirectURI is empty, it is +// defaulted to {resourceURL}/oauth/callback. func buildUpstreamRunConfig( provider *mcpv1alpha1.UpstreamProviderConfig, envVarName string, + resourceURL string, ) *authserver.UpstreamRunConfig { config := &authserver.UpstreamRunConfig{ Name: provider.Name, @@ -647,10 +661,14 @@ func buildUpstreamRunConfig( switch provider.Type { case mcpv1alpha1.UpstreamProviderTypeOIDC: if provider.OIDCConfig != nil { + redirectURI := provider.OIDCConfig.RedirectURI + if redirectURI == "" && resourceURL != "" { + redirectURI = defaultRedirectURI(resourceURL) + } config.OIDCConfig = &authserver.OIDCUpstreamRunConfig{ IssuerURL: provider.OIDCConfig.IssuerURL, ClientID: provider.OIDCConfig.ClientID, - RedirectURI: provider.OIDCConfig.RedirectURI, + RedirectURI: redirectURI, Scopes: provider.OIDCConfig.Scopes, AdditionalAuthorizationParams: provider.OIDCConfig.AdditionalAuthorizationParams, } @@ -664,11 +682,15 @@ func buildUpstreamRunConfig( } case mcpv1alpha1.UpstreamProviderTypeOAuth2: if provider.OAuth2Config != nil { + redirectURI := provider.OAuth2Config.RedirectURI + if redirectURI == "" && resourceURL != "" { + redirectURI = defaultRedirectURI(resourceURL) + } config.OAuth2Config = &authserver.OAuth2UpstreamRunConfig{ AuthorizationEndpoint: provider.OAuth2Config.AuthorizationEndpoint, TokenEndpoint: provider.OAuth2Config.TokenEndpoint, ClientID: provider.OAuth2Config.ClientID, - RedirectURI: provider.OAuth2Config.RedirectURI, + RedirectURI: redirectURI, Scopes: provider.OAuth2Config.Scopes, AdditionalAuthorizationParams: provider.OAuth2Config.AdditionalAuthorizationParams, } @@ -799,6 +821,7 @@ func AddAuthServerRefOptions( embeddedConfig, err := BuildAuthServerRunConfig( namespace, mcpServerName, authServerConfig, []string{oidcConfig.ResourceURL}, oidcConfig.Scopes, + oidcConfig.ResourceURL, ) if err != nil { return fmt.Errorf("failed to build embedded auth server config: %w", err) diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_test.go index f56eb1bd03..310be8ff1e 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go @@ -690,11 +690,14 @@ func TestBuildAuthServerRunConfig(t *testing.T) { defaultAudiences := []string{"http://test-server.default.svc.cluster.local:8080"} defaultScopes := []string{"openid", "offline_access"} + defaultResourceURL := "http://test-server.default.svc.cluster.local:8080" + tests := []struct { name string authConfig *mcpv1alpha1.EmbeddedAuthServerConfig allowedAudiences []string scopesSupported []string + resourceURL string checkFunc func(t *testing.T, config *authserver.RunConfig) }{ { @@ -774,7 +777,8 @@ func TestBuildAuthServerRunConfig(t *testing.T) { }, }, { - name: "with OIDC upstream provider", + name: "with OIDC upstream provider", + resourceURL: defaultResourceURL, authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ @@ -811,7 +815,8 @@ func TestBuildAuthServerRunConfig(t *testing.T) { }, }, { - name: "with OAuth2 upstream provider with userinfo config", + name: "with OAuth2 upstream provider with userinfo config", + resourceURL: defaultResourceURL, authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ @@ -904,7 +909,8 @@ func TestBuildAuthServerRunConfig(t *testing.T) { }, }, { - name: "with multiple upstream providers all are included", + name: "with multiple upstream providers all are included", + resourceURL: defaultResourceURL, authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ @@ -1041,13 +1047,141 @@ func TestBuildAuthServerRunConfig(t *testing.T) { upstream.OAuth2Config.AdditionalAuthorizationParams) }, }, + { + name: "OIDC upstream with empty redirectUri defaults to resourceURL/oauth/callback", + resourceURL: "https://mcp.example.com", + authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + { + Name: "okta", + Type: mcpv1alpha1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + IssuerURL: "https://okta.example.com", + ClientID: "client-id", + // RedirectURI intentionally omitted + }, + }, + }, + }, + allowedAudiences: defaultAudiences, + scopesSupported: defaultScopes, + checkFunc: func(t *testing.T, config *authserver.RunConfig) { + t.Helper() + require.Len(t, config.Upstreams, 1) + require.NotNil(t, config.Upstreams[0].OIDCConfig) + assert.Equal(t, "https://mcp.example.com/oauth/callback", config.Upstreams[0].OIDCConfig.RedirectURI) + }, + }, + { + name: "OAuth2 upstream with empty redirectUri defaults to resourceURL/oauth/callback", + resourceURL: "https://mcp.example.com", + authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + { + Name: "github", + Type: mcpv1alpha1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1alpha1.OAuth2UpstreamConfig{ + AuthorizationEndpoint: "https://github.com/login/oauth/authorize", + TokenEndpoint: "https://github.com/login/oauth/access_token", + ClientID: "client-id", + // RedirectURI intentionally omitted + }, + }, + }, + }, + allowedAudiences: defaultAudiences, + scopesSupported: defaultScopes, + checkFunc: func(t *testing.T, config *authserver.RunConfig) { + t.Helper() + require.Len(t, config.Upstreams, 1) + require.NotNil(t, config.Upstreams[0].OAuth2Config) + assert.Equal(t, "https://mcp.example.com/oauth/callback", config.Upstreams[0].OAuth2Config.RedirectURI) + }, + }, + { + name: "explicit redirectUri is preserved when resourceURL is also set", + resourceURL: "https://mcp.example.com", + authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + { + Name: "okta", + Type: mcpv1alpha1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + IssuerURL: "https://okta.example.com", + ClientID: "client-id", + RedirectURI: "https://custom.example.com/callback", + }, + }, + }, + }, + allowedAudiences: defaultAudiences, + scopesSupported: defaultScopes, + checkFunc: func(t *testing.T, config *authserver.RunConfig) { + t.Helper() + require.Len(t, config.Upstreams, 1) + require.NotNil(t, config.Upstreams[0].OIDCConfig) + assert.Equal(t, "https://custom.example.com/callback", config.Upstreams[0].OIDCConfig.RedirectURI) + }, + }, + { + name: "resourceURL with trailing slash produces correct default redirectUri", + resourceURL: "https://mcp.example.com/", + authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + { + Name: "okta", + Type: mcpv1alpha1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + IssuerURL: "https://okta.example.com", + ClientID: "client-id", + }, + }, + }, + }, + allowedAudiences: defaultAudiences, + scopesSupported: defaultScopes, + checkFunc: func(t *testing.T, config *authserver.RunConfig) { + t.Helper() + require.Len(t, config.Upstreams, 1) + require.NotNil(t, config.Upstreams[0].OIDCConfig) + assert.Equal(t, "https://mcp.example.com/oauth/callback", config.Upstreams[0].OIDCConfig.RedirectURI) + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - config, err := BuildAuthServerRunConfig("default", "test-server", tt.authConfig, tt.allowedAudiences, tt.scopesSupported) + config, err := BuildAuthServerRunConfig("default", "test-server", tt.authConfig, tt.allowedAudiences, tt.scopesSupported, tt.resourceURL) require.NoError(t, err) require.NotNil(t, config) @@ -1634,6 +1768,7 @@ func TestBuildAuthServerRunConfig_WithRedisStorage(t *testing.T) { "default", "my-mcp-server", authConfig, []string{"http://test-server.default.svc.cluster.local:8080"}, []string{"openid"}, + "http://test-server.default.svc.cluster.local:8080", ) require.NoError(t, err) diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter.go b/cmd/thv-operator/pkg/vmcpconfig/converter.go index be35dd1d5f..30e94fbdbf 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go @@ -318,6 +318,7 @@ func (*Converter) convertAuthServerConfig( vmcp.Spec.AuthServerConfig, deriveAllowedAudiences(config), deriveScopesSupported(config), + deriveResourceURL(config), ) } @@ -344,6 +345,16 @@ func deriveAllowedAudiences(config *vmcpconfig.Config) []string { return []string{resource} } +// deriveResourceURL returns the resource URL from the resolved incoming OIDC config. +// Returns empty string when OIDC is not configured or Resource is empty. +// Used to default upstream provider RedirectURIs to {resourceURL}/oauth/callback. +func deriveResourceURL(config *vmcpconfig.Config) string { + if config.IncomingAuth == nil || config.IncomingAuth.OIDC == nil { + return "" + } + return config.IncomingAuth.OIDC.Resource +} + // deriveScopesSupported returns the scopes from the resolved incoming OIDC config. // Returns nil when OIDC is not configured or scopes are empty, which causes the // auth server to use its default scopes (["openid", "profile", "email", "offline_access"]). diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go index aad297da44..8bf59a912c 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go @@ -1710,6 +1710,60 @@ func TestDeriveScopesSupported(t *testing.T) { } } +func TestDeriveResourceURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *vmcpconfig.Config + expected string + }{ + { + name: "nil IncomingAuth returns empty", + config: &vmcpconfig.Config{}, + expected: "", + }, + { + name: "nil OIDC returns empty", + config: &vmcpconfig.Config{ + IncomingAuth: &vmcpconfig.IncomingAuthConfig{Type: "oidc"}, + }, + expected: "", + }, + { + name: "empty Resource returns empty", + config: &vmcpconfig.Config{ + IncomingAuth: &vmcpconfig.IncomingAuthConfig{ + Type: "oidc", + OIDC: &vmcpconfig.OIDCConfig{}, + }, + }, + expected: "", + }, + { + name: "populated Resource is returned", + config: &vmcpconfig.Config{ + IncomingAuth: &vmcpconfig.IncomingAuthConfig{ + Type: "oidc", + OIDC: &vmcpconfig.OIDCConfig{ + Resource: "https://resource.example.com", + }, + }, + }, + expected: "https://resource.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := deriveResourceURL(tt.config) + assert.Equal(t, tt.expected, result) + }) + } +} + // TestConvert_AuthServerConfigIntegration is an integration-level test that exercises the // full Convert() path with an AuthServerConfig set on the VirtualMCPServer. It verifies that // the returned RunConfig has the correct Issuer, Upstreams, and AllowedAudiences derived From 3dbbf5ae696c0f61796a5128811996037099f5cb Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:54:49 +0100 Subject: [PATCH 112/117] Graduate CRDs from v1alpha1 to v1beta1 (#4849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Graduate CRDs from v1alpha1 to v1beta1 Signal API stability by promoting all ToolHive CRDs to v1beta1 while preserving backwards compatibility — the CRDs serve both versions simultaneously so existing v1alpha1 resources survive the upgrade untouched and users migrate manifests at their own pace. v1alpha1 and v1beta1 are schema-identical. The new cmd/thv-operator/api/v1alpha1/ package defines only the root resource types (MCPServer, MCPGroup, ...) as thin wrappers whose Spec and Status fields reference the canonical types from v1beta1. controller-gen walks field types when building the OpenAPI schema, so both versions resolve to the same schema without any duplication of the actual model — I verified empirically that all 12 CRDs produce structurally identical schemas (modulo the legitimately-different root descriptions). The upgrade is orchestrated through the `served` and `storage` flags on each version entry: the new CRDs list both versions as served, mark v1beta1 as storage, and mark v1alpha1 as deprecated with a warning. No conversion webhook is configured, which defaults to strategy: None — safe because the schemas are identical. Kubernetes swaps the apiVersion string when serving an object at a different version than it was stored at. Users can upgrade the CRD chart without deleting anything, keep reading/writing at v1alpha1 indefinitely, and migrate to v1beta1 by re-applying manifests. Once status.storedVersions no longer contains v1alpha1, a future release can drop the v1alpha1 entry and remove the wrapper package. What changed: - Mechanical rename from v1alpha1 to v1beta1 across all Go imports, YAML apiVersion strings, Helm deploy manifests, chainsaw fixtures, examples, docs, and generated artifacts. - New cmd/thv-operator/api/v1alpha1/ package: 12 root types + 12 list types as thin wrappers over v1beta1 Spec/Status, plus scheme registration. Each root type carries a +kubebuilder:deprecatedversion warning so kubectl prints a migration hint on every access. - Register both schemes in cmd/thv-operator/main.go. - Regenerated CRD manifests list both versions (v1alpha1 deprecated and non-storage, v1beta1 storage) with conversion.strategy: None. - Removed the obsolete pre-upgrade Helm hook that blocked upgrades when v1alpha1 resources existed — no longer needed since those resources now survive the upgrade. - Drive-by: regenerate pkg/audit/zz_generated.deepcopy.go which had drifted after DetectApplicationErrors was added to Config without re-running controller-gen. Closes #2556 * Update test files to use v1beta1 after merge Two test files that came in via the merge from main still reference the v1alpha1 Go package, which no longer exists on this branch. The operator binaries compile fine, but CI lint/test fail on the missing mcpv1alpha1 symbol in the test package. - cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go - cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go Applied the same mcpv1alpha1 → mcpv1beta1 rename that the rest of the branch uses. No behavioural change. --- .claude/skills/implement-story/SKILL.md | 2 +- PROJECT | 4 +- cmd/thv-operator/README.md | 10 +- cmd/thv-operator/REGISTRY.md | 12 +- cmd/thv-operator/api/v1alpha1/doc.go | 19 + .../api/v1alpha1/groupversion_info.go | 7 +- cmd/thv-operator/api/v1alpha1/types.go | 385 +++ .../api/v1alpha1/zz_generated.deepcopy.go | 2967 ++-------------- .../api/{v1alpha1 => v1beta1}/conditions.go | 2 +- .../embeddingserver_types.go | 3 +- .../api/v1beta1/groupversion_info.go | 23 + .../mcpexternalauthconfig_types.go | 3 +- .../mcpexternalauthconfig_types_test.go | 2 +- .../{v1alpha1 => v1beta1}/mcpgroup_types.go | 3 +- .../mcpoidcconfig_types.go | 3 +- .../mcpregistry_parse_test.go | 2 +- .../mcpregistry_types.go | 3 +- .../mcpremoteproxy_types.go | 3 +- .../{v1alpha1 => v1beta1}/mcpserver_types.go | 3 +- .../mcpserver_types_test.go | 2 +- .../mcpserverentry_types.go | 3 +- .../mcptelemetryconfig_types.go | 3 +- .../mcptelemetryconfig_types_test.go | 2 +- .../{v1alpha1 => v1beta1}/toolconfig_types.go | 3 +- ...virtualmcpcompositetooldefinition_types.go | 3 +- .../virtualmcpserver_types.go | 3 +- .../virtualmcpserver_types_test.go | 2 +- .../api/v1beta1/zz_generated.deepcopy.go | 3070 +++++++++++++++++ .../config/webhook/manifests.yaml | 12 +- .../controllers/embeddingserver_controller.go | 76 +- .../embeddingserver_controller_test.go | 84 +- cmd/thv-operator/controllers/helpers_test.go | 4 +- .../mcpexternalauthconfig_controller.go | 76 +- .../mcpexternalauthconfig_controller_test.go | 388 +-- .../controllers/mcpgroup_controller.go | 84 +- .../controllers/mcpgroup_controller_test.go | 472 +-- .../controllers/mcpoidcconfig_controller.go | 58 +- .../mcpoidcconfig_controller_test.go | 156 +- .../controllers/mcpregistry_controller.go | 52 +- .../mcpregistry_controller_test.go | 138 +- .../mcpremoteproxy_authserverref_test.go | 114 +- .../controllers/mcpremoteproxy_controller.go | 288 +- .../mcpremoteproxy_controller_test.go | 270 +- .../controllers/mcpremoteproxy_deployment.go | 28 +- .../mcpremoteproxy_deployment_test.go | 242 +- .../mcpremoteproxy_reconciler_test.go | 226 +- .../controllers/mcpremoteproxy_runconfig.go | 14 +- .../mcpremoteproxy_runconfig_test.go | 162 +- .../mcpremoteproxy_telemetryconfig_test.go | 130 +- .../mcpserver_authserverref_test.go | 114 +- .../controllers/mcpserver_authz_test.go | 80 +- .../controllers/mcpserver_controller.go | 284 +- .../mcpserver_externalauth_runconfig_test.go | 360 +- .../mcpserver_externalauth_test.go | 168 +- .../controllers/mcpserver_groupref_test.go | 130 +- ...rver_invalid_podtemplate_reconcile_test.go | 32 +- .../controllers/mcpserver_oidcconfig_test.go | 202 +- .../controllers/mcpserver_platform_test.go | 20 +- .../mcpserver_pod_template_test.go | 50 +- .../mcpserver_podtemplatespec_builder_test.go | 30 +- .../controllers/mcpserver_rbac_test.go | 20 +- .../controllers/mcpserver_replicas_test.go | 186 +- .../mcpserver_resource_overrides_test.go | 202 +- .../controllers/mcpserver_restart_test.go | 10 +- .../controllers/mcpserver_runconfig.go | 18 +- .../controllers/mcpserver_runconfig_test.go | 236 +- .../mcpserver_telemetry_cabundle_test.go | 48 +- .../controllers/mcpserver_telemetryconfig.go | 32 +- .../mcpserver_telemetryconfig_test.go | 36 +- .../controllers/mcpserverentry_controller.go | 100 +- .../mcpserverentry_controller_test.go | 192 +- .../mcptelemetryconfig_controller.go | 56 +- .../mcptelemetryconfig_controller_test.go | 168 +- .../controllers/toolconfig_controller.go | 42 +- .../toolconfig_controller_edge_cases_test.go | 86 +- .../controllers/toolconfig_controller_test.go | 136 +- .../virtualmcpserver_controller.go | 338 +- .../virtualmcpserver_controller_test.go | 612 ++-- .../virtualmcpserver_deployment.go | 70 +- .../virtualmcpserver_deployment_test.go | 140 +- .../controllers/virtualmcpserver_embedding.go | 16 +- .../virtualmcpserver_externalauth_test.go | 492 +-- ...cpserver_podtemplatespec_reconcile_test.go | 50 +- .../virtualmcpserver_telemetryconfig.go | 20 +- .../virtualmcpserver_telemetryconfig_test.go | 100 +- .../virtualmcpserver_vmcpconfig.go | 20 +- .../virtualmcpserver_vmcpconfig_test.go | 390 +-- .../virtualmcpserver_watch_test.go | 610 ++-- cmd/thv-operator/main.go | 14 +- .../pkg/controllerutil/authserver.go | 60 +- .../pkg/controllerutil/authserver_test.go | 624 ++-- cmd/thv-operator/pkg/controllerutil/authz.go | 20 +- .../pkg/controllerutil/authz_test.go | 138 +- cmd/thv-operator/pkg/controllerutil/config.go | 64 +- .../pkg/controllerutil/config_test.go | 180 +- cmd/thv-operator/pkg/controllerutil/oidc.go | 10 +- .../pkg/controllerutil/oidc_test.go | 12 +- .../pkg/controllerutil/oidc_volumes.go | 6 +- .../controllerutil/podtemplatespec_builder.go | 4 +- .../podtemplatespec_builder_test.go | 14 +- .../pkg/controllerutil/resources.go | 4 +- .../pkg/controllerutil/telemetry.go | 6 +- .../pkg/controllerutil/telemetry_test.go | 38 +- .../pkg/controllerutil/telemetry_volumes.go | 6 +- .../controllerutil/telemetry_volumes_test.go | 74 +- .../pkg/controllerutil/tokenexchange.go | 38 +- .../pkg/controllerutil/tools_config.go | 8 +- .../pkg/controllerutil/tools_config_test.go | 44 +- .../pkg/oidc/mocks/mock_resolver.go | 4 +- cmd/thv-operator/pkg/oidc/resolver.go | 26 +- .../pkg/oidc/resolver_configref_test.go | 106 +- .../pkg/registryapi/deployment.go | 8 +- .../pkg/registryapi/deployment_test.go | 18 +- cmd/thv-operator/pkg/registryapi/manager.go | 12 +- .../pkg/registryapi/manager_test.go | 14 +- .../pkg/registryapi/mocks/mock_manager.go | 10 +- cmd/thv-operator/pkg/registryapi/rbac.go | 4 +- cmd/thv-operator/pkg/registryapi/rbac_test.go | 18 +- cmd/thv-operator/pkg/registryapi/service.go | 6 +- .../pkg/registryapi/service_test.go | 6 +- cmd/thv-operator/pkg/registryapi/types.go | 12 +- .../pkg/registryapi/types_test.go | 10 +- cmd/thv-operator/pkg/runconfig/audit.go | 4 +- cmd/thv-operator/pkg/runconfig/audit_test.go | 12 +- cmd/thv-operator/pkg/runconfig/telemetry.go | 4 +- .../pkg/runconfig/telemetry_test.go | 24 +- .../pkg/spectoconfig/telemetry.go | 4 +- .../pkg/spectoconfig/telemetry_test.go | 48 +- .../pkg/validation/oidc_validation.go | 4 +- .../pkg/validation/oidc_validation_test.go | 10 +- .../pkg/virtualmcpserverstatus/collector.go | 34 +- .../virtualmcpserverstatus/collector_test.go | 94 +- .../mocks/mock_collector.go | 8 +- .../pkg/virtualmcpserverstatus/types.go | 8 +- cmd/thv-operator/pkg/vmcpconfig/converter.go | 64 +- .../pkg/vmcpconfig/converter_test.go | 326 +- .../embeddingserver_creation_test.go | 156 +- .../embeddingserver_update_test.go | 104 +- .../embedding-server/suite_test.go | 4 +- ...lauthconfig_controller_integration_test.go | 70 +- .../mcp-external-auth/suite_test.go | 4 +- .../mcpgroup_controller_integration_test.go | 208 +- .../test-integration/mcp-group/suite_test.go | 16 +- ...poidcconfig_controller_integration_test.go | 42 +- ...cconfig_mcpremoteproxy_integration_test.go | 152 +- ...cpoidcconfig_mcpserver_integration_test.go | 104 +- ...onfig_virtualmcpserver_integration_test.go | 182 +- .../mcp-oidc-config/suite_test.go | 16 +- .../mcp-registry/registry_helpers.go | 40 +- .../mcp-registry/registry_lifecycle_test.go | 16 +- .../mcp-registry/registry_server_rbac_test.go | 8 +- .../registryserver_config_test.go | 48 +- .../mcp-registry/status_helpers.go | 16 +- .../mcp-registry/suite_test.go | 6 +- .../mcp-remote-proxy/k8s_helpers.go | 6 +- ...oteproxy_authserverref_integration_test.go | 64 +- ...remoteproxy_controller_integration_test.go | 144 +- ...remoteproxy_validation_integration_test.go | 32 +- .../mcp-remote-proxy/remoteproxy_helpers.go | 50 +- .../mcp-remote-proxy/status_helpers.go | 12 +- .../mcp-remote-proxy/suite_test.go | 16 +- ...cpserver_authserverref_integration_test.go | 154 +- ...pserver_cel_validation_integration_test.go | 36 +- .../mcpserver_controller_integration_test.go | 136 +- .../mcpserver_runconfig_integration_test.go | 96 +- .../mcpserver_sessionstorage_cel_test.go | 20 +- .../test-integration/mcp-server/suite_test.go | 16 +- ...metryconfig_controller_integration_test.go | 108 +- .../mcp-telemetry-config/suite_test.go | 4 +- ...ptoolconfig_controller_integration_test.go | 74 +- .../mcp-toolconfig/suite_test.go | 4 +- .../test-integration/virtualmcp/suite_test.go | 16 +- ...rtualmcpserver_compositetool_watch_test.go | 118 +- ...lmcpserver_elicitation_integration_test.go | 158 +- ...irtualmcpserver_externalauth_watch_test.go | 58 +- ...server_podtemplatespec_integration_test.go | 60 +- ...tualmcpserver_replicas_integration_test.go | 38 +- ...irtualmcpserver_sessionstorage_cel_test.go | 22 +- ...server_telemetryconfig_integration_test.go | 98 +- cmd/vmcp/README.md | 2 +- config/webhook/manifests.yaml | 12 +- ...oolhive.stacklok.dev_embeddingservers.yaml | 346 ++ ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 1018 ++++++ .../crds/toolhive.stacklok.dev_mcpgroups.yaml | 156 + .../toolhive.stacklok.dev_mcpoidcconfigs.yaml | 295 ++ .../toolhive.stacklok.dev_mcpregistries.yaml | 239 ++ ...oolhive.stacklok.dev_mcpremoteproxies.yaml | 567 +++ ...oolhive.stacklok.dev_mcpserverentries.yaml | 254 ++ .../toolhive.stacklok.dev_mcpservers.yaml | 852 +++++ ...hive.stacklok.dev_mcptelemetryconfigs.yaml | 300 ++ .../toolhive.stacklok.dev_mcptoolconfigs.yaml | 202 ++ ...ev_virtualmcpcompositetooldefinitions.yaml | 407 +++ ...olhive.stacklok.dev_virtualmcpservers.yaml | 2288 ++++++++++++ ...oolhive.stacklok.dev_embeddingservers.yaml | 346 ++ ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 1018 ++++++ .../toolhive.stacklok.dev_mcpgroups.yaml | 156 + .../toolhive.stacklok.dev_mcpoidcconfigs.yaml | 295 ++ .../toolhive.stacklok.dev_mcpregistries.yaml | 239 ++ ...oolhive.stacklok.dev_mcpremoteproxies.yaml | 567 +++ ...oolhive.stacklok.dev_mcpserverentries.yaml | 254 ++ .../toolhive.stacklok.dev_mcpservers.yaml | 852 +++++ ...hive.stacklok.dev_mcptelemetryconfigs.yaml | 300 ++ .../toolhive.stacklok.dev_mcptoolconfigs.yaml | 202 ++ ...ev_virtualmcpcompositetooldefinitions.yaml | 407 +++ ...olhive.stacklok.dev_virtualmcpservers.yaml | 2288 ++++++++++++ deploy/keycloak/mcpserver-with-auth.yaml | 4 +- docs/arch/02-core-concepts.md | 4 +- docs/arch/03-transport-architecture.md | 2 +- docs/arch/04-secrets-management.md | 6 +- docs/arch/06-registry-system.md | 4 +- docs/arch/09-operator-architecture.md | 28 +- docs/arch/11-auth-server-storage.md | 2 +- docs/observability.md | 6 +- docs/operator/advanced-workflow-patterns.md | 4 +- .../composite-tools-quick-reference.md | 2 +- docs/operator/crd-api.md | 1043 +++--- docs/operator/crd-ref-config.yaml | 2 +- docs/operator/restart-annotation.md | 4 +- docs/operator/templates/markdown/type.tpl | 6 +- docs/operator/toolconfig-reconciliation.md | 2 +- ...virtualmcpcompositetooldefinition-guide.md | 12 +- docs/operator/virtualmcpserver-api.md | 6 +- .../virtualmcpserver-kubernetes-guide.md | 16 +- .../virtualmcpserver-observability.md | 8 +- docs/redis-storage.md | 4 +- docs/server/docs.go | 18 +- docs/server/swagger.json | 18 +- docs/server/swagger.yaml | 18 +- examples/mcpserver-with-audit.yaml | 2 +- .../embedding-servers/basic-embedding.yaml | 2 +- .../embedding-servers/embedding-advanced.yaml | 2 +- .../embedding-with-cache.yaml | 2 +- .../external-auth/complete_example.yaml | 4 +- .../mcpexternalauthconfig_basic.yaml | 2 +- .../mcpexternalauthconfig_minimal.yaml | 2 +- .../mcpremoteproxy_with_bearer_token.yaml | 6 +- .../mcpserver_with_external_auth.yaml | 2 +- .../mcpregistry-configyaml-api.yaml | 2 +- .../mcpregistry-configyaml-configmap.yaml | 2 +- .../mcpregistry-configyaml-git-auth.yaml | 2 +- .../mcpregistry-configyaml-minimal.yaml | 2 +- .../mcpregistry-configyaml-oauth.yaml | 2 +- .../mcpregistry-configyaml-pgpass.yaml | 2 +- .../mcpserverentry_basic.yaml | 4 +- .../mcpserverentry_mixed_group.yaml | 8 +- .../mcpserverentry_with_ca_bundle.yaml | 2 +- .../mcpserverentry_with_header_forward.yaml | 2 +- .../mcpserverentry_with_token_exchange.yaml | 4 +- .../mcpremoteproxy_with_oidcconfig_ref.yaml | 6 +- .../operator/mcp-servers/mcpserver_fetch.yaml | 2 +- .../mcp-servers/mcpserver_fetch_otel.yaml | 4 +- .../mcpserver_fetch_tools_filter.yaml | 4 +- .../mcp-servers/mcpserver_github.yaml | 2 +- .../operator/mcp-servers/mcpserver_mkp.yaml | 2 +- .../mcpserver_with_oidcconfig_ref.yaml | 8 +- .../mcpserver_with_pod_template.yaml | 2 +- .../mcpserver_with_resource_overrides.yaml | 2 +- .../mcpserver_with_restart_strategy.yaml | 2 +- .../mcp-servers/mcpserver_yardstick_sse.yaml | 2 +- .../mcpserver_yardstick_stdio.yaml | 2 +- .../mcpserver_yardstick_streamablehttp.yaml | 2 +- .../mcpexternalauthconfig-redis-storage.yaml | 2 +- .../tool-configs/toolconfig_basic.yaml | 2 +- .../toolconfig_with_overrides.yaml | 2 +- .../vault/mcpserver-github-with-vault.yaml | 2 +- .../virtual-mcps/composite_tool_complex.yaml | 12 +- .../virtual-mcps/composite_tool_simple.yaml | 10 +- .../composite_tool_with_elicitations.yaml | 8 +- .../vmcp_conflict_resolution.yaml | 14 +- .../vmcp_inline_incoming_auth.yaml | 12 +- .../vmcp_optimizer_all_options.yaml | 10 +- .../vmcp_optimizer_quickstart.yaml | 28 +- .../virtual-mcps/vmcp_production_full.yaml | 10 +- .../virtual-mcps/vmcp_simple_discovered.yaml | 6 +- .../vmcp_with_oidcconfig_ref.yaml | 8 +- .../virtual-mcps/vmcp_with_telemetry_ref.yaml | 10 +- pkg/audit/zz_generated.deepcopy.go | 5 + pkg/export/k8s.go | 34 +- pkg/export/k8s_test.go | 34 +- pkg/groups/crd_manager.go | 18 +- pkg/groups/crd_manager_test.go | 72 +- pkg/groups/manager.go | 6 +- pkg/operator/accessors/mcpserver_accessor.go | 10 +- .../accessors/mcpserver_accessor_test.go | 126 +- pkg/ratelimit/limiter.go | 10 +- pkg/ratelimit/limiter_test.go | 70 +- pkg/ratelimit/middleware.go | 12 +- pkg/runner/config.go | 4 +- pkg/runner/config_builder.go | 4 +- pkg/runner/middleware_test.go | 14 +- pkg/vmcp/auth/converters/header_injection.go | 6 +- .../auth/converters/header_injection_test.go | 74 +- pkg/vmcp/auth/converters/interface.go | 30 +- pkg/vmcp/auth/converters/registry_test.go | 116 +- pkg/vmcp/auth/converters/token_exchange.go | 6 +- .../auth/converters/token_exchange_test.go | 154 +- pkg/vmcp/auth/converters/unauthenticated.go | 6 +- .../auth/converters/unauthenticated_test.go | 30 +- pkg/vmcp/auth/converters/upstream_inject.go | 6 +- .../auth/converters/upstream_inject_test.go | 36 +- pkg/vmcp/auth/factory/integration_test.go | 38 +- pkg/vmcp/k8s/backend_reconciler.go | 38 +- .../backend_reconciler_integration_test.go | 72 +- pkg/vmcp/k8s/backend_reconciler_test.go | 98 +- pkg/vmcp/k8s/manager.go | 4 +- pkg/vmcp/status/k8s_reporter.go | 26 +- pkg/vmcp/status/k8s_reporter_test.go | 46 +- pkg/vmcp/workloads/k8s.go | 66 +- pkg/vmcp/workloads/k8s_test.go | 476 +-- .../assert-embeddingserver-ns1-running.yaml | 2 +- .../assert-embeddingserver-ns2-running.yaml | 2 +- .../embeddingserver/embeddingserver-ns1.yaml | 2 +- .../embeddingserver/embeddingserver-ns2.yaml | 2 +- .../sse/assert-mcpserver-running.yaml | 2 +- .../test-scenarios/sse/mcpserver.yaml | 2 +- .../assert-mcpserver-running.yaml | 2 +- .../stdio-streamable-http/mcpserver.yaml | 2 +- .../stdio/assert-mcpserver-running.yaml | 2 +- .../test-scenarios/stdio/mcpserver.yaml | 2 +- .../assert-mcpserver-running.yaml | 2 +- .../streamable-http/mcpserver.yaml | 2 +- .../basic/assert-embeddingserver-running.yaml | 2 +- .../basic/embeddingserver.yaml | 2 +- .../assert-embeddingserver-running.yaml | 2 +- .../assert-embeddingserver-scaled.yaml | 2 +- .../lifecycle/chainsaw-test.yaml | 2 +- .../lifecycle/embeddingserver-initial.yaml | 2 +- .../lifecycle/embeddingserver-scaled.yaml | 2 +- .../embeddingserver-updated-env.yaml | 2 +- .../assert-embeddingserver-running.yaml | 2 +- .../with-cache/embeddingserver.yaml | 2 +- .../assert-mcpserver-running.yaml | 2 +- .../pod-annotations/mcpserver.yaml | 2 +- .../sse/assert-mcpserver-running.yaml | 2 +- .../test-scenarios/sse/mcpserver.yaml | 2 +- .../assert-mcpserver-running.yaml | 2 +- .../stdio-streamable-http/mcpserver.yaml | 2 +- .../stdio/assert-mcpserver-running.yaml | 2 +- .../test-scenarios/stdio/mcpserver.yaml | 2 +- .../assert-mcpserver-running.yaml | 2 +- .../streamable-http/mcpserver.yaml | 2 +- .../vmcp/assert-vmcp-status-ready.yaml | 2 +- .../vmcp/audit-chainsaw-test.yaml | 14 +- .../vmcp/basic/chainsaw-test.yaml | 8 +- .../test-scenarios/vmcp/chainsaw-test.yaml | 14 +- .../vmcp/controller-chainsaw-test.yaml | 2 +- .../vmcp/mcpgroup-controller.yaml | 2 +- .../test-scenarios/vmcp/vmcp-controller.yaml | 2 +- .../test-scenarios/vmcp/vmcp-oidc-config.yaml | 2 +- .../test-scenarios/vmcp/vmcp-with-oidc.yaml | 2 +- .../mcpexternalauthconfig/chainsaw-test.yaml | 36 +- .../virtualmcpserver/chainsaw-test.yaml | 6 +- test/e2e/export_test.go | 8 +- .../acceptance_tests/ratelimit_test.go | 58 +- .../acceptance_tests/suite_test.go | 4 +- test/e2e/thv-operator/testutil/k8s.go | 8 +- test/e2e/thv-operator/virtualmcp/helpers.go | 74 +- .../virtualmcp/mcpserver_scaling_test.go | 30 +- .../e2e/thv-operator/virtualmcp/suite_test.go | 8 +- .../virtualmcp_aggregation_filtering_test.go | 18 +- .../virtualmcp_aggregation_overrides_test.go | 18 +- .../virtualmcp_auth_discovery_test.go | 156 +- .../virtualmcp_authserver_config_test.go | 34 +- .../virtualmcp_circuit_breaker_test.go | 94 +- ...irtualmcp_composite_defaultresults_test.go | 16 +- .../virtualmcp_composite_hidden_tools_test.go | 20 +- .../virtualmcp_composite_parallel_test.go | 20 +- .../virtualmcp_composite_referenced_test.go | 36 +- .../virtualmcp_composite_sequential_test.go | 18 +- .../virtualmcp_composite_validation_test.go | 28 +- .../virtualmcp_conflict_resolution_test.go | 20 +- .../virtualmcp_discovered_mode_test.go | 56 +- .../virtualmcp_excludeall_global_test.go | 20 +- .../virtualmcp_external_auth_test.go | 266 +- ...rtualmcp_optimizer_circuit_breaker_test.go | 52 +- .../virtualmcp_optimizer_composite_test.go | 18 +- .../virtualmcp_optimizer_multibackend_test.go | 32 +- .../virtualmcp/virtualmcp_optimizer_test.go | 26 +- .../virtualmcp_redis_session_test.go | 126 +- .../virtualmcp_session_management_test.go | 80 +- .../virtualmcp/virtualmcp_telemetry_test.go | 50 +- .../virtualmcp/virtualmcp_toolconfig_test.go | 52 +- .../virtualmcp_yardstick_base_test.go | 48 +- .../virtualmcpserver_scaling_test.go | 72 +- 384 files changed, 27197 insertions(+), 12094 deletions(-) create mode 100644 cmd/thv-operator/api/v1alpha1/doc.go create mode 100644 cmd/thv-operator/api/v1alpha1/types.go rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/conditions.go (93%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/embeddingserver_types.go (99%) create mode 100644 cmd/thv-operator/api/v1beta1/groupversion_info.go rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/mcpexternalauthconfig_types.go (99%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/mcpexternalauthconfig_types_test.go (99%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/mcpgroup_types.go (98%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/mcpoidcconfig_types.go (99%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/mcpregistry_parse_test.go (99%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/mcpregistry_types.go (99%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/mcpremoteproxy_types.go (99%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/mcpserver_types.go (99%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/mcpserver_types_test.go (99%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/mcpserverentry_types.go (99%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/mcptelemetryconfig_types.go (99%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/mcptelemetryconfig_types_test.go (99%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/toolconfig_types.go (99%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/virtualmcpcompositetooldefinition_types.go (99%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/virtualmcpserver_types.go (99%) rename cmd/thv-operator/api/{v1alpha1 => v1beta1}/virtualmcpserver_types_test.go (99%) create mode 100644 cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go diff --git a/.claude/skills/implement-story/SKILL.md b/.claude/skills/implement-story/SKILL.md index 73f59560e7..bf65de7c72 100644 --- a/.claude/skills/implement-story/SKILL.md +++ b/.claude/skills/implement-story/SKILL.md @@ -170,7 +170,7 @@ After changes that affect generated artifacts, run the appropriate tasks: | Change Type | Regeneration Command | |-------------|---------------------| -| CRD type definitions (`api/v1alpha1/*_types.go`) | `task operator-manifests operator-generate` | +| CRD type definitions (`api/v1beta1/*_types.go`) | `task operator-manifests operator-generate` | | Mock interfaces | `task gen` | | CLI commands or API endpoints | `task docs` | | Helm chart values | `task helm-docs` | diff --git a/PROJECT b/PROJECT index 7e874c9ac2..1488563eae 100644 --- a/PROJECT +++ b/PROJECT @@ -11,6 +11,6 @@ resources: domain: toolhive.stacklok.dev group: toolhive kind: MCPServer - path: github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1 - version: v1alpha1 + path: github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1 + version: v1beta1 version: "3" \ No newline at end of file diff --git a/cmd/thv-operator/README.md b/cmd/thv-operator/README.md index 80ffcc01f4..21915a82ce 100644 --- a/cmd/thv-operator/README.md +++ b/cmd/thv-operator/README.md @@ -136,7 +136,7 @@ helm upgrade -i <release_name> oci://ghcr.io/stacklok/toolhive/toolhive-operator To create an MCP server, define an `MCPServer` resource and apply it to your cluster: ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch @@ -165,7 +165,7 @@ kubectl apply -f your-mcpserver.yaml For MCP servers that require authentication tokens or other secrets: ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: github @@ -272,7 +272,7 @@ kubectl create configmap my-registry-data --from-file registry.json=/path/to/you Then create the MCPRegistry resource with `configYAML` and mount the ConfigMap: ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRegistry metadata: name: my-registry @@ -350,7 +350,7 @@ This operator is scaffolded using Kubebuilder. If you want to make changes to th Generate CRD manifests: ```bash -kubebuilder create api --group toolhive --version v1alpha1 --kind MCPServer +kubebuilder create api --group toolhive --version v1beta1 --kind MCPServer ``` Update CRD manifests after changing API types: @@ -369,7 +369,7 @@ task operator-run The Kubebuilder project structure is as follows: -- `api/v1alpha1/`: Contains the API definitions for the CRDs +- `api/v1beta1/`: Contains the API definitions for the CRDs - `controllers/`: Contains the reconciliation logic for the controllers - `config/`: Contains the Kubernetes manifests for deploying the operator - `PROJECT`: Kubebuilder project configuration file diff --git a/cmd/thv-operator/REGISTRY.md b/cmd/thv-operator/REGISTRY.md index 5132c13f0a..483ea2644a 100644 --- a/cmd/thv-operator/REGISTRY.md +++ b/cmd/thv-operator/REGISTRY.md @@ -33,7 +33,7 @@ data: } } --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRegistry metadata: name: my-registry @@ -171,7 +171,7 @@ type: Opaque stringData: password: "ghp_your_github_token_here" # GitHub PAT, GitLab token, etc. --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRegistry metadata: name: private-registry @@ -501,7 +501,7 @@ kubectl logs -n toolhive-system deployment/toolhive-operator | grep "my-registry ### Production Registry ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRegistry metadata: name: production-registry @@ -523,7 +523,7 @@ spec: ### Development Registry ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRegistry metadata: name: dev-registry @@ -554,7 +554,7 @@ type: Opaque stringData: token: "<github token>" --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRegistry metadata: name: private-registry @@ -586,7 +586,7 @@ spec: You can configure multiple data sources in a single MCPRegistry and aggregate them into registry views: ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRegistry metadata: name: multi-source-registry diff --git a/cmd/thv-operator/api/v1alpha1/doc.go b/cmd/thv-operator/api/v1alpha1/doc.go new file mode 100644 index 0000000000..b82a6f9974 --- /dev/null +++ b/cmd/thv-operator/api/v1alpha1/doc.go @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package v1alpha1 contains the deprecated v1alpha1 API types for the +// toolhive.stacklok.dev group. These types exist solely to enable seamless +// CRD graduation from v1alpha1 → v1beta1: the CRD serves both versions +// (with conversion strategy "None"), so existing v1alpha1 resources continue +// to work while users migrate their manifests to v1beta1. +// +// All Spec and Status types are imported from v1beta1 — the schemas are +// identical. Only the root resource types and their List companions are +// defined here so that controller-gen produces a multi-version CRD. +// +// This package will be removed in a future release once the v1alpha1 +// deprecation period ends. +// +// +kubebuilder:object:generate=true +// +groupName=toolhive.stacklok.dev +package v1alpha1 diff --git a/cmd/thv-operator/api/v1alpha1/groupversion_info.go b/cmd/thv-operator/api/v1alpha1/groupversion_info.go index c58025bb41..eb034130ce 100644 --- a/cmd/thv-operator/api/v1alpha1/groupversion_info.go +++ b/cmd/thv-operator/api/v1alpha1/groupversion_info.go @@ -1,9 +1,6 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -// Package v1alpha1 contains API Schema definitions for the toolhive v1alpha1 API group -// +kubebuilder:object:generate=true -// +groupName=toolhive.stacklok.dev package v1alpha1 import ( @@ -12,10 +9,10 @@ import ( ) var ( - // GroupVersion is group version used to register these objects + // GroupVersion is group version used to register these objects. GroupVersion = schema.GroupVersion{Group: "toolhive.stacklok.dev", Version: "v1alpha1"} - // SchemeBuilder is used to add go types to the GroupVersionKind scheme + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} // AddToScheme adds the types in this group-version to the given scheme. diff --git a/cmd/thv-operator/api/v1alpha1/types.go b/cmd/thv-operator/api/v1alpha1/types.go new file mode 100644 index 0000000000..e039ea9489 --- /dev/null +++ b/cmd/thv-operator/api/v1alpha1/types.go @@ -0,0 +1,385 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" +) + +// ─── EmbeddingServer ───────────────────────────────────────────────────────── + +//+kubebuilder:object:root=true +//+kubebuilder:deprecatedversion:warning="toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1" +//+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=emb;embedding,categories=toolhive +//+kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.phase" +//+kubebuilder:printcolumn:name="Model",type="string",JSONPath=".spec.model" +//+kubebuilder:printcolumn:name="Ready",type="integer",JSONPath=".status.readyReplicas" +//+kubebuilder:printcolumn:name="URL",type="string",JSONPath=".status.url" +//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// EmbeddingServer is the deprecated v1alpha1 version of the EmbeddingServer resource. +type EmbeddingServer struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec v1beta1.EmbeddingServerSpec `json:"spec,omitempty"` + Status v1beta1.EmbeddingServerStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// EmbeddingServerList contains a list of EmbeddingServer. +type EmbeddingServerList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []EmbeddingServer `json:"items"` +} + +// ─── MCPExternalAuthConfig ─────────────────────────────────────────────────── + +//+kubebuilder:object:root=true +//+kubebuilder:deprecatedversion:warning="toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1" +//+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=extauth;mcpextauth,categories=toolhive +//+kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type` +//+kubebuilder:printcolumn:name="Valid",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status` +//+kubebuilder:printcolumn:name="References",type=string,JSONPath=`.status.referencingWorkloads` +//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// MCPExternalAuthConfig is the deprecated v1alpha1 version of the MCPExternalAuthConfig resource. +type MCPExternalAuthConfig struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec v1beta1.MCPExternalAuthConfigSpec `json:"spec,omitempty"` + Status v1beta1.MCPExternalAuthConfigStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// MCPExternalAuthConfigList contains a list of MCPExternalAuthConfig. +type MCPExternalAuthConfigList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []MCPExternalAuthConfig `json:"items"` +} + +// ─── MCPGroup ──────────────────────────────────────────────────────────────── + +//+kubebuilder:object:root=true +//+kubebuilder:deprecatedversion:warning="toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1" +//+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=mcpg;mcpgroup,categories=toolhive +//+kubebuilder:printcolumn:name="Servers",type="integer",JSONPath=".status.serverCount" +//+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase" +//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='MCPServersChecked')].status" +//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// MCPGroup is the deprecated v1alpha1 version of the MCPGroup resource. +type MCPGroup struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec v1beta1.MCPGroupSpec `json:"spec,omitempty"` + Status v1beta1.MCPGroupStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// MCPGroupList contains a list of MCPGroup. +type MCPGroupList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []MCPGroup `json:"items"` +} + +// ─── MCPOIDCConfig ─────────────────────────────────────────────────────────── + +//+kubebuilder:object:root=true +//+kubebuilder:deprecatedversion:warning="toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1" +//+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=mcpoidc,categories=toolhive +//+kubebuilder:printcolumn:name="Source",type=string,JSONPath=`.spec.type` +//+kubebuilder:printcolumn:name="Valid",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status` +//+kubebuilder:printcolumn:name="References",type=string,JSONPath=`.status.referencingWorkloads` +//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// MCPOIDCConfig is the deprecated v1alpha1 version of the MCPOIDCConfig resource. +type MCPOIDCConfig struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec v1beta1.MCPOIDCConfigSpec `json:"spec,omitempty"` + Status v1beta1.MCPOIDCConfigStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// MCPOIDCConfigList contains a list of MCPOIDCConfig. +type MCPOIDCConfigList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []MCPOIDCConfig `json:"items"` +} + +// ─── MCPRegistry ───────────────────────────────────────────────────────────── + +//+kubebuilder:object:root=true +//+kubebuilder:deprecatedversion:warning="toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1" +//+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=mcpreg;registry,scope=Namespaced,categories=toolhive +//+kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.phase" +//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" +//+kubebuilder:printcolumn:name="Replicas",type="integer",JSONPath=".status.readyReplicas" +//+kubebuilder:printcolumn:name="URL",type="string",JSONPath=".status.url" +//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// MCPRegistry is the deprecated v1alpha1 version of the MCPRegistry resource. +type MCPRegistry struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec v1beta1.MCPRegistrySpec `json:"spec,omitempty"` + Status v1beta1.MCPRegistryStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// MCPRegistryList contains a list of MCPRegistry. +type MCPRegistryList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []MCPRegistry `json:"items"` +} + +// ─── MCPRemoteProxy ────────────────────────────────────────────────────────── + +//+kubebuilder:object:root=true +//+kubebuilder:deprecatedversion:warning="toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1" +//+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=rp;mcprp,categories=toolhive +//+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase" +//+kubebuilder:printcolumn:name="Remote URL",type="string",JSONPath=".spec.remoteUrl" +//+kubebuilder:printcolumn:name="URL",type="string",JSONPath=".status.url" +//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" +//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// MCPRemoteProxy is the deprecated v1alpha1 version of the MCPRemoteProxy resource. +type MCPRemoteProxy struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec v1beta1.MCPRemoteProxySpec `json:"spec,omitempty"` + Status v1beta1.MCPRemoteProxyStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// MCPRemoteProxyList contains a list of MCPRemoteProxy. +type MCPRemoteProxyList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []MCPRemoteProxy `json:"items"` +} + +// ─── MCPServer ─────────────────────────────────────────────────────────────── + +//+kubebuilder:object:root=true +//+kubebuilder:deprecatedversion:warning="toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1" +//+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=mcpserver;mcpservers,categories=toolhive +//+kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.phase" +//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" +//+kubebuilder:printcolumn:name="Replicas",type="integer",JSONPath=".status.readyReplicas" +//+kubebuilder:printcolumn:name="URL",type="string",JSONPath=".status.url" +//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// MCPServer is the deprecated v1alpha1 version of the MCPServer resource. +type MCPServer struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec v1beta1.MCPServerSpec `json:"spec,omitempty"` + Status v1beta1.MCPServerStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// MCPServerList contains a list of MCPServer. +type MCPServerList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []MCPServer `json:"items"` +} + +// ─── MCPServerEntry ────────────────────────────────────────────────────────── + +//+kubebuilder:object:root=true +//+kubebuilder:deprecatedversion:warning="toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1" +//+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=mcpentry,categories=toolhive +//+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase" +//+kubebuilder:printcolumn:name="Transport",type="string",JSONPath=".spec.transport" +//+kubebuilder:printcolumn:name="Remote URL",type="string",JSONPath=".spec.remoteUrl" +//+kubebuilder:printcolumn:name="Group",type="string",JSONPath=".spec.groupRef.name" +//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// MCPServerEntry is the deprecated v1alpha1 version of the MCPServerEntry resource. +type MCPServerEntry struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec v1beta1.MCPServerEntrySpec `json:"spec,omitempty"` + Status v1beta1.MCPServerEntryStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// MCPServerEntryList contains a list of MCPServerEntry. +type MCPServerEntryList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []MCPServerEntry `json:"items"` +} + +// ─── MCPTelemetryConfig ────────────────────────────────────────────────────── + +//+kubebuilder:object:root=true +//+kubebuilder:deprecatedversion:warning="toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1" +//+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=mcpotel,categories=toolhive +//+kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.spec.openTelemetry.endpoint` +//+kubebuilder:printcolumn:name="Valid",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status` +//+kubebuilder:printcolumn:name="Tracing",type=boolean,JSONPath=`.spec.openTelemetry.tracing.enabled` +//+kubebuilder:printcolumn:name="Metrics",type=boolean,JSONPath=`.spec.openTelemetry.metrics.enabled` +//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// MCPTelemetryConfig is the deprecated v1alpha1 version of the MCPTelemetryConfig resource. +type MCPTelemetryConfig struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec v1beta1.MCPTelemetryConfigSpec `json:"spec,omitempty"` + Status v1beta1.MCPTelemetryConfigStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// MCPTelemetryConfigList contains a list of MCPTelemetryConfig. +type MCPTelemetryConfigList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []MCPTelemetryConfig `json:"items"` +} + +// ─── MCPToolConfig ─────────────────────────────────────────────────────────── + +//+kubebuilder:object:root=true +//+kubebuilder:deprecatedversion:warning="toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1" +//+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=tc;toolconfig,categories=toolhive +//+kubebuilder:printcolumn:name="Valid",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status` +//+kubebuilder:printcolumn:name="References",type=string,JSONPath=`.status.referencingWorkloads` +//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// MCPToolConfig is the deprecated v1alpha1 version of the MCPToolConfig resource. +type MCPToolConfig struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec v1beta1.MCPToolConfigSpec `json:"spec,omitempty"` + Status v1beta1.MCPToolConfigStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// MCPToolConfigList contains a list of MCPToolConfig. +type MCPToolConfigList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []MCPToolConfig `json:"items"` +} + +// ─── VirtualMCPCompositeToolDefinition ─────────────────────────────────────── + +//+kubebuilder:object:root=true +//+kubebuilder:deprecatedversion:warning="toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1" +//+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=vmcpctd;compositetool,categories=toolhive +//+kubebuilder:printcolumn:name="Workflow",type="string",JSONPath=".spec.name",description="Workflow name" +//+kubebuilder:printcolumn:name="Steps",type="integer",JSONPath=".spec.steps[*]",description="Number of steps" +//+kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.validationStatus",description="Validation status" +//+kubebuilder:printcolumn:name="Refs",type="integer",JSONPath=".status.referencingVirtualServers[*]",description="Refs" +//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age" +//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" + +// VirtualMCPCompositeToolDefinition is the deprecated v1alpha1 version of the VirtualMCPCompositeToolDefinition resource. +type VirtualMCPCompositeToolDefinition struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec v1beta1.VirtualMCPCompositeToolDefinitionSpec `json:"spec,omitempty"` + Status v1beta1.VirtualMCPCompositeToolDefinitionStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// VirtualMCPCompositeToolDefinitionList contains a list of VirtualMCPCompositeToolDefinition. +type VirtualMCPCompositeToolDefinitionList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []VirtualMCPCompositeToolDefinition `json:"items"` +} + +// ─── VirtualMCPServer ──────────────────────────────────────────────────────── + +//+kubebuilder:object:root=true +//+kubebuilder:deprecatedversion:warning="toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1" +//+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=vmcp;virtualmcp,categories=toolhive +//+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase",description="The phase of the VirtualMCPServer" +//+kubebuilder:printcolumn:name="URL",type="string",JSONPath=".status.url",description="Virtual MCP server URL" +//+kubebuilder:printcolumn:name="Backends",type="integer",JSONPath=".status.backendCount",description="Discovered backends count" +//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age" +//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" + +// VirtualMCPServer is the deprecated v1alpha1 version of the VirtualMCPServer resource. +type VirtualMCPServer struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec v1beta1.VirtualMCPServerSpec `json:"spec,omitempty"` + Status v1beta1.VirtualMCPServerStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// VirtualMCPServerList contains a list of VirtualMCPServer. +type VirtualMCPServerList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []VirtualMCPServer `json:"items"` +} + +// ─── Scheme Registration ───────────────────────────────────────────────────── + +func init() { + SchemeBuilder.Register( + &EmbeddingServer{}, &EmbeddingServerList{}, + &MCPExternalAuthConfig{}, &MCPExternalAuthConfigList{}, + &MCPGroup{}, &MCPGroupList{}, + &MCPOIDCConfig{}, &MCPOIDCConfigList{}, + &MCPRegistry{}, &MCPRegistryList{}, + &MCPRemoteProxy{}, &MCPRemoteProxyList{}, + &MCPServer{}, &MCPServerList{}, + &MCPServerEntry{}, &MCPServerEntryList{}, + &MCPTelemetryConfig{}, &MCPTelemetryConfigList{}, + &MCPToolConfig{}, &MCPToolConfigList{}, + &VirtualMCPCompositeToolDefinition{}, &VirtualMCPCompositeToolDefinitionList{}, + &VirtualMCPServer{}, &VirtualMCPServerList{}, + ) +} diff --git a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go index eee5a0a73b..5ff43c9f52 100644 --- a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -21,282 +21,148 @@ limitations under the License. package v1alpha1 import ( - corev1 "k8s.io/api/core/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" + runtime "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *AWSStsConfig) DeepCopyInto(out *AWSStsConfig) { - *out = *in - if in.RoleMappings != nil { - in, out := &in.RoleMappings, &out.RoleMappings - *out = make([]RoleMapping, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.SessionDuration != nil { - in, out := &in.SessionDuration, &out.SessionDuration - *out = new(int32) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSStsConfig. -func (in *AWSStsConfig) DeepCopy() *AWSStsConfig { - if in == nil { - return nil - } - out := new(AWSStsConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *AuditConfig) DeepCopyInto(out *AuditConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuditConfig. -func (in *AuditConfig) DeepCopy() *AuditConfig { - if in == nil { - return nil - } - out := new(AuditConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *AuthServerRef) DeepCopyInto(out *AuthServerRef) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthServerRef. -func (in *AuthServerRef) DeepCopy() *AuthServerRef { - if in == nil { - return nil - } - out := new(AuthServerRef) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *AuthServerStorageConfig) DeepCopyInto(out *AuthServerStorageConfig) { +func (in *EmbeddingServer) DeepCopyInto(out *EmbeddingServer) { *out = *in - if in.Redis != nil { - in, out := &in.Redis, &out.Redis - *out = new(RedisStorageConfig) - (*in).DeepCopyInto(*out) - } + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthServerStorageConfig. -func (in *AuthServerStorageConfig) DeepCopy() *AuthServerStorageConfig { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingServer. +func (in *EmbeddingServer) DeepCopy() *EmbeddingServer { if in == nil { return nil } - out := new(AuthServerStorageConfig) + out := new(EmbeddingServer) in.DeepCopyInto(out) return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *AuthzConfigRef) DeepCopyInto(out *AuthzConfigRef) { - *out = *in - if in.ConfigMap != nil { - in, out := &in.ConfigMap, &out.ConfigMap - *out = new(ConfigMapAuthzRef) - **out = **in - } - if in.Inline != nil { - in, out := &in.Inline, &out.Inline - *out = new(InlineAuthzConfig) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthzConfigRef. -func (in *AuthzConfigRef) DeepCopy() *AuthzConfigRef { - if in == nil { - return nil +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EmbeddingServer) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c } - out := new(AuthzConfigRef) - in.DeepCopyInto(out) - return out + return nil } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *BackendAuthConfig) DeepCopyInto(out *BackendAuthConfig) { +func (in *EmbeddingServerList) DeepCopyInto(out *EmbeddingServerList) { *out = *in - if in.ExternalAuthConfigRef != nil { - in, out := &in.ExternalAuthConfigRef, &out.ExternalAuthConfigRef - *out = new(ExternalAuthConfigRef) - **out = **in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EmbeddingServer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendAuthConfig. -func (in *BackendAuthConfig) DeepCopy() *BackendAuthConfig { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingServerList. +func (in *EmbeddingServerList) DeepCopy() *EmbeddingServerList { if in == nil { return nil } - out := new(BackendAuthConfig) + out := new(EmbeddingServerList) in.DeepCopyInto(out) return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *BearerTokenConfig) DeepCopyInto(out *BearerTokenConfig) { - *out = *in - if in.TokenSecretRef != nil { - in, out := &in.TokenSecretRef, &out.TokenSecretRef - *out = new(SecretKeyRef) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BearerTokenConfig. -func (in *BearerTokenConfig) DeepCopy() *BearerTokenConfig { - if in == nil { - return nil +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EmbeddingServerList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c } - out := new(BearerTokenConfig) - in.DeepCopyInto(out) - return out + return nil } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *CABundleSource) DeepCopyInto(out *CABundleSource) { +func (in *MCPExternalAuthConfig) DeepCopyInto(out *MCPExternalAuthConfig) { *out = *in - if in.ConfigMapRef != nil { - in, out := &in.ConfigMapRef, &out.ConfigMapRef - *out = new(corev1.ConfigMapKeySelector) - (*in).DeepCopyInto(*out) - } + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CABundleSource. -func (in *CABundleSource) DeepCopy() *CABundleSource { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfig. +func (in *MCPExternalAuthConfig) DeepCopy() *MCPExternalAuthConfig { if in == nil { return nil } - out := new(CABundleSource) + out := new(MCPExternalAuthConfig) in.DeepCopyInto(out) return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ConfigMapAuthzRef) DeepCopyInto(out *ConfigMapAuthzRef) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapAuthzRef. -func (in *ConfigMapAuthzRef) DeepCopy() *ConfigMapAuthzRef { - if in == nil { - return nil +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPExternalAuthConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c } - out := new(ConfigMapAuthzRef) - in.DeepCopyInto(out) - return out + return nil } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *EmbeddedAuthServerConfig) DeepCopyInto(out *EmbeddedAuthServerConfig) { +func (in *MCPExternalAuthConfigList) DeepCopyInto(out *MCPExternalAuthConfigList) { *out = *in - if in.SigningKeySecretRefs != nil { - in, out := &in.SigningKeySecretRefs, &out.SigningKeySecretRefs - *out = make([]SecretKeyRef, len(*in)) - copy(*out, *in) - } - if in.HMACSecretRefs != nil { - in, out := &in.HMACSecretRefs, &out.HMACSecretRefs - *out = make([]SecretKeyRef, len(*in)) - copy(*out, *in) - } - if in.TokenLifespans != nil { - in, out := &in.TokenLifespans, &out.TokenLifespans - *out = new(TokenLifespanConfig) - **out = **in - } - if in.UpstreamProviders != nil { - in, out := &in.UpstreamProviders, &out.UpstreamProviders - *out = make([]UpstreamProviderConfig, len(*in)) + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPExternalAuthConfig, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.Storage != nil { - in, out := &in.Storage, &out.Storage - *out = new(AuthServerStorageConfig) - (*in).DeepCopyInto(*out) - } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddedAuthServerConfig. -func (in *EmbeddedAuthServerConfig) DeepCopy() *EmbeddedAuthServerConfig { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfigList. +func (in *MCPExternalAuthConfigList) DeepCopy() *MCPExternalAuthConfigList { if in == nil { return nil } - out := new(EmbeddedAuthServerConfig) + out := new(MCPExternalAuthConfigList) in.DeepCopyInto(out) return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *EmbeddingResourceOverrides) DeepCopyInto(out *EmbeddingResourceOverrides) { - *out = *in - if in.StatefulSet != nil { - in, out := &in.StatefulSet, &out.StatefulSet - *out = new(EmbeddingStatefulSetOverrides) - (*in).DeepCopyInto(*out) - } - if in.Service != nil { - in, out := &in.Service, &out.Service - *out = new(ResourceMetadataOverrides) - (*in).DeepCopyInto(*out) - } - if in.PersistentVolumeClaim != nil { - in, out := &in.PersistentVolumeClaim, &out.PersistentVolumeClaim - *out = new(ResourceMetadataOverrides) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingResourceOverrides. -func (in *EmbeddingResourceOverrides) DeepCopy() *EmbeddingResourceOverrides { - if in == nil { - return nil +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPExternalAuthConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c } - out := new(EmbeddingResourceOverrides) - in.DeepCopyInto(out) - return out + return nil } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *EmbeddingServer) DeepCopyInto(out *EmbeddingServer) { +func (in *MCPGroup) DeepCopyInto(out *MCPGroup) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) + out.Spec = in.Spec in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingServer. -func (in *EmbeddingServer) DeepCopy() *EmbeddingServer { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPGroup. +func (in *MCPGroup) DeepCopy() *MCPGroup { if in == nil { return nil } - out := new(EmbeddingServer) + out := new(MCPGroup) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *EmbeddingServer) DeepCopyObject() runtime.Object { +func (in *MCPGroup) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -304,31 +170,31 @@ func (in *EmbeddingServer) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *EmbeddingServerList) DeepCopyInto(out *EmbeddingServerList) { +func (in *MCPGroupList) DeepCopyInto(out *MCPGroupList) { *out = *in out.TypeMeta = in.TypeMeta in.ListMeta.DeepCopyInto(&out.ListMeta) if in.Items != nil { in, out := &in.Items, &out.Items - *out = make([]EmbeddingServer, len(*in)) + *out = make([]MCPGroup, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingServerList. -func (in *EmbeddingServerList) DeepCopy() *EmbeddingServerList { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPGroupList. +func (in *MCPGroupList) DeepCopy() *MCPGroupList { if in == nil { return nil } - out := new(EmbeddingServerList) + out := new(MCPGroupList) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *EmbeddingServerList) DeepCopyObject() runtime.Object { +func (in *MCPGroupList) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -336,2450 +202,302 @@ func (in *EmbeddingServerList) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *EmbeddingServerRef) DeepCopyInto(out *EmbeddingServerRef) { +func (in *MCPOIDCConfig) DeepCopyInto(out *MCPOIDCConfig) { *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingServerRef. -func (in *EmbeddingServerRef) DeepCopy() *EmbeddingServerRef { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPOIDCConfig. +func (in *MCPOIDCConfig) DeepCopy() *MCPOIDCConfig { if in == nil { return nil } - out := new(EmbeddingServerRef) + out := new(MCPOIDCConfig) in.DeepCopyInto(out) return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *EmbeddingServerSpec) DeepCopyInto(out *EmbeddingServerSpec) { - *out = *in - if in.HFTokenSecretRef != nil { - in, out := &in.HFTokenSecretRef, &out.HFTokenSecretRef - *out = new(SecretKeyRef) - **out = **in - } - if in.Args != nil { - in, out := &in.Args, &out.Args - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Env != nil { - in, out := &in.Env, &out.Env - *out = make([]EnvVar, len(*in)) - copy(*out, *in) - } - out.Resources = in.Resources - if in.ModelCache != nil { - in, out := &in.ModelCache, &out.ModelCache - *out = new(ModelCacheConfig) - (*in).DeepCopyInto(*out) - } - if in.PodTemplateSpec != nil { - in, out := &in.PodTemplateSpec, &out.PodTemplateSpec - *out = new(runtime.RawExtension) - (*in).DeepCopyInto(*out) - } - if in.ResourceOverrides != nil { - in, out := &in.ResourceOverrides, &out.ResourceOverrides - *out = new(EmbeddingResourceOverrides) - (*in).DeepCopyInto(*out) - } - if in.Replicas != nil { - in, out := &in.Replicas, &out.Replicas - *out = new(int32) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingServerSpec. -func (in *EmbeddingServerSpec) DeepCopy() *EmbeddingServerSpec { - if in == nil { - return nil +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPOIDCConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c } - out := new(EmbeddingServerSpec) - in.DeepCopyInto(out) - return out + return nil } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *EmbeddingServerStatus) DeepCopyInto(out *EmbeddingServerStatus) { +func (in *MCPOIDCConfigList) DeepCopyInto(out *MCPOIDCConfigList) { *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPOIDCConfig, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingServerStatus. -func (in *EmbeddingServerStatus) DeepCopy() *EmbeddingServerStatus { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPOIDCConfigList. +func (in *MCPOIDCConfigList) DeepCopy() *MCPOIDCConfigList { if in == nil { return nil } - out := new(EmbeddingServerStatus) + out := new(MCPOIDCConfigList) in.DeepCopyInto(out) return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *EmbeddingStatefulSetOverrides) DeepCopyInto(out *EmbeddingStatefulSetOverrides) { - *out = *in - in.ResourceMetadataOverrides.DeepCopyInto(&out.ResourceMetadataOverrides) - if in.PodTemplateMetadataOverrides != nil { - in, out := &in.PodTemplateMetadataOverrides, &out.PodTemplateMetadataOverrides - *out = new(ResourceMetadataOverrides) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingStatefulSetOverrides. -func (in *EmbeddingStatefulSetOverrides) DeepCopy() *EmbeddingStatefulSetOverrides { - if in == nil { - return nil +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPOIDCConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c } - out := new(EmbeddingStatefulSetOverrides) - in.DeepCopyInto(out) - return out + return nil } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *EnvVar) DeepCopyInto(out *EnvVar) { +func (in *MCPRegistry) DeepCopyInto(out *MCPRegistry) { *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvVar. -func (in *EnvVar) DeepCopy() *EnvVar { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistry. +func (in *MCPRegistry) DeepCopy() *MCPRegistry { if in == nil { return nil } - out := new(EnvVar) + out := new(MCPRegistry) in.DeepCopyInto(out) return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ExternalAuthConfigRef) DeepCopyInto(out *ExternalAuthConfigRef) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalAuthConfigRef. -func (in *ExternalAuthConfigRef) DeepCopy() *ExternalAuthConfigRef { - if in == nil { - return nil +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPRegistry) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c } - out := new(ExternalAuthConfigRef) - in.DeepCopyInto(out) - return out + return nil } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HeaderForwardConfig) DeepCopyInto(out *HeaderForwardConfig) { +func (in *MCPRegistryList) DeepCopyInto(out *MCPRegistryList) { *out = *in - if in.AddPlaintextHeaders != nil { - in, out := &in.AddPlaintextHeaders, &out.AddPlaintextHeaders - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.AddHeadersFromSecret != nil { - in, out := &in.AddHeadersFromSecret, &out.AddHeadersFromSecret - *out = make([]HeaderFromSecret, len(*in)) + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPRegistry, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HeaderForwardConfig. -func (in *HeaderForwardConfig) DeepCopy() *HeaderForwardConfig { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryList. +func (in *MCPRegistryList) DeepCopy() *MCPRegistryList { if in == nil { return nil } - out := new(HeaderForwardConfig) + out := new(MCPRegistryList) in.DeepCopyInto(out) return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HeaderFromSecret) DeepCopyInto(out *HeaderFromSecret) { - *out = *in - if in.ValueSecretRef != nil { - in, out := &in.ValueSecretRef, &out.ValueSecretRef - *out = new(SecretKeyRef) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HeaderFromSecret. -func (in *HeaderFromSecret) DeepCopy() *HeaderFromSecret { - if in == nil { - return nil - } - out := new(HeaderFromSecret) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HeaderInjectionConfig) DeepCopyInto(out *HeaderInjectionConfig) { - *out = *in - if in.ValueSecretRef != nil { - in, out := &in.ValueSecretRef, &out.ValueSecretRef - *out = new(SecretKeyRef) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HeaderInjectionConfig. -func (in *HeaderInjectionConfig) DeepCopy() *HeaderInjectionConfig { - if in == nil { - return nil - } - out := new(HeaderInjectionConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *IncomingAuthConfig) DeepCopyInto(out *IncomingAuthConfig) { - *out = *in - if in.OIDCConfigRef != nil { - in, out := &in.OIDCConfigRef, &out.OIDCConfigRef - *out = new(MCPOIDCConfigReference) - (*in).DeepCopyInto(*out) - } - if in.AuthzConfig != nil { - in, out := &in.AuthzConfig, &out.AuthzConfig - *out = new(AuthzConfigRef) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IncomingAuthConfig. -func (in *IncomingAuthConfig) DeepCopy() *IncomingAuthConfig { - if in == nil { - return nil - } - out := new(IncomingAuthConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *InlineAuthzConfig) DeepCopyInto(out *InlineAuthzConfig) { - *out = *in - if in.Policies != nil { - in, out := &in.Policies, &out.Policies - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InlineAuthzConfig. -func (in *InlineAuthzConfig) DeepCopy() *InlineAuthzConfig { - if in == nil { - return nil - } - out := new(InlineAuthzConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *InlineOIDCSharedConfig) DeepCopyInto(out *InlineOIDCSharedConfig) { - *out = *in - if in.ClientSecretRef != nil { - in, out := &in.ClientSecretRef, &out.ClientSecretRef - *out = new(SecretKeyRef) - **out = **in - } - if in.CABundleRef != nil { - in, out := &in.CABundleRef, &out.CABundleRef - *out = new(CABundleSource) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InlineOIDCSharedConfig. -func (in *InlineOIDCSharedConfig) DeepCopy() *InlineOIDCSharedConfig { - if in == nil { - return nil - } - out := new(InlineOIDCSharedConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *KubernetesServiceAccountOIDCConfig) DeepCopyInto(out *KubernetesServiceAccountOIDCConfig) { - *out = *in - if in.UseClusterAuth != nil { - in, out := &in.UseClusterAuth, &out.UseClusterAuth - *out = new(bool) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesServiceAccountOIDCConfig. -func (in *KubernetesServiceAccountOIDCConfig) DeepCopy() *KubernetesServiceAccountOIDCConfig { - if in == nil { - return nil - } - out := new(KubernetesServiceAccountOIDCConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPExternalAuthConfig) DeepCopyInto(out *MCPExternalAuthConfig) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfig. -func (in *MCPExternalAuthConfig) DeepCopy() *MCPExternalAuthConfig { - if in == nil { - return nil - } - out := new(MCPExternalAuthConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPExternalAuthConfig) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPExternalAuthConfigList) DeepCopyInto(out *MCPExternalAuthConfigList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]MCPExternalAuthConfig, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfigList. -func (in *MCPExternalAuthConfigList) DeepCopy() *MCPExternalAuthConfigList { - if in == nil { - return nil - } - out := new(MCPExternalAuthConfigList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPExternalAuthConfigList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPExternalAuthConfigSpec) DeepCopyInto(out *MCPExternalAuthConfigSpec) { - *out = *in - if in.TokenExchange != nil { - in, out := &in.TokenExchange, &out.TokenExchange - *out = new(TokenExchangeConfig) - (*in).DeepCopyInto(*out) - } - if in.HeaderInjection != nil { - in, out := &in.HeaderInjection, &out.HeaderInjection - *out = new(HeaderInjectionConfig) - (*in).DeepCopyInto(*out) - } - if in.BearerToken != nil { - in, out := &in.BearerToken, &out.BearerToken - *out = new(BearerTokenConfig) - (*in).DeepCopyInto(*out) - } - if in.EmbeddedAuthServer != nil { - in, out := &in.EmbeddedAuthServer, &out.EmbeddedAuthServer - *out = new(EmbeddedAuthServerConfig) - (*in).DeepCopyInto(*out) - } - if in.AWSSts != nil { - in, out := &in.AWSSts, &out.AWSSts - *out = new(AWSStsConfig) - (*in).DeepCopyInto(*out) - } - if in.UpstreamInject != nil { - in, out := &in.UpstreamInject, &out.UpstreamInject - *out = new(UpstreamInjectSpec) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfigSpec. -func (in *MCPExternalAuthConfigSpec) DeepCopy() *MCPExternalAuthConfigSpec { - if in == nil { - return nil - } - out := new(MCPExternalAuthConfigSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPExternalAuthConfigStatus) DeepCopyInto(out *MCPExternalAuthConfigStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.ReferencingWorkloads != nil { - in, out := &in.ReferencingWorkloads, &out.ReferencingWorkloads - *out = make([]WorkloadReference, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfigStatus. -func (in *MCPExternalAuthConfigStatus) DeepCopy() *MCPExternalAuthConfigStatus { - if in == nil { - return nil - } - out := new(MCPExternalAuthConfigStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPGroup) DeepCopyInto(out *MCPGroup) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPGroup. -func (in *MCPGroup) DeepCopy() *MCPGroup { - if in == nil { - return nil - } - out := new(MCPGroup) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPGroup) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPGroupList) DeepCopyInto(out *MCPGroupList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]MCPGroup, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPGroupList. -func (in *MCPGroupList) DeepCopy() *MCPGroupList { - if in == nil { - return nil - } - out := new(MCPGroupList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPGroupList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPRegistryList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c } return nil } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPGroupRef) DeepCopyInto(out *MCPGroupRef) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPGroupRef. -func (in *MCPGroupRef) DeepCopy() *MCPGroupRef { - if in == nil { - return nil - } - out := new(MCPGroupRef) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPGroupSpec) DeepCopyInto(out *MCPGroupSpec) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPGroupSpec. -func (in *MCPGroupSpec) DeepCopy() *MCPGroupSpec { - if in == nil { - return nil - } - out := new(MCPGroupSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPGroupStatus) DeepCopyInto(out *MCPGroupStatus) { - *out = *in - if in.Servers != nil { - in, out := &in.Servers, &out.Servers - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.RemoteProxies != nil { - in, out := &in.RemoteProxies, &out.RemoteProxies - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Entries != nil { - in, out := &in.Entries, &out.Entries - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPGroupStatus. -func (in *MCPGroupStatus) DeepCopy() *MCPGroupStatus { - if in == nil { - return nil - } - out := new(MCPGroupStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPOIDCConfig) DeepCopyInto(out *MCPOIDCConfig) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPOIDCConfig. -func (in *MCPOIDCConfig) DeepCopy() *MCPOIDCConfig { - if in == nil { - return nil - } - out := new(MCPOIDCConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPOIDCConfig) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPOIDCConfigList) DeepCopyInto(out *MCPOIDCConfigList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]MCPOIDCConfig, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPOIDCConfigList. -func (in *MCPOIDCConfigList) DeepCopy() *MCPOIDCConfigList { - if in == nil { - return nil - } - out := new(MCPOIDCConfigList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPOIDCConfigList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPOIDCConfigReference) DeepCopyInto(out *MCPOIDCConfigReference) { - *out = *in - if in.Scopes != nil { - in, out := &in.Scopes, &out.Scopes - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPOIDCConfigReference. -func (in *MCPOIDCConfigReference) DeepCopy() *MCPOIDCConfigReference { - if in == nil { - return nil - } - out := new(MCPOIDCConfigReference) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPOIDCConfigSpec) DeepCopyInto(out *MCPOIDCConfigSpec) { - *out = *in - if in.KubernetesServiceAccount != nil { - in, out := &in.KubernetesServiceAccount, &out.KubernetesServiceAccount - *out = new(KubernetesServiceAccountOIDCConfig) - (*in).DeepCopyInto(*out) - } - if in.Inline != nil { - in, out := &in.Inline, &out.Inline - *out = new(InlineOIDCSharedConfig) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPOIDCConfigSpec. -func (in *MCPOIDCConfigSpec) DeepCopy() *MCPOIDCConfigSpec { - if in == nil { - return nil - } - out := new(MCPOIDCConfigSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPOIDCConfigStatus) DeepCopyInto(out *MCPOIDCConfigStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.ReferencingWorkloads != nil { - in, out := &in.ReferencingWorkloads, &out.ReferencingWorkloads - *out = make([]WorkloadReference, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPOIDCConfigStatus. -func (in *MCPOIDCConfigStatus) DeepCopy() *MCPOIDCConfigStatus { - if in == nil { - return nil - } - out := new(MCPOIDCConfigStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistry) DeepCopyInto(out *MCPRegistry) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistry. -func (in *MCPRegistry) DeepCopy() *MCPRegistry { - if in == nil { - return nil - } - out := new(MCPRegistry) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPRegistry) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistryList) DeepCopyInto(out *MCPRegistryList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]MCPRegistry, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryList. -func (in *MCPRegistryList) DeepCopy() *MCPRegistryList { - if in == nil { - return nil - } - out := new(MCPRegistryList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPRegistryList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistrySpec) DeepCopyInto(out *MCPRegistrySpec) { - *out = *in - if in.Volumes != nil { - in, out := &in.Volumes, &out.Volumes - *out = make([]apiextensionsv1.JSON, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.VolumeMounts != nil { - in, out := &in.VolumeMounts, &out.VolumeMounts - *out = make([]apiextensionsv1.JSON, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.PGPassSecretRef != nil { - in, out := &in.PGPassSecretRef, &out.PGPassSecretRef - *out = new(corev1.SecretKeySelector) - (*in).DeepCopyInto(*out) - } - if in.PodTemplateSpec != nil { - in, out := &in.PodTemplateSpec, &out.PodTemplateSpec - *out = new(runtime.RawExtension) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistrySpec. -func (in *MCPRegistrySpec) DeepCopy() *MCPRegistrySpec { - if in == nil { - return nil - } - out := new(MCPRegistrySpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistryStatus) DeepCopyInto(out *MCPRegistryStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryStatus. -func (in *MCPRegistryStatus) DeepCopy() *MCPRegistryStatus { - if in == nil { - return nil - } - out := new(MCPRegistryStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRemoteProxy) DeepCopyInto(out *MCPRemoteProxy) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRemoteProxy. -func (in *MCPRemoteProxy) DeepCopy() *MCPRemoteProxy { - if in == nil { - return nil - } - out := new(MCPRemoteProxy) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPRemoteProxy) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRemoteProxyList) DeepCopyInto(out *MCPRemoteProxyList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]MCPRemoteProxy, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRemoteProxyList. -func (in *MCPRemoteProxyList) DeepCopy() *MCPRemoteProxyList { - if in == nil { - return nil - } - out := new(MCPRemoteProxyList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPRemoteProxyList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRemoteProxySpec) DeepCopyInto(out *MCPRemoteProxySpec) { - *out = *in - if in.OIDCConfigRef != nil { - in, out := &in.OIDCConfigRef, &out.OIDCConfigRef - *out = new(MCPOIDCConfigReference) - (*in).DeepCopyInto(*out) - } - if in.ExternalAuthConfigRef != nil { - in, out := &in.ExternalAuthConfigRef, &out.ExternalAuthConfigRef - *out = new(ExternalAuthConfigRef) - **out = **in - } - if in.AuthServerRef != nil { - in, out := &in.AuthServerRef, &out.AuthServerRef - *out = new(AuthServerRef) - **out = **in - } - if in.HeaderForward != nil { - in, out := &in.HeaderForward, &out.HeaderForward - *out = new(HeaderForwardConfig) - (*in).DeepCopyInto(*out) - } - if in.AuthzConfig != nil { - in, out := &in.AuthzConfig, &out.AuthzConfig - *out = new(AuthzConfigRef) - (*in).DeepCopyInto(*out) - } - if in.Audit != nil { - in, out := &in.Audit, &out.Audit - *out = new(AuditConfig) - **out = **in - } - if in.ToolConfigRef != nil { - in, out := &in.ToolConfigRef, &out.ToolConfigRef - *out = new(ToolConfigRef) - **out = **in - } - if in.TelemetryConfigRef != nil { - in, out := &in.TelemetryConfigRef, &out.TelemetryConfigRef - *out = new(MCPTelemetryConfigReference) - **out = **in - } - out.Resources = in.Resources - if in.ServiceAccount != nil { - in, out := &in.ServiceAccount, &out.ServiceAccount - *out = new(string) - **out = **in - } - if in.ResourceOverrides != nil { - in, out := &in.ResourceOverrides, &out.ResourceOverrides - *out = new(ResourceOverrides) - (*in).DeepCopyInto(*out) - } - if in.GroupRef != nil { - in, out := &in.GroupRef, &out.GroupRef - *out = new(MCPGroupRef) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRemoteProxySpec. -func (in *MCPRemoteProxySpec) DeepCopy() *MCPRemoteProxySpec { - if in == nil { - return nil - } - out := new(MCPRemoteProxySpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRemoteProxyStatus) DeepCopyInto(out *MCPRemoteProxyStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRemoteProxyStatus. -func (in *MCPRemoteProxyStatus) DeepCopy() *MCPRemoteProxyStatus { - if in == nil { - return nil - } - out := new(MCPRemoteProxyStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPServer) DeepCopyInto(out *MCPServer) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServer. -func (in *MCPServer) DeepCopy() *MCPServer { - if in == nil { - return nil - } - out := new(MCPServer) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPServer) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPServerEntry) DeepCopyInto(out *MCPServerEntry) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerEntry. -func (in *MCPServerEntry) DeepCopy() *MCPServerEntry { - if in == nil { - return nil - } - out := new(MCPServerEntry) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPServerEntry) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPServerEntryList) DeepCopyInto(out *MCPServerEntryList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]MCPServerEntry, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerEntryList. -func (in *MCPServerEntryList) DeepCopy() *MCPServerEntryList { - if in == nil { - return nil - } - out := new(MCPServerEntryList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPServerEntryList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPServerEntrySpec) DeepCopyInto(out *MCPServerEntrySpec) { - *out = *in - if in.GroupRef != nil { - in, out := &in.GroupRef, &out.GroupRef - *out = new(MCPGroupRef) - **out = **in - } - if in.ExternalAuthConfigRef != nil { - in, out := &in.ExternalAuthConfigRef, &out.ExternalAuthConfigRef - *out = new(ExternalAuthConfigRef) - **out = **in - } - if in.HeaderForward != nil { - in, out := &in.HeaderForward, &out.HeaderForward - *out = new(HeaderForwardConfig) - (*in).DeepCopyInto(*out) - } - if in.CABundleRef != nil { - in, out := &in.CABundleRef, &out.CABundleRef - *out = new(CABundleSource) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerEntrySpec. -func (in *MCPServerEntrySpec) DeepCopy() *MCPServerEntrySpec { - if in == nil { - return nil - } - out := new(MCPServerEntrySpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPServerEntryStatus) DeepCopyInto(out *MCPServerEntryStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerEntryStatus. -func (in *MCPServerEntryStatus) DeepCopy() *MCPServerEntryStatus { - if in == nil { - return nil - } - out := new(MCPServerEntryStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPServerList) DeepCopyInto(out *MCPServerList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]MCPServer, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerList. -func (in *MCPServerList) DeepCopy() *MCPServerList { - if in == nil { - return nil - } - out := new(MCPServerList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPServerList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { - *out = *in - if in.Args != nil { - in, out := &in.Args, &out.Args - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Env != nil { - in, out := &in.Env, &out.Env - *out = make([]EnvVar, len(*in)) - copy(*out, *in) - } - if in.Volumes != nil { - in, out := &in.Volumes, &out.Volumes - *out = make([]Volume, len(*in)) - copy(*out, *in) - } - out.Resources = in.Resources - if in.Secrets != nil { - in, out := &in.Secrets, &out.Secrets - *out = make([]SecretRef, len(*in)) - copy(*out, *in) - } - if in.ServiceAccount != nil { - in, out := &in.ServiceAccount, &out.ServiceAccount - *out = new(string) - **out = **in - } - if in.PermissionProfile != nil { - in, out := &in.PermissionProfile, &out.PermissionProfile - *out = new(PermissionProfileRef) - **out = **in - } - if in.PodTemplateSpec != nil { - in, out := &in.PodTemplateSpec, &out.PodTemplateSpec - *out = new(runtime.RawExtension) - (*in).DeepCopyInto(*out) - } - if in.ResourceOverrides != nil { - in, out := &in.ResourceOverrides, &out.ResourceOverrides - *out = new(ResourceOverrides) - (*in).DeepCopyInto(*out) - } - if in.OIDCConfigRef != nil { - in, out := &in.OIDCConfigRef, &out.OIDCConfigRef - *out = new(MCPOIDCConfigReference) - (*in).DeepCopyInto(*out) - } - if in.AuthzConfig != nil { - in, out := &in.AuthzConfig, &out.AuthzConfig - *out = new(AuthzConfigRef) - (*in).DeepCopyInto(*out) - } - if in.Audit != nil { - in, out := &in.Audit, &out.Audit - *out = new(AuditConfig) - **out = **in - } - if in.ToolConfigRef != nil { - in, out := &in.ToolConfigRef, &out.ToolConfigRef - *out = new(ToolConfigRef) - **out = **in - } - if in.ExternalAuthConfigRef != nil { - in, out := &in.ExternalAuthConfigRef, &out.ExternalAuthConfigRef - *out = new(ExternalAuthConfigRef) - **out = **in - } - if in.AuthServerRef != nil { - in, out := &in.AuthServerRef, &out.AuthServerRef - *out = new(AuthServerRef) - **out = **in - } - if in.TelemetryConfigRef != nil { - in, out := &in.TelemetryConfigRef, &out.TelemetryConfigRef - *out = new(MCPTelemetryConfigReference) - **out = **in - } - if in.GroupRef != nil { - in, out := &in.GroupRef, &out.GroupRef - *out = new(MCPGroupRef) - **out = **in - } - if in.Replicas != nil { - in, out := &in.Replicas, &out.Replicas - *out = new(int32) - **out = **in - } - if in.BackendReplicas != nil { - in, out := &in.BackendReplicas, &out.BackendReplicas - *out = new(int32) - **out = **in - } - if in.SessionStorage != nil { - in, out := &in.SessionStorage, &out.SessionStorage - *out = new(SessionStorageConfig) - (*in).DeepCopyInto(*out) - } - if in.RateLimiting != nil { - in, out := &in.RateLimiting, &out.RateLimiting - *out = new(RateLimitConfig) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerSpec. -func (in *MCPServerSpec) DeepCopy() *MCPServerSpec { - if in == nil { - return nil - } - out := new(MCPServerSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPServerStatus) DeepCopyInto(out *MCPServerStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerStatus. -func (in *MCPServerStatus) DeepCopy() *MCPServerStatus { - if in == nil { - return nil - } - out := new(MCPServerStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPTelemetryConfig) DeepCopyInto(out *MCPTelemetryConfig) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPTelemetryConfig. -func (in *MCPTelemetryConfig) DeepCopy() *MCPTelemetryConfig { - if in == nil { - return nil - } - out := new(MCPTelemetryConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPTelemetryConfig) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPTelemetryConfigList) DeepCopyInto(out *MCPTelemetryConfigList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]MCPTelemetryConfig, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPTelemetryConfigList. -func (in *MCPTelemetryConfigList) DeepCopy() *MCPTelemetryConfigList { - if in == nil { - return nil - } - out := new(MCPTelemetryConfigList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPTelemetryConfigList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPTelemetryConfigReference) DeepCopyInto(out *MCPTelemetryConfigReference) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPTelemetryConfigReference. -func (in *MCPTelemetryConfigReference) DeepCopy() *MCPTelemetryConfigReference { - if in == nil { - return nil - } - out := new(MCPTelemetryConfigReference) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPTelemetryConfigSpec) DeepCopyInto(out *MCPTelemetryConfigSpec) { - *out = *in - if in.OpenTelemetry != nil { - in, out := &in.OpenTelemetry, &out.OpenTelemetry - *out = new(MCPTelemetryOTelConfig) - (*in).DeepCopyInto(*out) - } - if in.Prometheus != nil { - in, out := &in.Prometheus, &out.Prometheus - *out = new(PrometheusConfig) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPTelemetryConfigSpec. -func (in *MCPTelemetryConfigSpec) DeepCopy() *MCPTelemetryConfigSpec { - if in == nil { - return nil - } - out := new(MCPTelemetryConfigSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPTelemetryConfigStatus) DeepCopyInto(out *MCPTelemetryConfigStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.ReferencingWorkloads != nil { - in, out := &in.ReferencingWorkloads, &out.ReferencingWorkloads - *out = make([]WorkloadReference, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPTelemetryConfigStatus. -func (in *MCPTelemetryConfigStatus) DeepCopy() *MCPTelemetryConfigStatus { - if in == nil { - return nil - } - out := new(MCPTelemetryConfigStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPTelemetryOTelConfig) DeepCopyInto(out *MCPTelemetryOTelConfig) { - *out = *in - if in.Headers != nil { - in, out := &in.Headers, &out.Headers - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.SensitiveHeaders != nil { - in, out := &in.SensitiveHeaders, &out.SensitiveHeaders - *out = make([]SensitiveHeader, len(*in)) - copy(*out, *in) - } - if in.ResourceAttributes != nil { - in, out := &in.ResourceAttributes, &out.ResourceAttributes - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Metrics != nil { - in, out := &in.Metrics, &out.Metrics - *out = new(OpenTelemetryMetricsConfig) - **out = **in - } - if in.Tracing != nil { - in, out := &in.Tracing, &out.Tracing - *out = new(OpenTelemetryTracingConfig) - **out = **in - } - if in.CABundleRef != nil { - in, out := &in.CABundleRef, &out.CABundleRef - *out = new(CABundleSource) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPTelemetryOTelConfig. -func (in *MCPTelemetryOTelConfig) DeepCopy() *MCPTelemetryOTelConfig { - if in == nil { - return nil - } - out := new(MCPTelemetryOTelConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPToolConfig) DeepCopyInto(out *MCPToolConfig) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPToolConfig. -func (in *MCPToolConfig) DeepCopy() *MCPToolConfig { - if in == nil { - return nil - } - out := new(MCPToolConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPToolConfig) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPToolConfigList) DeepCopyInto(out *MCPToolConfigList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]MCPToolConfig, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPToolConfigList. -func (in *MCPToolConfigList) DeepCopy() *MCPToolConfigList { - if in == nil { - return nil - } - out := new(MCPToolConfigList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *MCPToolConfigList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPToolConfigSpec) DeepCopyInto(out *MCPToolConfigSpec) { - *out = *in - if in.ToolsFilter != nil { - in, out := &in.ToolsFilter, &out.ToolsFilter - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.ToolsOverride != nil { - in, out := &in.ToolsOverride, &out.ToolsOverride - *out = make(map[string]ToolOverride, len(*in)) - for key, val := range *in { - (*out)[key] = *val.DeepCopy() - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPToolConfigSpec. -func (in *MCPToolConfigSpec) DeepCopy() *MCPToolConfigSpec { - if in == nil { - return nil - } - out := new(MCPToolConfigSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPToolConfigStatus) DeepCopyInto(out *MCPToolConfigStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.ReferencingWorkloads != nil { - in, out := &in.ReferencingWorkloads, &out.ReferencingWorkloads - *out = make([]WorkloadReference, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPToolConfigStatus. -func (in *MCPToolConfigStatus) DeepCopy() *MCPToolConfigStatus { - if in == nil { - return nil - } - out := new(MCPToolConfigStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ModelCacheConfig) DeepCopyInto(out *ModelCacheConfig) { - *out = *in - if in.StorageClassName != nil { - in, out := &in.StorageClassName, &out.StorageClassName - *out = new(string) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModelCacheConfig. -func (in *ModelCacheConfig) DeepCopy() *ModelCacheConfig { - if in == nil { - return nil - } - out := new(ModelCacheConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NetworkPermissions) DeepCopyInto(out *NetworkPermissions) { - *out = *in - if in.Outbound != nil { - in, out := &in.Outbound, &out.Outbound - *out = new(OutboundNetworkPermissions) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkPermissions. -func (in *NetworkPermissions) DeepCopy() *NetworkPermissions { - if in == nil { - return nil - } - out := new(NetworkPermissions) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *OAuth2UpstreamConfig) DeepCopyInto(out *OAuth2UpstreamConfig) { - *out = *in - if in.UserInfo != nil { - in, out := &in.UserInfo, &out.UserInfo - *out = new(UserInfoConfig) - (*in).DeepCopyInto(*out) - } - if in.ClientSecretRef != nil { - in, out := &in.ClientSecretRef, &out.ClientSecretRef - *out = new(SecretKeyRef) - **out = **in - } - if in.Scopes != nil { - in, out := &in.Scopes, &out.Scopes - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.TokenResponseMapping != nil { - in, out := &in.TokenResponseMapping, &out.TokenResponseMapping - *out = new(TokenResponseMapping) - **out = **in - } - if in.AdditionalAuthorizationParams != nil { - in, out := &in.AdditionalAuthorizationParams, &out.AdditionalAuthorizationParams - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OAuth2UpstreamConfig. -func (in *OAuth2UpstreamConfig) DeepCopy() *OAuth2UpstreamConfig { - if in == nil { - return nil - } - out := new(OAuth2UpstreamConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *OIDCUpstreamConfig) DeepCopyInto(out *OIDCUpstreamConfig) { - *out = *in - if in.ClientSecretRef != nil { - in, out := &in.ClientSecretRef, &out.ClientSecretRef - *out = new(SecretKeyRef) - **out = **in - } - if in.Scopes != nil { - in, out := &in.Scopes, &out.Scopes - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.UserInfoOverride != nil { - in, out := &in.UserInfoOverride, &out.UserInfoOverride - *out = new(UserInfoConfig) - (*in).DeepCopyInto(*out) - } - if in.AdditionalAuthorizationParams != nil { - in, out := &in.AdditionalAuthorizationParams, &out.AdditionalAuthorizationParams - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OIDCUpstreamConfig. -func (in *OIDCUpstreamConfig) DeepCopy() *OIDCUpstreamConfig { - if in == nil { - return nil - } - out := new(OIDCUpstreamConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *OpenTelemetryMetricsConfig) DeepCopyInto(out *OpenTelemetryMetricsConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenTelemetryMetricsConfig. -func (in *OpenTelemetryMetricsConfig) DeepCopy() *OpenTelemetryMetricsConfig { - if in == nil { - return nil - } - out := new(OpenTelemetryMetricsConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *OpenTelemetryTracingConfig) DeepCopyInto(out *OpenTelemetryTracingConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenTelemetryTracingConfig. -func (in *OpenTelemetryTracingConfig) DeepCopy() *OpenTelemetryTracingConfig { - if in == nil { - return nil - } - out := new(OpenTelemetryTracingConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *OutboundNetworkPermissions) DeepCopyInto(out *OutboundNetworkPermissions) { - *out = *in - if in.AllowHost != nil { - in, out := &in.AllowHost, &out.AllowHost - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.AllowPort != nil { - in, out := &in.AllowPort, &out.AllowPort - *out = make([]int32, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OutboundNetworkPermissions. -func (in *OutboundNetworkPermissions) DeepCopy() *OutboundNetworkPermissions { - if in == nil { - return nil - } - out := new(OutboundNetworkPermissions) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *OutgoingAuthConfig) DeepCopyInto(out *OutgoingAuthConfig) { - *out = *in - if in.Default != nil { - in, out := &in.Default, &out.Default - *out = new(BackendAuthConfig) - (*in).DeepCopyInto(*out) - } - if in.Backends != nil { - in, out := &in.Backends, &out.Backends - *out = make(map[string]BackendAuthConfig, len(*in)) - for key, val := range *in { - (*out)[key] = *val.DeepCopy() - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OutgoingAuthConfig. -func (in *OutgoingAuthConfig) DeepCopy() *OutgoingAuthConfig { - if in == nil { - return nil - } - out := new(OutgoingAuthConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *PermissionProfileRef) DeepCopyInto(out *PermissionProfileRef) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PermissionProfileRef. -func (in *PermissionProfileRef) DeepCopy() *PermissionProfileRef { - if in == nil { - return nil - } - out := new(PermissionProfileRef) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *PermissionProfileSpec) DeepCopyInto(out *PermissionProfileSpec) { - *out = *in - if in.Read != nil { - in, out := &in.Read, &out.Read - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Write != nil { - in, out := &in.Write, &out.Write - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Network != nil { - in, out := &in.Network, &out.Network - *out = new(NetworkPermissions) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PermissionProfileSpec. -func (in *PermissionProfileSpec) DeepCopy() *PermissionProfileSpec { - if in == nil { - return nil - } - out := new(PermissionProfileSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *PrometheusConfig) DeepCopyInto(out *PrometheusConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrometheusConfig. -func (in *PrometheusConfig) DeepCopy() *PrometheusConfig { - if in == nil { - return nil - } - out := new(PrometheusConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ProxyDeploymentOverrides) DeepCopyInto(out *ProxyDeploymentOverrides) { - *out = *in - in.ResourceMetadataOverrides.DeepCopyInto(&out.ResourceMetadataOverrides) - if in.PodTemplateMetadataOverrides != nil { - in, out := &in.PodTemplateMetadataOverrides, &out.PodTemplateMetadataOverrides - *out = new(ResourceMetadataOverrides) - (*in).DeepCopyInto(*out) - } - if in.Env != nil { - in, out := &in.Env, &out.Env - *out = make([]EnvVar, len(*in)) - copy(*out, *in) - } - if in.ImagePullSecrets != nil { - in, out := &in.ImagePullSecrets, &out.ImagePullSecrets - *out = make([]corev1.LocalObjectReference, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyDeploymentOverrides. -func (in *ProxyDeploymentOverrides) DeepCopy() *ProxyDeploymentOverrides { - if in == nil { - return nil - } - out := new(ProxyDeploymentOverrides) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RateLimitBucket) DeepCopyInto(out *RateLimitBucket) { - *out = *in - out.RefillPeriod = in.RefillPeriod -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RateLimitBucket. -func (in *RateLimitBucket) DeepCopy() *RateLimitBucket { - if in == nil { - return nil - } - out := new(RateLimitBucket) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RateLimitConfig) DeepCopyInto(out *RateLimitConfig) { - *out = *in - if in.Shared != nil { - in, out := &in.Shared, &out.Shared - *out = new(RateLimitBucket) - **out = **in - } - if in.PerUser != nil { - in, out := &in.PerUser, &out.PerUser - *out = new(RateLimitBucket) - **out = **in - } - if in.Tools != nil { - in, out := &in.Tools, &out.Tools - *out = make([]ToolRateLimitConfig, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RateLimitConfig. -func (in *RateLimitConfig) DeepCopy() *RateLimitConfig { - if in == nil { - return nil - } - out := new(RateLimitConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RedisACLUserConfig) DeepCopyInto(out *RedisACLUserConfig) { - *out = *in - if in.UsernameSecretRef != nil { - in, out := &in.UsernameSecretRef, &out.UsernameSecretRef - *out = new(SecretKeyRef) - **out = **in - } - if in.PasswordSecretRef != nil { - in, out := &in.PasswordSecretRef, &out.PasswordSecretRef - *out = new(SecretKeyRef) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisACLUserConfig. -func (in *RedisACLUserConfig) DeepCopy() *RedisACLUserConfig { - if in == nil { - return nil - } - out := new(RedisACLUserConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RedisSentinelConfig) DeepCopyInto(out *RedisSentinelConfig) { - *out = *in - if in.SentinelAddrs != nil { - in, out := &in.SentinelAddrs, &out.SentinelAddrs - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.SentinelService != nil { - in, out := &in.SentinelService, &out.SentinelService - *out = new(SentinelServiceRef) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisSentinelConfig. -func (in *RedisSentinelConfig) DeepCopy() *RedisSentinelConfig { - if in == nil { - return nil - } - out := new(RedisSentinelConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RedisStorageConfig) DeepCopyInto(out *RedisStorageConfig) { - *out = *in - if in.SentinelConfig != nil { - in, out := &in.SentinelConfig, &out.SentinelConfig - *out = new(RedisSentinelConfig) - (*in).DeepCopyInto(*out) - } - if in.ACLUserConfig != nil { - in, out := &in.ACLUserConfig, &out.ACLUserConfig - *out = new(RedisACLUserConfig) - (*in).DeepCopyInto(*out) - } - if in.TLS != nil { - in, out := &in.TLS, &out.TLS - *out = new(RedisTLSConfig) - (*in).DeepCopyInto(*out) - } - if in.SentinelTLS != nil { - in, out := &in.SentinelTLS, &out.SentinelTLS - *out = new(RedisTLSConfig) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisStorageConfig. -func (in *RedisStorageConfig) DeepCopy() *RedisStorageConfig { - if in == nil { - return nil - } - out := new(RedisStorageConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RedisTLSConfig) DeepCopyInto(out *RedisTLSConfig) { - *out = *in - if in.CACertSecretRef != nil { - in, out := &in.CACertSecretRef, &out.CACertSecretRef - *out = new(SecretKeyRef) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisTLSConfig. -func (in *RedisTLSConfig) DeepCopy() *RedisTLSConfig { - if in == nil { - return nil - } - out := new(RedisTLSConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ResourceList) DeepCopyInto(out *ResourceList) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceList. -func (in *ResourceList) DeepCopy() *ResourceList { - if in == nil { - return nil - } - out := new(ResourceList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ResourceMetadataOverrides) DeepCopyInto(out *ResourceMetadataOverrides) { - *out = *in - if in.Annotations != nil { - in, out := &in.Annotations, &out.Annotations - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Labels != nil { - in, out := &in.Labels, &out.Labels - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceMetadataOverrides. -func (in *ResourceMetadataOverrides) DeepCopy() *ResourceMetadataOverrides { - if in == nil { - return nil - } - out := new(ResourceMetadataOverrides) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ResourceOverrides) DeepCopyInto(out *ResourceOverrides) { - *out = *in - if in.ProxyDeployment != nil { - in, out := &in.ProxyDeployment, &out.ProxyDeployment - *out = new(ProxyDeploymentOverrides) - (*in).DeepCopyInto(*out) - } - if in.ProxyService != nil { - in, out := &in.ProxyService, &out.ProxyService - *out = new(ResourceMetadataOverrides) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceOverrides. -func (in *ResourceOverrides) DeepCopy() *ResourceOverrides { - if in == nil { - return nil - } - out := new(ResourceOverrides) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ResourceRequirements) DeepCopyInto(out *ResourceRequirements) { - *out = *in - out.Limits = in.Limits - out.Requests = in.Requests -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceRequirements. -func (in *ResourceRequirements) DeepCopy() *ResourceRequirements { - if in == nil { - return nil - } - out := new(ResourceRequirements) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RoleMapping) DeepCopyInto(out *RoleMapping) { - *out = *in - if in.Priority != nil { - in, out := &in.Priority, &out.Priority - *out = new(int32) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoleMapping. -func (in *RoleMapping) DeepCopy() *RoleMapping { - if in == nil { - return nil - } - out := new(RoleMapping) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SecretKeyRef) DeepCopyInto(out *SecretKeyRef) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeyRef. -func (in *SecretKeyRef) DeepCopy() *SecretKeyRef { - if in == nil { - return nil - } - out := new(SecretKeyRef) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SecretRef) DeepCopyInto(out *SecretRef) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretRef. -func (in *SecretRef) DeepCopy() *SecretRef { - if in == nil { - return nil - } - out := new(SecretRef) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SensitiveHeader) DeepCopyInto(out *SensitiveHeader) { - *out = *in - out.SecretKeyRef = in.SecretKeyRef -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SensitiveHeader. -func (in *SensitiveHeader) DeepCopy() *SensitiveHeader { - if in == nil { - return nil - } - out := new(SensitiveHeader) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SentinelServiceRef) DeepCopyInto(out *SentinelServiceRef) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SentinelServiceRef. -func (in *SentinelServiceRef) DeepCopy() *SentinelServiceRef { - if in == nil { - return nil - } - out := new(SentinelServiceRef) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SessionStorageConfig) DeepCopyInto(out *SessionStorageConfig) { - *out = *in - if in.PasswordRef != nil { - in, out := &in.PasswordRef, &out.PasswordRef - *out = new(SecretKeyRef) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SessionStorageConfig. -func (in *SessionStorageConfig) DeepCopy() *SessionStorageConfig { - if in == nil { - return nil - } - out := new(SessionStorageConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TokenExchangeConfig) DeepCopyInto(out *TokenExchangeConfig) { - *out = *in - if in.ClientSecretRef != nil { - in, out := &in.ClientSecretRef, &out.ClientSecretRef - *out = new(SecretKeyRef) - **out = **in - } - if in.Scopes != nil { - in, out := &in.Scopes, &out.Scopes - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TokenExchangeConfig. -func (in *TokenExchangeConfig) DeepCopy() *TokenExchangeConfig { - if in == nil { - return nil - } - out := new(TokenExchangeConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TokenLifespanConfig) DeepCopyInto(out *TokenLifespanConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TokenLifespanConfig. -func (in *TokenLifespanConfig) DeepCopy() *TokenLifespanConfig { - if in == nil { - return nil - } - out := new(TokenLifespanConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TokenResponseMapping) DeepCopyInto(out *TokenResponseMapping) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TokenResponseMapping. -func (in *TokenResponseMapping) DeepCopy() *TokenResponseMapping { - if in == nil { - return nil - } - out := new(TokenResponseMapping) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ToolAnnotationsOverride) DeepCopyInto(out *ToolAnnotationsOverride) { - *out = *in - if in.Title != nil { - in, out := &in.Title, &out.Title - *out = new(string) - **out = **in - } - if in.ReadOnlyHint != nil { - in, out := &in.ReadOnlyHint, &out.ReadOnlyHint - *out = new(bool) - **out = **in - } - if in.DestructiveHint != nil { - in, out := &in.DestructiveHint, &out.DestructiveHint - *out = new(bool) - **out = **in - } - if in.IdempotentHint != nil { - in, out := &in.IdempotentHint, &out.IdempotentHint - *out = new(bool) - **out = **in - } - if in.OpenWorldHint != nil { - in, out := &in.OpenWorldHint, &out.OpenWorldHint - *out = new(bool) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolAnnotationsOverride. -func (in *ToolAnnotationsOverride) DeepCopy() *ToolAnnotationsOverride { - if in == nil { - return nil - } - out := new(ToolAnnotationsOverride) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ToolConfigRef) DeepCopyInto(out *ToolConfigRef) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolConfigRef. -func (in *ToolConfigRef) DeepCopy() *ToolConfigRef { - if in == nil { - return nil - } - out := new(ToolConfigRef) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ToolOverride) DeepCopyInto(out *ToolOverride) { +func (in *MCPRemoteProxy) DeepCopyInto(out *MCPRemoteProxy) { *out = *in - if in.Annotations != nil { - in, out := &in.Annotations, &out.Annotations - *out = new(ToolAnnotationsOverride) - (*in).DeepCopyInto(*out) - } + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolOverride. -func (in *ToolOverride) DeepCopy() *ToolOverride { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRemoteProxy. +func (in *MCPRemoteProxy) DeepCopy() *MCPRemoteProxy { if in == nil { return nil } - out := new(ToolOverride) + out := new(MCPRemoteProxy) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPRemoteProxy) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ToolRateLimitConfig) DeepCopyInto(out *ToolRateLimitConfig) { +func (in *MCPRemoteProxyList) DeepCopyInto(out *MCPRemoteProxyList) { *out = *in - if in.Shared != nil { - in, out := &in.Shared, &out.Shared - *out = new(RateLimitBucket) - **out = **in - } - if in.PerUser != nil { - in, out := &in.PerUser, &out.PerUser - *out = new(RateLimitBucket) - **out = **in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPRemoteProxy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolRateLimitConfig. -func (in *ToolRateLimitConfig) DeepCopy() *ToolRateLimitConfig { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRemoteProxyList. +func (in *MCPRemoteProxyList) DeepCopy() *MCPRemoteProxyList { if in == nil { return nil } - out := new(ToolRateLimitConfig) + out := new(MCPRemoteProxyList) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPRemoteProxyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *UpstreamInjectSpec) DeepCopyInto(out *UpstreamInjectSpec) { +func (in *MCPServer) DeepCopyInto(out *MCPServer) { *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamInjectSpec. -func (in *UpstreamInjectSpec) DeepCopy() *UpstreamInjectSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServer. +func (in *MCPServer) DeepCopy() *MCPServer { if in == nil { return nil } - out := new(UpstreamInjectSpec) + out := new(MCPServer) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPServer) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *UpstreamProviderConfig) DeepCopyInto(out *UpstreamProviderConfig) { +func (in *MCPServerEntry) DeepCopyInto(out *MCPServerEntry) { *out = *in - if in.OIDCConfig != nil { - in, out := &in.OIDCConfig, &out.OIDCConfig - *out = new(OIDCUpstreamConfig) - (*in).DeepCopyInto(*out) - } - if in.OAuth2Config != nil { - in, out := &in.OAuth2Config, &out.OAuth2Config - *out = new(OAuth2UpstreamConfig) - (*in).DeepCopyInto(*out) - } + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamProviderConfig. -func (in *UpstreamProviderConfig) DeepCopy() *UpstreamProviderConfig { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerEntry. +func (in *MCPServerEntry) DeepCopy() *MCPServerEntry { if in == nil { return nil } - out := new(UpstreamProviderConfig) + out := new(MCPServerEntry) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPServerEntry) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *UserInfoConfig) DeepCopyInto(out *UserInfoConfig) { +func (in *MCPServerEntryList) DeepCopyInto(out *MCPServerEntryList) { *out = *in - if in.AdditionalHeaders != nil { - in, out := &in.AdditionalHeaders, &out.AdditionalHeaders - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPServerEntry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.FieldMapping != nil { - in, out := &in.FieldMapping, &out.FieldMapping - *out = new(UserInfoFieldMapping) - (*in).DeepCopyInto(*out) - } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserInfoConfig. -func (in *UserInfoConfig) DeepCopy() *UserInfoConfig { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerEntryList. +func (in *MCPServerEntryList) DeepCopy() *MCPServerEntryList { if in == nil { return nil } - out := new(UserInfoConfig) + out := new(MCPServerEntryList) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPServerEntryList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *UserInfoFieldMapping) DeepCopyInto(out *UserInfoFieldMapping) { +func (in *MCPServerList) DeepCopyInto(out *MCPServerList) { *out = *in - if in.SubjectFields != nil { - in, out := &in.SubjectFields, &out.SubjectFields - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.NameFields != nil { - in, out := &in.NameFields, &out.NameFields - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.EmailFields != nil { - in, out := &in.EmailFields, &out.EmailFields - *out = make([]string, len(*in)) - copy(*out, *in) + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPServer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserInfoFieldMapping. -func (in *UserInfoFieldMapping) DeepCopy() *UserInfoFieldMapping { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerList. +func (in *MCPServerList) DeepCopy() *MCPServerList { if in == nil { return nil } - out := new(UserInfoFieldMapping) + out := new(MCPServerList) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPServerList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *VirtualMCPCompositeToolDefinition) DeepCopyInto(out *VirtualMCPCompositeToolDefinition) { +func (in *MCPTelemetryConfig) DeepCopyInto(out *MCPTelemetryConfig) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) @@ -2787,18 +505,18 @@ func (in *VirtualMCPCompositeToolDefinition) DeepCopyInto(out *VirtualMCPComposi in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPCompositeToolDefinition. -func (in *VirtualMCPCompositeToolDefinition) DeepCopy() *VirtualMCPCompositeToolDefinition { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPTelemetryConfig. +func (in *MCPTelemetryConfig) DeepCopy() *MCPTelemetryConfig { if in == nil { return nil } - out := new(VirtualMCPCompositeToolDefinition) + out := new(MCPTelemetryConfig) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *VirtualMCPCompositeToolDefinition) DeepCopyObject() runtime.Object { +func (in *MCPTelemetryConfig) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -2806,31 +524,31 @@ func (in *VirtualMCPCompositeToolDefinition) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *VirtualMCPCompositeToolDefinitionList) DeepCopyInto(out *VirtualMCPCompositeToolDefinitionList) { +func (in *MCPTelemetryConfigList) DeepCopyInto(out *MCPTelemetryConfigList) { *out = *in out.TypeMeta = in.TypeMeta in.ListMeta.DeepCopyInto(&out.ListMeta) if in.Items != nil { in, out := &in.Items, &out.Items - *out = make([]VirtualMCPCompositeToolDefinition, len(*in)) + *out = make([]MCPTelemetryConfig, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPCompositeToolDefinitionList. -func (in *VirtualMCPCompositeToolDefinitionList) DeepCopy() *VirtualMCPCompositeToolDefinitionList { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPTelemetryConfigList. +func (in *MCPTelemetryConfigList) DeepCopy() *MCPTelemetryConfigList { if in == nil { return nil } - out := new(VirtualMCPCompositeToolDefinitionList) + out := new(MCPTelemetryConfigList) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *VirtualMCPCompositeToolDefinitionList) DeepCopyObject() runtime.Object { +func (in *MCPTelemetryConfigList) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -2838,55 +556,66 @@ func (in *VirtualMCPCompositeToolDefinitionList) DeepCopyObject() runtime.Object } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *VirtualMCPCompositeToolDefinitionSpec) DeepCopyInto(out *VirtualMCPCompositeToolDefinitionSpec) { +func (in *MCPToolConfig) DeepCopyInto(out *MCPToolConfig) { *out = *in - in.CompositeToolConfig.DeepCopyInto(&out.CompositeToolConfig) + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPCompositeToolDefinitionSpec. -func (in *VirtualMCPCompositeToolDefinitionSpec) DeepCopy() *VirtualMCPCompositeToolDefinitionSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPToolConfig. +func (in *MCPToolConfig) DeepCopy() *MCPToolConfig { if in == nil { return nil } - out := new(VirtualMCPCompositeToolDefinitionSpec) + out := new(MCPToolConfig) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPToolConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *VirtualMCPCompositeToolDefinitionStatus) DeepCopyInto(out *VirtualMCPCompositeToolDefinitionStatus) { +func (in *MCPToolConfigList) DeepCopyInto(out *MCPToolConfigList) { *out = *in - if in.ValidationErrors != nil { - in, out := &in.ValidationErrors, &out.ValidationErrors - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.ReferencingVirtualServers != nil { - in, out := &in.ReferencingVirtualServers, &out.ReferencingVirtualServers - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPToolConfig, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPCompositeToolDefinitionStatus. -func (in *VirtualMCPCompositeToolDefinitionStatus) DeepCopy() *VirtualMCPCompositeToolDefinitionStatus { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPToolConfigList. +func (in *MCPToolConfigList) DeepCopy() *MCPToolConfigList { if in == nil { return nil } - out := new(VirtualMCPCompositeToolDefinitionStatus) + out := new(MCPToolConfigList) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPToolConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *VirtualMCPServer) DeepCopyInto(out *VirtualMCPServer) { +func (in *VirtualMCPCompositeToolDefinition) DeepCopyInto(out *VirtualMCPCompositeToolDefinition) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) @@ -2894,18 +623,18 @@ func (in *VirtualMCPServer) DeepCopyInto(out *VirtualMCPServer) { in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPServer. -func (in *VirtualMCPServer) DeepCopy() *VirtualMCPServer { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPCompositeToolDefinition. +func (in *VirtualMCPCompositeToolDefinition) DeepCopy() *VirtualMCPCompositeToolDefinition { if in == nil { return nil } - out := new(VirtualMCPServer) + out := new(VirtualMCPCompositeToolDefinition) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *VirtualMCPServer) DeepCopyObject() runtime.Object { +func (in *VirtualMCPCompositeToolDefinition) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -2913,31 +642,31 @@ func (in *VirtualMCPServer) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *VirtualMCPServerList) DeepCopyInto(out *VirtualMCPServerList) { +func (in *VirtualMCPCompositeToolDefinitionList) DeepCopyInto(out *VirtualMCPCompositeToolDefinitionList) { *out = *in out.TypeMeta = in.TypeMeta in.ListMeta.DeepCopyInto(&out.ListMeta) if in.Items != nil { in, out := &in.Items, &out.Items - *out = make([]VirtualMCPServer, len(*in)) + *out = make([]VirtualMCPCompositeToolDefinition, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPServerList. -func (in *VirtualMCPServerList) DeepCopy() *VirtualMCPServerList { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPCompositeToolDefinitionList. +func (in *VirtualMCPCompositeToolDefinitionList) DeepCopy() *VirtualMCPCompositeToolDefinitionList { if in == nil { return nil } - out := new(VirtualMCPServerList) + out := new(VirtualMCPCompositeToolDefinitionList) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *VirtualMCPServerList) DeepCopyObject() runtime.Object { +func (in *VirtualMCPCompositeToolDefinitionList) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -2945,126 +674,60 @@ func (in *VirtualMCPServerList) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *VirtualMCPServerSpec) DeepCopyInto(out *VirtualMCPServerSpec) { +func (in *VirtualMCPServer) DeepCopyInto(out *VirtualMCPServer) { *out = *in - if in.IncomingAuth != nil { - in, out := &in.IncomingAuth, &out.IncomingAuth - *out = new(IncomingAuthConfig) - (*in).DeepCopyInto(*out) - } - if in.OutgoingAuth != nil { - in, out := &in.OutgoingAuth, &out.OutgoingAuth - *out = new(OutgoingAuthConfig) - (*in).DeepCopyInto(*out) - } - if in.ServiceAccount != nil { - in, out := &in.ServiceAccount, &out.ServiceAccount - *out = new(string) - **out = **in - } - if in.PodTemplateSpec != nil { - in, out := &in.PodTemplateSpec, &out.PodTemplateSpec - *out = new(runtime.RawExtension) - (*in).DeepCopyInto(*out) - } - if in.GroupRef != nil { - in, out := &in.GroupRef, &out.GroupRef - *out = new(MCPGroupRef) - **out = **in - } - in.Config.DeepCopyInto(&out.Config) - if in.TelemetryConfigRef != nil { - in, out := &in.TelemetryConfigRef, &out.TelemetryConfigRef - *out = new(MCPTelemetryConfigReference) - **out = **in - } - if in.EmbeddingServerRef != nil { - in, out := &in.EmbeddingServerRef, &out.EmbeddingServerRef - *out = new(EmbeddingServerRef) - **out = **in - } - if in.AuthServerConfig != nil { - in, out := &in.AuthServerConfig, &out.AuthServerConfig - *out = new(EmbeddedAuthServerConfig) - (*in).DeepCopyInto(*out) - } - if in.Replicas != nil { - in, out := &in.Replicas, &out.Replicas - *out = new(int32) - **out = **in - } - if in.SessionStorage != nil { - in, out := &in.SessionStorage, &out.SessionStorage - *out = new(SessionStorageConfig) - (*in).DeepCopyInto(*out) - } + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPServerSpec. -func (in *VirtualMCPServerSpec) DeepCopy() *VirtualMCPServerSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPServer. +func (in *VirtualMCPServer) DeepCopy() *VirtualMCPServer { if in == nil { return nil } - out := new(VirtualMCPServerSpec) + out := new(VirtualMCPServer) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VirtualMCPServer) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *VirtualMCPServerStatus) DeepCopyInto(out *VirtualMCPServerStatus) { +func (in *VirtualMCPServerList) DeepCopyInto(out *VirtualMCPServerList) { *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.DiscoveredBackends != nil { - in, out := &in.DiscoveredBackends, &out.DiscoveredBackends - *out = make([]DiscoveredBackend, len(*in)) + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]VirtualMCPServer, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPServerStatus. -func (in *VirtualMCPServerStatus) DeepCopy() *VirtualMCPServerStatus { - if in == nil { - return nil - } - out := new(VirtualMCPServerStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Volume) DeepCopyInto(out *Volume) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Volume. -func (in *Volume) DeepCopy() *Volume { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPServerList. +func (in *VirtualMCPServerList) DeepCopy() *VirtualMCPServerList { if in == nil { return nil } - out := new(Volume) + out := new(VirtualMCPServerList) in.DeepCopyInto(out) return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *WorkloadReference) DeepCopyInto(out *WorkloadReference) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadReference. -func (in *WorkloadReference) DeepCopy() *WorkloadReference { - if in == nil { - return nil +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VirtualMCPServerList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c } - out := new(WorkloadReference) - in.DeepCopyInto(out) - return out + return nil } diff --git a/cmd/thv-operator/api/v1alpha1/conditions.go b/cmd/thv-operator/api/v1beta1/conditions.go similarity index 93% rename from cmd/thv-operator/api/v1alpha1/conditions.go rename to cmd/thv-operator/api/v1beta1/conditions.go index 432303cf84..e94aae68da 100644 --- a/cmd/thv-operator/api/v1alpha1/conditions.go +++ b/cmd/thv-operator/api/v1beta1/conditions.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 // Shared condition types used across config controllers. const ( diff --git a/cmd/thv-operator/api/v1alpha1/embeddingserver_types.go b/cmd/thv-operator/api/v1beta1/embeddingserver_types.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/embeddingserver_types.go rename to cmd/thv-operator/api/v1beta1/embeddingserver_types.go index 5353fa22b3..2b14b22c34 100644 --- a/cmd/thv-operator/api/v1alpha1/embeddingserver_types.go +++ b/cmd/thv-operator/api/v1beta1/embeddingserver_types.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -208,6 +208,7 @@ const ( ) //+kubebuilder:object:root=true +//+kubebuilder:storageversion //+kubebuilder:subresource:status //+kubebuilder:resource:shortName=emb;embedding,categories=toolhive //+kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.phase" diff --git a/cmd/thv-operator/api/v1beta1/groupversion_info.go b/cmd/thv-operator/api/v1beta1/groupversion_info.go new file mode 100644 index 0000000000..fc23da9fd4 --- /dev/null +++ b/cmd/thv-operator/api/v1beta1/groupversion_info.go @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package v1beta1 contains API Schema definitions for the toolhive v1beta1 API group +// +kubebuilder:object:generate=true +// +groupName=toolhive.stacklok.dev +package v1beta1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "toolhive.stacklok.dev", Version: "v1beta1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go rename to cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index 1dcdbf1e47..01558865d1 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( "fmt" @@ -766,6 +766,7 @@ type MCPExternalAuthConfigStatus struct { } // +kubebuilder:object:root=true +// +kubebuilder:storageversion // +kubebuilder:subresource:status // +kubebuilder:resource:shortName=extauth;mcpextauth,categories=toolhive // +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type` diff --git a/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types_test.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types_test.go rename to cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go index 07b0cdc8fa..a76ea721b8 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types_test.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( "testing" diff --git a/cmd/thv-operator/api/v1alpha1/mcpgroup_types.go b/cmd/thv-operator/api/v1beta1/mcpgroup_types.go similarity index 98% rename from cmd/thv-operator/api/v1alpha1/mcpgroup_types.go rename to cmd/thv-operator/api/v1beta1/mcpgroup_types.go index af6bebb9eb..a151448943 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpgroup_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpgroup_types.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -86,6 +86,7 @@ const ( ) //+kubebuilder:object:root=true +//+kubebuilder:storageversion //+kubebuilder:subresource:status //+kubebuilder:resource:shortName=mcpg;mcpgroup,categories=toolhive //+kubebuilder:printcolumn:name="Servers",type="integer",JSONPath=".status.serverCount" diff --git a/cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpoidcconfig_types.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go rename to cmd/thv-operator/api/v1beta1/mcpoidcconfig_types.go index 52545ff0ac..bd711bfd4f 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpoidcconfig_types.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( "fmt" @@ -194,6 +194,7 @@ type MCPOIDCConfigStatus struct { } // +kubebuilder:object:root=true +// +kubebuilder:storageversion // +kubebuilder:subresource:status // +kubebuilder:resource:shortName=mcpoidc,categories=toolhive // +kubebuilder:printcolumn:name="Source",type=string,JSONPath=`.spec.type` diff --git a/cmd/thv-operator/api/v1alpha1/mcpregistry_parse_test.go b/cmd/thv-operator/api/v1beta1/mcpregistry_parse_test.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/mcpregistry_parse_test.go rename to cmd/thv-operator/api/v1beta1/mcpregistry_parse_test.go index e41d0b4713..48dcd97fde 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpregistry_parse_test.go +++ b/cmd/thv-operator/api/v1beta1/mcpregistry_parse_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( "encoding/json" diff --git a/cmd/thv-operator/api/v1alpha1/mcpregistry_types.go b/cmd/thv-operator/api/v1beta1/mcpregistry_types.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/mcpregistry_types.go rename to cmd/thv-operator/api/v1beta1/mcpregistry_types.go index 9e8290e25d..a95fd5a775 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpregistry_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpregistry_types.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( "encoding/json" @@ -168,6 +168,7 @@ const ( ) //+kubebuilder:object:root=true +//+kubebuilder:storageversion //+kubebuilder:subresource:status //+kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.phase" //+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" diff --git a/cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go b/cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go rename to cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go index cc126eab8e..3b38d591a7 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -346,6 +346,7 @@ const ( ) //+kubebuilder:object:root=true +//+kubebuilder:storageversion //+kubebuilder:subresource:status //+kubebuilder:resource:shortName=rp;mcprp,categories=toolhive //+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase" diff --git a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go b/cmd/thv-operator/api/v1beta1/mcpserver_types.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/mcpserver_types.go rename to cmd/thv-operator/api/v1beta1/mcpserver_types.go index 0f1740f170..3a21eac58e 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpserver_types.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( corev1 "k8s.io/api/core/v1" @@ -867,6 +867,7 @@ const ( ) //+kubebuilder:object:root=true +//+kubebuilder:storageversion //+kubebuilder:subresource:status //+kubebuilder:resource:shortName=mcpserver;mcpservers,categories=toolhive //+kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.phase" diff --git a/cmd/thv-operator/api/v1alpha1/mcpserver_types_test.go b/cmd/thv-operator/api/v1beta1/mcpserver_types_test.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/mcpserver_types_test.go rename to cmd/thv-operator/api/v1beta1/mcpserver_types_test.go index b21ae1af95..0e69f33836 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpserver_types_test.go +++ b/cmd/thv-operator/api/v1beta1/mcpserver_types_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( "encoding/json" diff --git a/cmd/thv-operator/api/v1alpha1/mcpserverentry_types.go b/cmd/thv-operator/api/v1beta1/mcpserverentry_types.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/mcpserverentry_types.go rename to cmd/thv-operator/api/v1beta1/mcpserverentry_types.go index 60c089ccb8..6c5043d6e7 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpserverentry_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpserverentry_types.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -149,6 +149,7 @@ const ( ) //+kubebuilder:object:root=true +//+kubebuilder:storageversion //+kubebuilder:subresource:status //+kubebuilder:resource:shortName=mcpentry,categories=toolhive //+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase" diff --git a/cmd/thv-operator/api/v1alpha1/mcptelemetryconfig_types.go b/cmd/thv-operator/api/v1beta1/mcptelemetryconfig_types.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/mcptelemetryconfig_types.go rename to cmd/thv-operator/api/v1beta1/mcptelemetryconfig_types.go index cb1aa40e5e..7e1685227c 100644 --- a/cmd/thv-operator/api/v1alpha1/mcptelemetryconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcptelemetryconfig_types.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( "fmt" @@ -137,6 +137,7 @@ type MCPTelemetryConfigStatus struct { } // +kubebuilder:object:root=true +// +kubebuilder:storageversion // +kubebuilder:subresource:status // +kubebuilder:resource:shortName=mcpotel,categories=toolhive // +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.spec.openTelemetry.endpoint` diff --git a/cmd/thv-operator/api/v1alpha1/mcptelemetryconfig_types_test.go b/cmd/thv-operator/api/v1beta1/mcptelemetryconfig_types_test.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/mcptelemetryconfig_types_test.go rename to cmd/thv-operator/api/v1beta1/mcptelemetryconfig_types_test.go index 6392e2859a..2a05cc8dd3 100644 --- a/cmd/thv-operator/api/v1alpha1/mcptelemetryconfig_types_test.go +++ b/cmd/thv-operator/api/v1beta1/mcptelemetryconfig_types_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( "testing" diff --git a/cmd/thv-operator/api/v1alpha1/toolconfig_types.go b/cmd/thv-operator/api/v1beta1/toolconfig_types.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/toolconfig_types.go rename to cmd/thv-operator/api/v1beta1/toolconfig_types.go index d0490abe01..55e206b414 100644 --- a/cmd/thv-operator/api/v1alpha1/toolconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/toolconfig_types.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -106,6 +106,7 @@ type MCPToolConfigStatus struct { } // +kubebuilder:object:root=true +// +kubebuilder:storageversion // +kubebuilder:subresource:status // +kubebuilder:resource:shortName=tc;toolconfig,categories=toolhive // +kubebuilder:printcolumn:name="Valid",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status` diff --git a/cmd/thv-operator/api/v1alpha1/virtualmcpcompositetooldefinition_types.go b/cmd/thv-operator/api/v1beta1/virtualmcpcompositetooldefinition_types.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/virtualmcpcompositetooldefinition_types.go rename to cmd/thv-operator/api/v1beta1/virtualmcpcompositetooldefinition_types.go index 22bc507eeb..e2a4ec0da6 100644 --- a/cmd/thv-operator/api/v1alpha1/virtualmcpcompositetooldefinition_types.go +++ b/cmd/thv-operator/api/v1beta1/virtualmcpcompositetooldefinition_types.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -98,6 +98,7 @@ const ( ) //+kubebuilder:object:root=true +//+kubebuilder:storageversion //+kubebuilder:subresource:status //+kubebuilder:resource:shortName=vmcpctd;compositetool,categories=toolhive //+kubebuilder:printcolumn:name="Workflow",type="string",JSONPath=".spec.name",description="Workflow name" diff --git a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go rename to cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go index d71947b483..6a9f72b3d9 100644 --- a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go +++ b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( "fmt" @@ -390,6 +390,7 @@ const ( ) //+kubebuilder:object:root=true +//+kubebuilder:storageversion //+kubebuilder:subresource:status //+kubebuilder:resource:shortName=vmcp;virtualmcp,categories=toolhive //+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase",description="The phase of the VirtualMCPServer" diff --git a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types_test.go similarity index 99% rename from cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go rename to cmd/thv-operator/api/v1beta1/virtualmcpserver_types_test.go index 5d0e033b0d..01c93bdc32 100644 --- a/cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go +++ b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( "encoding/json" diff --git a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go new file mode 100644 index 0000000000..249470e7c2 --- /dev/null +++ b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go @@ -0,0 +1,3070 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2025 Stacklok + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1beta1 + +import ( + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AWSStsConfig) DeepCopyInto(out *AWSStsConfig) { + *out = *in + if in.RoleMappings != nil { + in, out := &in.RoleMappings, &out.RoleMappings + *out = make([]RoleMapping, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.SessionDuration != nil { + in, out := &in.SessionDuration, &out.SessionDuration + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSStsConfig. +func (in *AWSStsConfig) DeepCopy() *AWSStsConfig { + if in == nil { + return nil + } + out := new(AWSStsConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuditConfig) DeepCopyInto(out *AuditConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuditConfig. +func (in *AuditConfig) DeepCopy() *AuditConfig { + if in == nil { + return nil + } + out := new(AuditConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthServerRef) DeepCopyInto(out *AuthServerRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthServerRef. +func (in *AuthServerRef) DeepCopy() *AuthServerRef { + if in == nil { + return nil + } + out := new(AuthServerRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthServerStorageConfig) DeepCopyInto(out *AuthServerStorageConfig) { + *out = *in + if in.Redis != nil { + in, out := &in.Redis, &out.Redis + *out = new(RedisStorageConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthServerStorageConfig. +func (in *AuthServerStorageConfig) DeepCopy() *AuthServerStorageConfig { + if in == nil { + return nil + } + out := new(AuthServerStorageConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthzConfigRef) DeepCopyInto(out *AuthzConfigRef) { + *out = *in + if in.ConfigMap != nil { + in, out := &in.ConfigMap, &out.ConfigMap + *out = new(ConfigMapAuthzRef) + **out = **in + } + if in.Inline != nil { + in, out := &in.Inline, &out.Inline + *out = new(InlineAuthzConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthzConfigRef. +func (in *AuthzConfigRef) DeepCopy() *AuthzConfigRef { + if in == nil { + return nil + } + out := new(AuthzConfigRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BackendAuthConfig) DeepCopyInto(out *BackendAuthConfig) { + *out = *in + if in.ExternalAuthConfigRef != nil { + in, out := &in.ExternalAuthConfigRef, &out.ExternalAuthConfigRef + *out = new(ExternalAuthConfigRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendAuthConfig. +func (in *BackendAuthConfig) DeepCopy() *BackendAuthConfig { + if in == nil { + return nil + } + out := new(BackendAuthConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BearerTokenConfig) DeepCopyInto(out *BearerTokenConfig) { + *out = *in + if in.TokenSecretRef != nil { + in, out := &in.TokenSecretRef, &out.TokenSecretRef + *out = new(SecretKeyRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BearerTokenConfig. +func (in *BearerTokenConfig) DeepCopy() *BearerTokenConfig { + if in == nil { + return nil + } + out := new(BearerTokenConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CABundleSource) DeepCopyInto(out *CABundleSource) { + *out = *in + if in.ConfigMapRef != nil { + in, out := &in.ConfigMapRef, &out.ConfigMapRef + *out = new(corev1.ConfigMapKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CABundleSource. +func (in *CABundleSource) DeepCopy() *CABundleSource { + if in == nil { + return nil + } + out := new(CABundleSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConfigMapAuthzRef) DeepCopyInto(out *ConfigMapAuthzRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapAuthzRef. +func (in *ConfigMapAuthzRef) DeepCopy() *ConfigMapAuthzRef { + if in == nil { + return nil + } + out := new(ConfigMapAuthzRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EmbeddedAuthServerConfig) DeepCopyInto(out *EmbeddedAuthServerConfig) { + *out = *in + if in.SigningKeySecretRefs != nil { + in, out := &in.SigningKeySecretRefs, &out.SigningKeySecretRefs + *out = make([]SecretKeyRef, len(*in)) + copy(*out, *in) + } + if in.HMACSecretRefs != nil { + in, out := &in.HMACSecretRefs, &out.HMACSecretRefs + *out = make([]SecretKeyRef, len(*in)) + copy(*out, *in) + } + if in.TokenLifespans != nil { + in, out := &in.TokenLifespans, &out.TokenLifespans + *out = new(TokenLifespanConfig) + **out = **in + } + if in.UpstreamProviders != nil { + in, out := &in.UpstreamProviders, &out.UpstreamProviders + *out = make([]UpstreamProviderConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Storage != nil { + in, out := &in.Storage, &out.Storage + *out = new(AuthServerStorageConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddedAuthServerConfig. +func (in *EmbeddedAuthServerConfig) DeepCopy() *EmbeddedAuthServerConfig { + if in == nil { + return nil + } + out := new(EmbeddedAuthServerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EmbeddingResourceOverrides) DeepCopyInto(out *EmbeddingResourceOverrides) { + *out = *in + if in.StatefulSet != nil { + in, out := &in.StatefulSet, &out.StatefulSet + *out = new(EmbeddingStatefulSetOverrides) + (*in).DeepCopyInto(*out) + } + if in.Service != nil { + in, out := &in.Service, &out.Service + *out = new(ResourceMetadataOverrides) + (*in).DeepCopyInto(*out) + } + if in.PersistentVolumeClaim != nil { + in, out := &in.PersistentVolumeClaim, &out.PersistentVolumeClaim + *out = new(ResourceMetadataOverrides) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingResourceOverrides. +func (in *EmbeddingResourceOverrides) DeepCopy() *EmbeddingResourceOverrides { + if in == nil { + return nil + } + out := new(EmbeddingResourceOverrides) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EmbeddingServer) DeepCopyInto(out *EmbeddingServer) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingServer. +func (in *EmbeddingServer) DeepCopy() *EmbeddingServer { + if in == nil { + return nil + } + out := new(EmbeddingServer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EmbeddingServer) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EmbeddingServerList) DeepCopyInto(out *EmbeddingServerList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EmbeddingServer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingServerList. +func (in *EmbeddingServerList) DeepCopy() *EmbeddingServerList { + if in == nil { + return nil + } + out := new(EmbeddingServerList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EmbeddingServerList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EmbeddingServerRef) DeepCopyInto(out *EmbeddingServerRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingServerRef. +func (in *EmbeddingServerRef) DeepCopy() *EmbeddingServerRef { + if in == nil { + return nil + } + out := new(EmbeddingServerRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EmbeddingServerSpec) DeepCopyInto(out *EmbeddingServerSpec) { + *out = *in + if in.HFTokenSecretRef != nil { + in, out := &in.HFTokenSecretRef, &out.HFTokenSecretRef + *out = new(SecretKeyRef) + **out = **in + } + if in.Args != nil { + in, out := &in.Args, &out.Args + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make([]EnvVar, len(*in)) + copy(*out, *in) + } + out.Resources = in.Resources + if in.ModelCache != nil { + in, out := &in.ModelCache, &out.ModelCache + *out = new(ModelCacheConfig) + (*in).DeepCopyInto(*out) + } + if in.PodTemplateSpec != nil { + in, out := &in.PodTemplateSpec, &out.PodTemplateSpec + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } + if in.ResourceOverrides != nil { + in, out := &in.ResourceOverrides, &out.ResourceOverrides + *out = new(EmbeddingResourceOverrides) + (*in).DeepCopyInto(*out) + } + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingServerSpec. +func (in *EmbeddingServerSpec) DeepCopy() *EmbeddingServerSpec { + if in == nil { + return nil + } + out := new(EmbeddingServerSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EmbeddingServerStatus) DeepCopyInto(out *EmbeddingServerStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingServerStatus. +func (in *EmbeddingServerStatus) DeepCopy() *EmbeddingServerStatus { + if in == nil { + return nil + } + out := new(EmbeddingServerStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EmbeddingStatefulSetOverrides) DeepCopyInto(out *EmbeddingStatefulSetOverrides) { + *out = *in + in.ResourceMetadataOverrides.DeepCopyInto(&out.ResourceMetadataOverrides) + if in.PodTemplateMetadataOverrides != nil { + in, out := &in.PodTemplateMetadataOverrides, &out.PodTemplateMetadataOverrides + *out = new(ResourceMetadataOverrides) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EmbeddingStatefulSetOverrides. +func (in *EmbeddingStatefulSetOverrides) DeepCopy() *EmbeddingStatefulSetOverrides { + if in == nil { + return nil + } + out := new(EmbeddingStatefulSetOverrides) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EnvVar) DeepCopyInto(out *EnvVar) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvVar. +func (in *EnvVar) DeepCopy() *EnvVar { + if in == nil { + return nil + } + out := new(EnvVar) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalAuthConfigRef) DeepCopyInto(out *ExternalAuthConfigRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalAuthConfigRef. +func (in *ExternalAuthConfigRef) DeepCopy() *ExternalAuthConfigRef { + if in == nil { + return nil + } + out := new(ExternalAuthConfigRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HeaderForwardConfig) DeepCopyInto(out *HeaderForwardConfig) { + *out = *in + if in.AddPlaintextHeaders != nil { + in, out := &in.AddPlaintextHeaders, &out.AddPlaintextHeaders + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.AddHeadersFromSecret != nil { + in, out := &in.AddHeadersFromSecret, &out.AddHeadersFromSecret + *out = make([]HeaderFromSecret, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HeaderForwardConfig. +func (in *HeaderForwardConfig) DeepCopy() *HeaderForwardConfig { + if in == nil { + return nil + } + out := new(HeaderForwardConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HeaderFromSecret) DeepCopyInto(out *HeaderFromSecret) { + *out = *in + if in.ValueSecretRef != nil { + in, out := &in.ValueSecretRef, &out.ValueSecretRef + *out = new(SecretKeyRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HeaderFromSecret. +func (in *HeaderFromSecret) DeepCopy() *HeaderFromSecret { + if in == nil { + return nil + } + out := new(HeaderFromSecret) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HeaderInjectionConfig) DeepCopyInto(out *HeaderInjectionConfig) { + *out = *in + if in.ValueSecretRef != nil { + in, out := &in.ValueSecretRef, &out.ValueSecretRef + *out = new(SecretKeyRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HeaderInjectionConfig. +func (in *HeaderInjectionConfig) DeepCopy() *HeaderInjectionConfig { + if in == nil { + return nil + } + out := new(HeaderInjectionConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IncomingAuthConfig) DeepCopyInto(out *IncomingAuthConfig) { + *out = *in + if in.OIDCConfigRef != nil { + in, out := &in.OIDCConfigRef, &out.OIDCConfigRef + *out = new(MCPOIDCConfigReference) + (*in).DeepCopyInto(*out) + } + if in.AuthzConfig != nil { + in, out := &in.AuthzConfig, &out.AuthzConfig + *out = new(AuthzConfigRef) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IncomingAuthConfig. +func (in *IncomingAuthConfig) DeepCopy() *IncomingAuthConfig { + if in == nil { + return nil + } + out := new(IncomingAuthConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InlineAuthzConfig) DeepCopyInto(out *InlineAuthzConfig) { + *out = *in + if in.Policies != nil { + in, out := &in.Policies, &out.Policies + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InlineAuthzConfig. +func (in *InlineAuthzConfig) DeepCopy() *InlineAuthzConfig { + if in == nil { + return nil + } + out := new(InlineAuthzConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InlineOIDCSharedConfig) DeepCopyInto(out *InlineOIDCSharedConfig) { + *out = *in + if in.ClientSecretRef != nil { + in, out := &in.ClientSecretRef, &out.ClientSecretRef + *out = new(SecretKeyRef) + **out = **in + } + if in.CABundleRef != nil { + in, out := &in.CABundleRef, &out.CABundleRef + *out = new(CABundleSource) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InlineOIDCSharedConfig. +func (in *InlineOIDCSharedConfig) DeepCopy() *InlineOIDCSharedConfig { + if in == nil { + return nil + } + out := new(InlineOIDCSharedConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubernetesServiceAccountOIDCConfig) DeepCopyInto(out *KubernetesServiceAccountOIDCConfig) { + *out = *in + if in.UseClusterAuth != nil { + in, out := &in.UseClusterAuth, &out.UseClusterAuth + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesServiceAccountOIDCConfig. +func (in *KubernetesServiceAccountOIDCConfig) DeepCopy() *KubernetesServiceAccountOIDCConfig { + if in == nil { + return nil + } + out := new(KubernetesServiceAccountOIDCConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPExternalAuthConfig) DeepCopyInto(out *MCPExternalAuthConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfig. +func (in *MCPExternalAuthConfig) DeepCopy() *MCPExternalAuthConfig { + if in == nil { + return nil + } + out := new(MCPExternalAuthConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPExternalAuthConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPExternalAuthConfigList) DeepCopyInto(out *MCPExternalAuthConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPExternalAuthConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfigList. +func (in *MCPExternalAuthConfigList) DeepCopy() *MCPExternalAuthConfigList { + if in == nil { + return nil + } + out := new(MCPExternalAuthConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPExternalAuthConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPExternalAuthConfigSpec) DeepCopyInto(out *MCPExternalAuthConfigSpec) { + *out = *in + if in.TokenExchange != nil { + in, out := &in.TokenExchange, &out.TokenExchange + *out = new(TokenExchangeConfig) + (*in).DeepCopyInto(*out) + } + if in.HeaderInjection != nil { + in, out := &in.HeaderInjection, &out.HeaderInjection + *out = new(HeaderInjectionConfig) + (*in).DeepCopyInto(*out) + } + if in.BearerToken != nil { + in, out := &in.BearerToken, &out.BearerToken + *out = new(BearerTokenConfig) + (*in).DeepCopyInto(*out) + } + if in.EmbeddedAuthServer != nil { + in, out := &in.EmbeddedAuthServer, &out.EmbeddedAuthServer + *out = new(EmbeddedAuthServerConfig) + (*in).DeepCopyInto(*out) + } + if in.AWSSts != nil { + in, out := &in.AWSSts, &out.AWSSts + *out = new(AWSStsConfig) + (*in).DeepCopyInto(*out) + } + if in.UpstreamInject != nil { + in, out := &in.UpstreamInject, &out.UpstreamInject + *out = new(UpstreamInjectSpec) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfigSpec. +func (in *MCPExternalAuthConfigSpec) DeepCopy() *MCPExternalAuthConfigSpec { + if in == nil { + return nil + } + out := new(MCPExternalAuthConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPExternalAuthConfigStatus) DeepCopyInto(out *MCPExternalAuthConfigStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ReferencingWorkloads != nil { + in, out := &in.ReferencingWorkloads, &out.ReferencingWorkloads + *out = make([]WorkloadReference, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfigStatus. +func (in *MCPExternalAuthConfigStatus) DeepCopy() *MCPExternalAuthConfigStatus { + if in == nil { + return nil + } + out := new(MCPExternalAuthConfigStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPGroup) DeepCopyInto(out *MCPGroup) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPGroup. +func (in *MCPGroup) DeepCopy() *MCPGroup { + if in == nil { + return nil + } + out := new(MCPGroup) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPGroup) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPGroupList) DeepCopyInto(out *MCPGroupList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPGroup, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPGroupList. +func (in *MCPGroupList) DeepCopy() *MCPGroupList { + if in == nil { + return nil + } + out := new(MCPGroupList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPGroupList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPGroupRef) DeepCopyInto(out *MCPGroupRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPGroupRef. +func (in *MCPGroupRef) DeepCopy() *MCPGroupRef { + if in == nil { + return nil + } + out := new(MCPGroupRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPGroupSpec) DeepCopyInto(out *MCPGroupSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPGroupSpec. +func (in *MCPGroupSpec) DeepCopy() *MCPGroupSpec { + if in == nil { + return nil + } + out := new(MCPGroupSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPGroupStatus) DeepCopyInto(out *MCPGroupStatus) { + *out = *in + if in.Servers != nil { + in, out := &in.Servers, &out.Servers + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.RemoteProxies != nil { + in, out := &in.RemoteProxies, &out.RemoteProxies + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Entries != nil { + in, out := &in.Entries, &out.Entries + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPGroupStatus. +func (in *MCPGroupStatus) DeepCopy() *MCPGroupStatus { + if in == nil { + return nil + } + out := new(MCPGroupStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPOIDCConfig) DeepCopyInto(out *MCPOIDCConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPOIDCConfig. +func (in *MCPOIDCConfig) DeepCopy() *MCPOIDCConfig { + if in == nil { + return nil + } + out := new(MCPOIDCConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPOIDCConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPOIDCConfigList) DeepCopyInto(out *MCPOIDCConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPOIDCConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPOIDCConfigList. +func (in *MCPOIDCConfigList) DeepCopy() *MCPOIDCConfigList { + if in == nil { + return nil + } + out := new(MCPOIDCConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPOIDCConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPOIDCConfigReference) DeepCopyInto(out *MCPOIDCConfigReference) { + *out = *in + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPOIDCConfigReference. +func (in *MCPOIDCConfigReference) DeepCopy() *MCPOIDCConfigReference { + if in == nil { + return nil + } + out := new(MCPOIDCConfigReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPOIDCConfigSpec) DeepCopyInto(out *MCPOIDCConfigSpec) { + *out = *in + if in.KubernetesServiceAccount != nil { + in, out := &in.KubernetesServiceAccount, &out.KubernetesServiceAccount + *out = new(KubernetesServiceAccountOIDCConfig) + (*in).DeepCopyInto(*out) + } + if in.Inline != nil { + in, out := &in.Inline, &out.Inline + *out = new(InlineOIDCSharedConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPOIDCConfigSpec. +func (in *MCPOIDCConfigSpec) DeepCopy() *MCPOIDCConfigSpec { + if in == nil { + return nil + } + out := new(MCPOIDCConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPOIDCConfigStatus) DeepCopyInto(out *MCPOIDCConfigStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ReferencingWorkloads != nil { + in, out := &in.ReferencingWorkloads, &out.ReferencingWorkloads + *out = make([]WorkloadReference, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPOIDCConfigStatus. +func (in *MCPOIDCConfigStatus) DeepCopy() *MCPOIDCConfigStatus { + if in == nil { + return nil + } + out := new(MCPOIDCConfigStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPRegistry) DeepCopyInto(out *MCPRegistry) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistry. +func (in *MCPRegistry) DeepCopy() *MCPRegistry { + if in == nil { + return nil + } + out := new(MCPRegistry) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPRegistry) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPRegistryList) DeepCopyInto(out *MCPRegistryList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPRegistry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryList. +func (in *MCPRegistryList) DeepCopy() *MCPRegistryList { + if in == nil { + return nil + } + out := new(MCPRegistryList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPRegistryList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPRegistrySpec) DeepCopyInto(out *MCPRegistrySpec) { + *out = *in + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = make([]apiextensionsv1.JSON, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.VolumeMounts != nil { + in, out := &in.VolumeMounts, &out.VolumeMounts + *out = make([]apiextensionsv1.JSON, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.PGPassSecretRef != nil { + in, out := &in.PGPassSecretRef, &out.PGPassSecretRef + *out = new(corev1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } + if in.PodTemplateSpec != nil { + in, out := &in.PodTemplateSpec, &out.PodTemplateSpec + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistrySpec. +func (in *MCPRegistrySpec) DeepCopy() *MCPRegistrySpec { + if in == nil { + return nil + } + out := new(MCPRegistrySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPRegistryStatus) DeepCopyInto(out *MCPRegistryStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryStatus. +func (in *MCPRegistryStatus) DeepCopy() *MCPRegistryStatus { + if in == nil { + return nil + } + out := new(MCPRegistryStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPRemoteProxy) DeepCopyInto(out *MCPRemoteProxy) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRemoteProxy. +func (in *MCPRemoteProxy) DeepCopy() *MCPRemoteProxy { + if in == nil { + return nil + } + out := new(MCPRemoteProxy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPRemoteProxy) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPRemoteProxyList) DeepCopyInto(out *MCPRemoteProxyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPRemoteProxy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRemoteProxyList. +func (in *MCPRemoteProxyList) DeepCopy() *MCPRemoteProxyList { + if in == nil { + return nil + } + out := new(MCPRemoteProxyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPRemoteProxyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPRemoteProxySpec) DeepCopyInto(out *MCPRemoteProxySpec) { + *out = *in + if in.OIDCConfigRef != nil { + in, out := &in.OIDCConfigRef, &out.OIDCConfigRef + *out = new(MCPOIDCConfigReference) + (*in).DeepCopyInto(*out) + } + if in.ExternalAuthConfigRef != nil { + in, out := &in.ExternalAuthConfigRef, &out.ExternalAuthConfigRef + *out = new(ExternalAuthConfigRef) + **out = **in + } + if in.AuthServerRef != nil { + in, out := &in.AuthServerRef, &out.AuthServerRef + *out = new(AuthServerRef) + **out = **in + } + if in.HeaderForward != nil { + in, out := &in.HeaderForward, &out.HeaderForward + *out = new(HeaderForwardConfig) + (*in).DeepCopyInto(*out) + } + if in.AuthzConfig != nil { + in, out := &in.AuthzConfig, &out.AuthzConfig + *out = new(AuthzConfigRef) + (*in).DeepCopyInto(*out) + } + if in.Audit != nil { + in, out := &in.Audit, &out.Audit + *out = new(AuditConfig) + **out = **in + } + if in.ToolConfigRef != nil { + in, out := &in.ToolConfigRef, &out.ToolConfigRef + *out = new(ToolConfigRef) + **out = **in + } + if in.TelemetryConfigRef != nil { + in, out := &in.TelemetryConfigRef, &out.TelemetryConfigRef + *out = new(MCPTelemetryConfigReference) + **out = **in + } + out.Resources = in.Resources + if in.ServiceAccount != nil { + in, out := &in.ServiceAccount, &out.ServiceAccount + *out = new(string) + **out = **in + } + if in.ResourceOverrides != nil { + in, out := &in.ResourceOverrides, &out.ResourceOverrides + *out = new(ResourceOverrides) + (*in).DeepCopyInto(*out) + } + if in.GroupRef != nil { + in, out := &in.GroupRef, &out.GroupRef + *out = new(MCPGroupRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRemoteProxySpec. +func (in *MCPRemoteProxySpec) DeepCopy() *MCPRemoteProxySpec { + if in == nil { + return nil + } + out := new(MCPRemoteProxySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPRemoteProxyStatus) DeepCopyInto(out *MCPRemoteProxyStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRemoteProxyStatus. +func (in *MCPRemoteProxyStatus) DeepCopy() *MCPRemoteProxyStatus { + if in == nil { + return nil + } + out := new(MCPRemoteProxyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPServer) DeepCopyInto(out *MCPServer) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServer. +func (in *MCPServer) DeepCopy() *MCPServer { + if in == nil { + return nil + } + out := new(MCPServer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPServer) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPServerEntry) DeepCopyInto(out *MCPServerEntry) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerEntry. +func (in *MCPServerEntry) DeepCopy() *MCPServerEntry { + if in == nil { + return nil + } + out := new(MCPServerEntry) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPServerEntry) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPServerEntryList) DeepCopyInto(out *MCPServerEntryList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPServerEntry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerEntryList. +func (in *MCPServerEntryList) DeepCopy() *MCPServerEntryList { + if in == nil { + return nil + } + out := new(MCPServerEntryList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPServerEntryList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPServerEntrySpec) DeepCopyInto(out *MCPServerEntrySpec) { + *out = *in + if in.GroupRef != nil { + in, out := &in.GroupRef, &out.GroupRef + *out = new(MCPGroupRef) + **out = **in + } + if in.ExternalAuthConfigRef != nil { + in, out := &in.ExternalAuthConfigRef, &out.ExternalAuthConfigRef + *out = new(ExternalAuthConfigRef) + **out = **in + } + if in.HeaderForward != nil { + in, out := &in.HeaderForward, &out.HeaderForward + *out = new(HeaderForwardConfig) + (*in).DeepCopyInto(*out) + } + if in.CABundleRef != nil { + in, out := &in.CABundleRef, &out.CABundleRef + *out = new(CABundleSource) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerEntrySpec. +func (in *MCPServerEntrySpec) DeepCopy() *MCPServerEntrySpec { + if in == nil { + return nil + } + out := new(MCPServerEntrySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPServerEntryStatus) DeepCopyInto(out *MCPServerEntryStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerEntryStatus. +func (in *MCPServerEntryStatus) DeepCopy() *MCPServerEntryStatus { + if in == nil { + return nil + } + out := new(MCPServerEntryStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPServerList) DeepCopyInto(out *MCPServerList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPServer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerList. +func (in *MCPServerList) DeepCopy() *MCPServerList { + if in == nil { + return nil + } + out := new(MCPServerList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPServerList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { + *out = *in + if in.Args != nil { + in, out := &in.Args, &out.Args + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make([]EnvVar, len(*in)) + copy(*out, *in) + } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = make([]Volume, len(*in)) + copy(*out, *in) + } + out.Resources = in.Resources + if in.Secrets != nil { + in, out := &in.Secrets, &out.Secrets + *out = make([]SecretRef, len(*in)) + copy(*out, *in) + } + if in.ServiceAccount != nil { + in, out := &in.ServiceAccount, &out.ServiceAccount + *out = new(string) + **out = **in + } + if in.PermissionProfile != nil { + in, out := &in.PermissionProfile, &out.PermissionProfile + *out = new(PermissionProfileRef) + **out = **in + } + if in.PodTemplateSpec != nil { + in, out := &in.PodTemplateSpec, &out.PodTemplateSpec + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } + if in.ResourceOverrides != nil { + in, out := &in.ResourceOverrides, &out.ResourceOverrides + *out = new(ResourceOverrides) + (*in).DeepCopyInto(*out) + } + if in.OIDCConfigRef != nil { + in, out := &in.OIDCConfigRef, &out.OIDCConfigRef + *out = new(MCPOIDCConfigReference) + (*in).DeepCopyInto(*out) + } + if in.AuthzConfig != nil { + in, out := &in.AuthzConfig, &out.AuthzConfig + *out = new(AuthzConfigRef) + (*in).DeepCopyInto(*out) + } + if in.Audit != nil { + in, out := &in.Audit, &out.Audit + *out = new(AuditConfig) + **out = **in + } + if in.ToolConfigRef != nil { + in, out := &in.ToolConfigRef, &out.ToolConfigRef + *out = new(ToolConfigRef) + **out = **in + } + if in.ExternalAuthConfigRef != nil { + in, out := &in.ExternalAuthConfigRef, &out.ExternalAuthConfigRef + *out = new(ExternalAuthConfigRef) + **out = **in + } + if in.AuthServerRef != nil { + in, out := &in.AuthServerRef, &out.AuthServerRef + *out = new(AuthServerRef) + **out = **in + } + if in.TelemetryConfigRef != nil { + in, out := &in.TelemetryConfigRef, &out.TelemetryConfigRef + *out = new(MCPTelemetryConfigReference) + **out = **in + } + if in.GroupRef != nil { + in, out := &in.GroupRef, &out.GroupRef + *out = new(MCPGroupRef) + **out = **in + } + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } + if in.BackendReplicas != nil { + in, out := &in.BackendReplicas, &out.BackendReplicas + *out = new(int32) + **out = **in + } + if in.SessionStorage != nil { + in, out := &in.SessionStorage, &out.SessionStorage + *out = new(SessionStorageConfig) + (*in).DeepCopyInto(*out) + } + if in.RateLimiting != nil { + in, out := &in.RateLimiting, &out.RateLimiting + *out = new(RateLimitConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerSpec. +func (in *MCPServerSpec) DeepCopy() *MCPServerSpec { + if in == nil { + return nil + } + out := new(MCPServerSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPServerStatus) DeepCopyInto(out *MCPServerStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerStatus. +func (in *MCPServerStatus) DeepCopy() *MCPServerStatus { + if in == nil { + return nil + } + out := new(MCPServerStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPTelemetryConfig) DeepCopyInto(out *MCPTelemetryConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPTelemetryConfig. +func (in *MCPTelemetryConfig) DeepCopy() *MCPTelemetryConfig { + if in == nil { + return nil + } + out := new(MCPTelemetryConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPTelemetryConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPTelemetryConfigList) DeepCopyInto(out *MCPTelemetryConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPTelemetryConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPTelemetryConfigList. +func (in *MCPTelemetryConfigList) DeepCopy() *MCPTelemetryConfigList { + if in == nil { + return nil + } + out := new(MCPTelemetryConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPTelemetryConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPTelemetryConfigReference) DeepCopyInto(out *MCPTelemetryConfigReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPTelemetryConfigReference. +func (in *MCPTelemetryConfigReference) DeepCopy() *MCPTelemetryConfigReference { + if in == nil { + return nil + } + out := new(MCPTelemetryConfigReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPTelemetryConfigSpec) DeepCopyInto(out *MCPTelemetryConfigSpec) { + *out = *in + if in.OpenTelemetry != nil { + in, out := &in.OpenTelemetry, &out.OpenTelemetry + *out = new(MCPTelemetryOTelConfig) + (*in).DeepCopyInto(*out) + } + if in.Prometheus != nil { + in, out := &in.Prometheus, &out.Prometheus + *out = new(PrometheusConfig) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPTelemetryConfigSpec. +func (in *MCPTelemetryConfigSpec) DeepCopy() *MCPTelemetryConfigSpec { + if in == nil { + return nil + } + out := new(MCPTelemetryConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPTelemetryConfigStatus) DeepCopyInto(out *MCPTelemetryConfigStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ReferencingWorkloads != nil { + in, out := &in.ReferencingWorkloads, &out.ReferencingWorkloads + *out = make([]WorkloadReference, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPTelemetryConfigStatus. +func (in *MCPTelemetryConfigStatus) DeepCopy() *MCPTelemetryConfigStatus { + if in == nil { + return nil + } + out := new(MCPTelemetryConfigStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPTelemetryOTelConfig) DeepCopyInto(out *MCPTelemetryOTelConfig) { + *out = *in + if in.Headers != nil { + in, out := &in.Headers, &out.Headers + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.SensitiveHeaders != nil { + in, out := &in.SensitiveHeaders, &out.SensitiveHeaders + *out = make([]SensitiveHeader, len(*in)) + copy(*out, *in) + } + if in.ResourceAttributes != nil { + in, out := &in.ResourceAttributes, &out.ResourceAttributes + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Metrics != nil { + in, out := &in.Metrics, &out.Metrics + *out = new(OpenTelemetryMetricsConfig) + **out = **in + } + if in.Tracing != nil { + in, out := &in.Tracing, &out.Tracing + *out = new(OpenTelemetryTracingConfig) + **out = **in + } + if in.CABundleRef != nil { + in, out := &in.CABundleRef, &out.CABundleRef + *out = new(CABundleSource) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPTelemetryOTelConfig. +func (in *MCPTelemetryOTelConfig) DeepCopy() *MCPTelemetryOTelConfig { + if in == nil { + return nil + } + out := new(MCPTelemetryOTelConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPToolConfig) DeepCopyInto(out *MCPToolConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPToolConfig. +func (in *MCPToolConfig) DeepCopy() *MCPToolConfig { + if in == nil { + return nil + } + out := new(MCPToolConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPToolConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPToolConfigList) DeepCopyInto(out *MCPToolConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPToolConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPToolConfigList. +func (in *MCPToolConfigList) DeepCopy() *MCPToolConfigList { + if in == nil { + return nil + } + out := new(MCPToolConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPToolConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPToolConfigSpec) DeepCopyInto(out *MCPToolConfigSpec) { + *out = *in + if in.ToolsFilter != nil { + in, out := &in.ToolsFilter, &out.ToolsFilter + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ToolsOverride != nil { + in, out := &in.ToolsOverride, &out.ToolsOverride + *out = make(map[string]ToolOverride, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPToolConfigSpec. +func (in *MCPToolConfigSpec) DeepCopy() *MCPToolConfigSpec { + if in == nil { + return nil + } + out := new(MCPToolConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPToolConfigStatus) DeepCopyInto(out *MCPToolConfigStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ReferencingWorkloads != nil { + in, out := &in.ReferencingWorkloads, &out.ReferencingWorkloads + *out = make([]WorkloadReference, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPToolConfigStatus. +func (in *MCPToolConfigStatus) DeepCopy() *MCPToolConfigStatus { + if in == nil { + return nil + } + out := new(MCPToolConfigStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModelCacheConfig) DeepCopyInto(out *ModelCacheConfig) { + *out = *in + if in.StorageClassName != nil { + in, out := &in.StorageClassName, &out.StorageClassName + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModelCacheConfig. +func (in *ModelCacheConfig) DeepCopy() *ModelCacheConfig { + if in == nil { + return nil + } + out := new(ModelCacheConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkPermissions) DeepCopyInto(out *NetworkPermissions) { + *out = *in + if in.Outbound != nil { + in, out := &in.Outbound, &out.Outbound + *out = new(OutboundNetworkPermissions) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkPermissions. +func (in *NetworkPermissions) DeepCopy() *NetworkPermissions { + if in == nil { + return nil + } + out := new(NetworkPermissions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OAuth2UpstreamConfig) DeepCopyInto(out *OAuth2UpstreamConfig) { + *out = *in + if in.UserInfo != nil { + in, out := &in.UserInfo, &out.UserInfo + *out = new(UserInfoConfig) + (*in).DeepCopyInto(*out) + } + if in.ClientSecretRef != nil { + in, out := &in.ClientSecretRef, &out.ClientSecretRef + *out = new(SecretKeyRef) + **out = **in + } + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.TokenResponseMapping != nil { + in, out := &in.TokenResponseMapping, &out.TokenResponseMapping + *out = new(TokenResponseMapping) + **out = **in + } + if in.AdditionalAuthorizationParams != nil { + in, out := &in.AdditionalAuthorizationParams, &out.AdditionalAuthorizationParams + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OAuth2UpstreamConfig. +func (in *OAuth2UpstreamConfig) DeepCopy() *OAuth2UpstreamConfig { + if in == nil { + return nil + } + out := new(OAuth2UpstreamConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OIDCUpstreamConfig) DeepCopyInto(out *OIDCUpstreamConfig) { + *out = *in + if in.ClientSecretRef != nil { + in, out := &in.ClientSecretRef, &out.ClientSecretRef + *out = new(SecretKeyRef) + **out = **in + } + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.UserInfoOverride != nil { + in, out := &in.UserInfoOverride, &out.UserInfoOverride + *out = new(UserInfoConfig) + (*in).DeepCopyInto(*out) + } + if in.AdditionalAuthorizationParams != nil { + in, out := &in.AdditionalAuthorizationParams, &out.AdditionalAuthorizationParams + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OIDCUpstreamConfig. +func (in *OIDCUpstreamConfig) DeepCopy() *OIDCUpstreamConfig { + if in == nil { + return nil + } + out := new(OIDCUpstreamConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenTelemetryMetricsConfig) DeepCopyInto(out *OpenTelemetryMetricsConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenTelemetryMetricsConfig. +func (in *OpenTelemetryMetricsConfig) DeepCopy() *OpenTelemetryMetricsConfig { + if in == nil { + return nil + } + out := new(OpenTelemetryMetricsConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenTelemetryTracingConfig) DeepCopyInto(out *OpenTelemetryTracingConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenTelemetryTracingConfig. +func (in *OpenTelemetryTracingConfig) DeepCopy() *OpenTelemetryTracingConfig { + if in == nil { + return nil + } + out := new(OpenTelemetryTracingConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OutboundNetworkPermissions) DeepCopyInto(out *OutboundNetworkPermissions) { + *out = *in + if in.AllowHost != nil { + in, out := &in.AllowHost, &out.AllowHost + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AllowPort != nil { + in, out := &in.AllowPort, &out.AllowPort + *out = make([]int32, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OutboundNetworkPermissions. +func (in *OutboundNetworkPermissions) DeepCopy() *OutboundNetworkPermissions { + if in == nil { + return nil + } + out := new(OutboundNetworkPermissions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OutgoingAuthConfig) DeepCopyInto(out *OutgoingAuthConfig) { + *out = *in + if in.Default != nil { + in, out := &in.Default, &out.Default + *out = new(BackendAuthConfig) + (*in).DeepCopyInto(*out) + } + if in.Backends != nil { + in, out := &in.Backends, &out.Backends + *out = make(map[string]BackendAuthConfig, len(*in)) + for key, val := range *in { + (*out)[key] = *val.DeepCopy() + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OutgoingAuthConfig. +func (in *OutgoingAuthConfig) DeepCopy() *OutgoingAuthConfig { + if in == nil { + return nil + } + out := new(OutgoingAuthConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PermissionProfileRef) DeepCopyInto(out *PermissionProfileRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PermissionProfileRef. +func (in *PermissionProfileRef) DeepCopy() *PermissionProfileRef { + if in == nil { + return nil + } + out := new(PermissionProfileRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PermissionProfileSpec) DeepCopyInto(out *PermissionProfileSpec) { + *out = *in + if in.Read != nil { + in, out := &in.Read, &out.Read + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Write != nil { + in, out := &in.Write, &out.Write + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Network != nil { + in, out := &in.Network, &out.Network + *out = new(NetworkPermissions) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PermissionProfileSpec. +func (in *PermissionProfileSpec) DeepCopy() *PermissionProfileSpec { + if in == nil { + return nil + } + out := new(PermissionProfileSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PrometheusConfig) DeepCopyInto(out *PrometheusConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrometheusConfig. +func (in *PrometheusConfig) DeepCopy() *PrometheusConfig { + if in == nil { + return nil + } + out := new(PrometheusConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProxyDeploymentOverrides) DeepCopyInto(out *ProxyDeploymentOverrides) { + *out = *in + in.ResourceMetadataOverrides.DeepCopyInto(&out.ResourceMetadataOverrides) + if in.PodTemplateMetadataOverrides != nil { + in, out := &in.PodTemplateMetadataOverrides, &out.PodTemplateMetadataOverrides + *out = new(ResourceMetadataOverrides) + (*in).DeepCopyInto(*out) + } + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make([]EnvVar, len(*in)) + copy(*out, *in) + } + if in.ImagePullSecrets != nil { + in, out := &in.ImagePullSecrets, &out.ImagePullSecrets + *out = make([]corev1.LocalObjectReference, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyDeploymentOverrides. +func (in *ProxyDeploymentOverrides) DeepCopy() *ProxyDeploymentOverrides { + if in == nil { + return nil + } + out := new(ProxyDeploymentOverrides) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RateLimitBucket) DeepCopyInto(out *RateLimitBucket) { + *out = *in + out.RefillPeriod = in.RefillPeriod +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RateLimitBucket. +func (in *RateLimitBucket) DeepCopy() *RateLimitBucket { + if in == nil { + return nil + } + out := new(RateLimitBucket) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RateLimitConfig) DeepCopyInto(out *RateLimitConfig) { + *out = *in + if in.Shared != nil { + in, out := &in.Shared, &out.Shared + *out = new(RateLimitBucket) + **out = **in + } + if in.PerUser != nil { + in, out := &in.PerUser, &out.PerUser + *out = new(RateLimitBucket) + **out = **in + } + if in.Tools != nil { + in, out := &in.Tools, &out.Tools + *out = make([]ToolRateLimitConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RateLimitConfig. +func (in *RateLimitConfig) DeepCopy() *RateLimitConfig { + if in == nil { + return nil + } + out := new(RateLimitConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedisACLUserConfig) DeepCopyInto(out *RedisACLUserConfig) { + *out = *in + if in.UsernameSecretRef != nil { + in, out := &in.UsernameSecretRef, &out.UsernameSecretRef + *out = new(SecretKeyRef) + **out = **in + } + if in.PasswordSecretRef != nil { + in, out := &in.PasswordSecretRef, &out.PasswordSecretRef + *out = new(SecretKeyRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisACLUserConfig. +func (in *RedisACLUserConfig) DeepCopy() *RedisACLUserConfig { + if in == nil { + return nil + } + out := new(RedisACLUserConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedisSentinelConfig) DeepCopyInto(out *RedisSentinelConfig) { + *out = *in + if in.SentinelAddrs != nil { + in, out := &in.SentinelAddrs, &out.SentinelAddrs + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.SentinelService != nil { + in, out := &in.SentinelService, &out.SentinelService + *out = new(SentinelServiceRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisSentinelConfig. +func (in *RedisSentinelConfig) DeepCopy() *RedisSentinelConfig { + if in == nil { + return nil + } + out := new(RedisSentinelConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedisStorageConfig) DeepCopyInto(out *RedisStorageConfig) { + *out = *in + if in.SentinelConfig != nil { + in, out := &in.SentinelConfig, &out.SentinelConfig + *out = new(RedisSentinelConfig) + (*in).DeepCopyInto(*out) + } + if in.ACLUserConfig != nil { + in, out := &in.ACLUserConfig, &out.ACLUserConfig + *out = new(RedisACLUserConfig) + (*in).DeepCopyInto(*out) + } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(RedisTLSConfig) + (*in).DeepCopyInto(*out) + } + if in.SentinelTLS != nil { + in, out := &in.SentinelTLS, &out.SentinelTLS + *out = new(RedisTLSConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisStorageConfig. +func (in *RedisStorageConfig) DeepCopy() *RedisStorageConfig { + if in == nil { + return nil + } + out := new(RedisStorageConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedisTLSConfig) DeepCopyInto(out *RedisTLSConfig) { + *out = *in + if in.CACertSecretRef != nil { + in, out := &in.CACertSecretRef, &out.CACertSecretRef + *out = new(SecretKeyRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisTLSConfig. +func (in *RedisTLSConfig) DeepCopy() *RedisTLSConfig { + if in == nil { + return nil + } + out := new(RedisTLSConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceList) DeepCopyInto(out *ResourceList) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceList. +func (in *ResourceList) DeepCopy() *ResourceList { + if in == nil { + return nil + } + out := new(ResourceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceMetadataOverrides) DeepCopyInto(out *ResourceMetadataOverrides) { + *out = *in + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceMetadataOverrides. +func (in *ResourceMetadataOverrides) DeepCopy() *ResourceMetadataOverrides { + if in == nil { + return nil + } + out := new(ResourceMetadataOverrides) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceOverrides) DeepCopyInto(out *ResourceOverrides) { + *out = *in + if in.ProxyDeployment != nil { + in, out := &in.ProxyDeployment, &out.ProxyDeployment + *out = new(ProxyDeploymentOverrides) + (*in).DeepCopyInto(*out) + } + if in.ProxyService != nil { + in, out := &in.ProxyService, &out.ProxyService + *out = new(ResourceMetadataOverrides) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceOverrides. +func (in *ResourceOverrides) DeepCopy() *ResourceOverrides { + if in == nil { + return nil + } + out := new(ResourceOverrides) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceRequirements) DeepCopyInto(out *ResourceRequirements) { + *out = *in + out.Limits = in.Limits + out.Requests = in.Requests +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceRequirements. +func (in *ResourceRequirements) DeepCopy() *ResourceRequirements { + if in == nil { + return nil + } + out := new(ResourceRequirements) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RoleMapping) DeepCopyInto(out *RoleMapping) { + *out = *in + if in.Priority != nil { + in, out := &in.Priority, &out.Priority + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoleMapping. +func (in *RoleMapping) DeepCopy() *RoleMapping { + if in == nil { + return nil + } + out := new(RoleMapping) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretKeyRef) DeepCopyInto(out *SecretKeyRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeyRef. +func (in *SecretKeyRef) DeepCopy() *SecretKeyRef { + if in == nil { + return nil + } + out := new(SecretKeyRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretRef) DeepCopyInto(out *SecretRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretRef. +func (in *SecretRef) DeepCopy() *SecretRef { + if in == nil { + return nil + } + out := new(SecretRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SensitiveHeader) DeepCopyInto(out *SensitiveHeader) { + *out = *in + out.SecretKeyRef = in.SecretKeyRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SensitiveHeader. +func (in *SensitiveHeader) DeepCopy() *SensitiveHeader { + if in == nil { + return nil + } + out := new(SensitiveHeader) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SentinelServiceRef) DeepCopyInto(out *SentinelServiceRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SentinelServiceRef. +func (in *SentinelServiceRef) DeepCopy() *SentinelServiceRef { + if in == nil { + return nil + } + out := new(SentinelServiceRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SessionStorageConfig) DeepCopyInto(out *SessionStorageConfig) { + *out = *in + if in.PasswordRef != nil { + in, out := &in.PasswordRef, &out.PasswordRef + *out = new(SecretKeyRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SessionStorageConfig. +func (in *SessionStorageConfig) DeepCopy() *SessionStorageConfig { + if in == nil { + return nil + } + out := new(SessionStorageConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TokenExchangeConfig) DeepCopyInto(out *TokenExchangeConfig) { + *out = *in + if in.ClientSecretRef != nil { + in, out := &in.ClientSecretRef, &out.ClientSecretRef + *out = new(SecretKeyRef) + **out = **in + } + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TokenExchangeConfig. +func (in *TokenExchangeConfig) DeepCopy() *TokenExchangeConfig { + if in == nil { + return nil + } + out := new(TokenExchangeConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TokenLifespanConfig) DeepCopyInto(out *TokenLifespanConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TokenLifespanConfig. +func (in *TokenLifespanConfig) DeepCopy() *TokenLifespanConfig { + if in == nil { + return nil + } + out := new(TokenLifespanConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TokenResponseMapping) DeepCopyInto(out *TokenResponseMapping) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TokenResponseMapping. +func (in *TokenResponseMapping) DeepCopy() *TokenResponseMapping { + if in == nil { + return nil + } + out := new(TokenResponseMapping) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToolAnnotationsOverride) DeepCopyInto(out *ToolAnnotationsOverride) { + *out = *in + if in.Title != nil { + in, out := &in.Title, &out.Title + *out = new(string) + **out = **in + } + if in.ReadOnlyHint != nil { + in, out := &in.ReadOnlyHint, &out.ReadOnlyHint + *out = new(bool) + **out = **in + } + if in.DestructiveHint != nil { + in, out := &in.DestructiveHint, &out.DestructiveHint + *out = new(bool) + **out = **in + } + if in.IdempotentHint != nil { + in, out := &in.IdempotentHint, &out.IdempotentHint + *out = new(bool) + **out = **in + } + if in.OpenWorldHint != nil { + in, out := &in.OpenWorldHint, &out.OpenWorldHint + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolAnnotationsOverride. +func (in *ToolAnnotationsOverride) DeepCopy() *ToolAnnotationsOverride { + if in == nil { + return nil + } + out := new(ToolAnnotationsOverride) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToolConfigRef) DeepCopyInto(out *ToolConfigRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolConfigRef. +func (in *ToolConfigRef) DeepCopy() *ToolConfigRef { + if in == nil { + return nil + } + out := new(ToolConfigRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToolOverride) DeepCopyInto(out *ToolOverride) { + *out = *in + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = new(ToolAnnotationsOverride) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolOverride. +func (in *ToolOverride) DeepCopy() *ToolOverride { + if in == nil { + return nil + } + out := new(ToolOverride) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToolRateLimitConfig) DeepCopyInto(out *ToolRateLimitConfig) { + *out = *in + if in.Shared != nil { + in, out := &in.Shared, &out.Shared + *out = new(RateLimitBucket) + **out = **in + } + if in.PerUser != nil { + in, out := &in.PerUser, &out.PerUser + *out = new(RateLimitBucket) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolRateLimitConfig. +func (in *ToolRateLimitConfig) DeepCopy() *ToolRateLimitConfig { + if in == nil { + return nil + } + out := new(ToolRateLimitConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpstreamInjectSpec) DeepCopyInto(out *UpstreamInjectSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamInjectSpec. +func (in *UpstreamInjectSpec) DeepCopy() *UpstreamInjectSpec { + if in == nil { + return nil + } + out := new(UpstreamInjectSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpstreamProviderConfig) DeepCopyInto(out *UpstreamProviderConfig) { + *out = *in + if in.OIDCConfig != nil { + in, out := &in.OIDCConfig, &out.OIDCConfig + *out = new(OIDCUpstreamConfig) + (*in).DeepCopyInto(*out) + } + if in.OAuth2Config != nil { + in, out := &in.OAuth2Config, &out.OAuth2Config + *out = new(OAuth2UpstreamConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpstreamProviderConfig. +func (in *UpstreamProviderConfig) DeepCopy() *UpstreamProviderConfig { + if in == nil { + return nil + } + out := new(UpstreamProviderConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UserInfoConfig) DeepCopyInto(out *UserInfoConfig) { + *out = *in + if in.AdditionalHeaders != nil { + in, out := &in.AdditionalHeaders, &out.AdditionalHeaders + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.FieldMapping != nil { + in, out := &in.FieldMapping, &out.FieldMapping + *out = new(UserInfoFieldMapping) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserInfoConfig. +func (in *UserInfoConfig) DeepCopy() *UserInfoConfig { + if in == nil { + return nil + } + out := new(UserInfoConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UserInfoFieldMapping) DeepCopyInto(out *UserInfoFieldMapping) { + *out = *in + if in.SubjectFields != nil { + in, out := &in.SubjectFields, &out.SubjectFields + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.NameFields != nil { + in, out := &in.NameFields, &out.NameFields + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.EmailFields != nil { + in, out := &in.EmailFields, &out.EmailFields + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserInfoFieldMapping. +func (in *UserInfoFieldMapping) DeepCopy() *UserInfoFieldMapping { + if in == nil { + return nil + } + out := new(UserInfoFieldMapping) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VirtualMCPCompositeToolDefinition) DeepCopyInto(out *VirtualMCPCompositeToolDefinition) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPCompositeToolDefinition. +func (in *VirtualMCPCompositeToolDefinition) DeepCopy() *VirtualMCPCompositeToolDefinition { + if in == nil { + return nil + } + out := new(VirtualMCPCompositeToolDefinition) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VirtualMCPCompositeToolDefinition) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VirtualMCPCompositeToolDefinitionList) DeepCopyInto(out *VirtualMCPCompositeToolDefinitionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]VirtualMCPCompositeToolDefinition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPCompositeToolDefinitionList. +func (in *VirtualMCPCompositeToolDefinitionList) DeepCopy() *VirtualMCPCompositeToolDefinitionList { + if in == nil { + return nil + } + out := new(VirtualMCPCompositeToolDefinitionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VirtualMCPCompositeToolDefinitionList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VirtualMCPCompositeToolDefinitionSpec) DeepCopyInto(out *VirtualMCPCompositeToolDefinitionSpec) { + *out = *in + in.CompositeToolConfig.DeepCopyInto(&out.CompositeToolConfig) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPCompositeToolDefinitionSpec. +func (in *VirtualMCPCompositeToolDefinitionSpec) DeepCopy() *VirtualMCPCompositeToolDefinitionSpec { + if in == nil { + return nil + } + out := new(VirtualMCPCompositeToolDefinitionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VirtualMCPCompositeToolDefinitionStatus) DeepCopyInto(out *VirtualMCPCompositeToolDefinitionStatus) { + *out = *in + if in.ValidationErrors != nil { + in, out := &in.ValidationErrors, &out.ValidationErrors + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ReferencingVirtualServers != nil { + in, out := &in.ReferencingVirtualServers, &out.ReferencingVirtualServers + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPCompositeToolDefinitionStatus. +func (in *VirtualMCPCompositeToolDefinitionStatus) DeepCopy() *VirtualMCPCompositeToolDefinitionStatus { + if in == nil { + return nil + } + out := new(VirtualMCPCompositeToolDefinitionStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VirtualMCPServer) DeepCopyInto(out *VirtualMCPServer) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPServer. +func (in *VirtualMCPServer) DeepCopy() *VirtualMCPServer { + if in == nil { + return nil + } + out := new(VirtualMCPServer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VirtualMCPServer) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VirtualMCPServerList) DeepCopyInto(out *VirtualMCPServerList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]VirtualMCPServer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPServerList. +func (in *VirtualMCPServerList) DeepCopy() *VirtualMCPServerList { + if in == nil { + return nil + } + out := new(VirtualMCPServerList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VirtualMCPServerList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VirtualMCPServerSpec) DeepCopyInto(out *VirtualMCPServerSpec) { + *out = *in + if in.IncomingAuth != nil { + in, out := &in.IncomingAuth, &out.IncomingAuth + *out = new(IncomingAuthConfig) + (*in).DeepCopyInto(*out) + } + if in.OutgoingAuth != nil { + in, out := &in.OutgoingAuth, &out.OutgoingAuth + *out = new(OutgoingAuthConfig) + (*in).DeepCopyInto(*out) + } + if in.ServiceAccount != nil { + in, out := &in.ServiceAccount, &out.ServiceAccount + *out = new(string) + **out = **in + } + if in.PodTemplateSpec != nil { + in, out := &in.PodTemplateSpec, &out.PodTemplateSpec + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } + if in.GroupRef != nil { + in, out := &in.GroupRef, &out.GroupRef + *out = new(MCPGroupRef) + **out = **in + } + in.Config.DeepCopyInto(&out.Config) + if in.TelemetryConfigRef != nil { + in, out := &in.TelemetryConfigRef, &out.TelemetryConfigRef + *out = new(MCPTelemetryConfigReference) + **out = **in + } + if in.EmbeddingServerRef != nil { + in, out := &in.EmbeddingServerRef, &out.EmbeddingServerRef + *out = new(EmbeddingServerRef) + **out = **in + } + if in.AuthServerConfig != nil { + in, out := &in.AuthServerConfig, &out.AuthServerConfig + *out = new(EmbeddedAuthServerConfig) + (*in).DeepCopyInto(*out) + } + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } + if in.SessionStorage != nil { + in, out := &in.SessionStorage, &out.SessionStorage + *out = new(SessionStorageConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPServerSpec. +func (in *VirtualMCPServerSpec) DeepCopy() *VirtualMCPServerSpec { + if in == nil { + return nil + } + out := new(VirtualMCPServerSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VirtualMCPServerStatus) DeepCopyInto(out *VirtualMCPServerStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.DiscoveredBackends != nil { + in, out := &in.DiscoveredBackends, &out.DiscoveredBackends + *out = make([]DiscoveredBackend, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMCPServerStatus. +func (in *VirtualMCPServerStatus) DeepCopy() *VirtualMCPServerStatus { + if in == nil { + return nil + } + out := new(VirtualMCPServerStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Volume) DeepCopyInto(out *Volume) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Volume. +func (in *Volume) DeepCopy() *Volume { + if in == nil { + return nil + } + out := new(Volume) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkloadReference) DeepCopyInto(out *WorkloadReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadReference. +func (in *WorkloadReference) DeepCopy() *WorkloadReference { + if in == nil { + return nil + } + out := new(WorkloadReference) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/thv-operator/config/webhook/manifests.yaml b/cmd/thv-operator/config/webhook/manifests.yaml index 07da31b419..88bdf37769 100644 --- a/cmd/thv-operator/config/webhook/manifests.yaml +++ b/cmd/thv-operator/config/webhook/manifests.yaml @@ -10,14 +10,14 @@ webhooks: service: name: webhook-service namespace: system - path: /validate-toolhive-stacklok-dev-v1alpha1-mcpexternalauthconfig + path: /validate-toolhive-stacklok-dev-v1beta1-mcpexternalauthconfig failurePolicy: Fail name: vmcpexternalauthconfig.kb.io rules: - apiGroups: - toolhive.stacklok.dev apiVersions: - - v1alpha1 + - v1beta1 operations: - CREATE - UPDATE @@ -30,14 +30,14 @@ webhooks: service: name: webhook-service namespace: system - path: /validate-toolhive-stacklok-dev-v1alpha1-virtualmcpcompositetooldefinition + path: /validate-toolhive-stacklok-dev-v1beta1-virtualmcpcompositetooldefinition failurePolicy: Fail name: vvirtualmcpcompositetooldefinition.kb.io rules: - apiGroups: - toolhive.stacklok.dev apiVersions: - - v1alpha1 + - v1beta1 operations: - CREATE - UPDATE @@ -50,14 +50,14 @@ webhooks: service: name: webhook-service namespace: system - path: /validate-toolhive-stacklok-dev-v1alpha1-virtualmcpserver + path: /validate-toolhive-stacklok-dev-v1beta1-virtualmcpserver failurePolicy: Fail name: vvirtualmcpserver.kb.io rules: - apiGroups: - toolhive.stacklok.dev apiVersions: - - v1alpha1 + - v1beta1 operations: - CREATE - UPDATE diff --git a/cmd/thv-operator/controllers/embeddingserver_controller.go b/cmd/thv-operator/controllers/embeddingserver_controller.go index f734d1e284..79a4542cf7 100644 --- a/cmd/thv-operator/controllers/embeddingserver_controller.go +++ b/cmd/thv-operator/controllers/embeddingserver_controller.go @@ -27,7 +27,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -67,7 +67,7 @@ func (r *EmbeddingServerReconciler) Reconcile(ctx context.Context, req ctrl.Requ ctxLogger := log.FromContext(ctx) // Fetch the EmbeddingServer instance - embedding := &mcpv1alpha1.EmbeddingServer{} + embedding := &mcpv1beta1.EmbeddingServer{} err := r.Get(ctx, req.NamespacedName, embedding) if err != nil { if errors.IsNotFound(err) { @@ -127,7 +127,7 @@ func (r *EmbeddingServerReconciler) Reconcile(ctx context.Context, req ctrl.Requ //nolint:unparam // error return kept for consistency with reconciler pattern func (r *EmbeddingServerReconciler) performValidations( ctx context.Context, - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, ) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) @@ -149,7 +149,7 @@ func (r *EmbeddingServerReconciler) performValidations( //nolint:unparam // ctrl.Result return kept for consistency with reconciler pattern func (r *EmbeddingServerReconciler) handleDeletion( ctx context.Context, - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, ) (ctrl.Result, bool, error) { if embedding.GetDeletionTimestamp() == nil { return ctrl.Result{}, false, nil @@ -172,7 +172,7 @@ func (r *EmbeddingServerReconciler) handleDeletion( //nolint:unparam // ctrl.Result return kept for consistency with reconciler pattern func (r *EmbeddingServerReconciler) ensureFinalizer( ctx context.Context, - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, ) (ctrl.Result, bool, error) { if controllerutil.ContainsFinalizer(embedding, embeddingFinalizerName) { return ctrl.Result{}, false, nil @@ -189,7 +189,7 @@ func (r *EmbeddingServerReconciler) ensureFinalizer( // ensureStatefulSet ensures the statefulset exists and is up to date func (r *EmbeddingServerReconciler) ensureStatefulSet( ctx context.Context, - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, ) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) @@ -250,7 +250,7 @@ func (r *EmbeddingServerReconciler) ensureStatefulSet( //nolint:unparam // ctrl.Result return kept for consistency with reconciler pattern func (r *EmbeddingServerReconciler) ensureService( ctx context.Context, - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, ) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) @@ -298,7 +298,7 @@ func (r *EmbeddingServerReconciler) ensureService( // serviceNeedsUpdate checks if the service needs to be updated based on the embedding spec func (*EmbeddingServerReconciler) serviceNeedsUpdate( service *corev1.Service, - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, ) bool { desiredPort := embedding.GetPort() @@ -343,15 +343,15 @@ func (*EmbeddingServerReconciler) serviceNeedsUpdate( // Status is not updated here - it will be updated at the end of reconciliation func (r *EmbeddingServerReconciler) validateAndUpdatePodTemplateStatus( ctx context.Context, - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, ) bool { ctxLogger := log.FromContext(ctx) if embedding.Spec.PodTemplateSpec == nil { meta.SetStatusCondition(&embedding.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionPodTemplateValid, + Type: mcpv1beta1.ConditionPodTemplateValid, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonPodTemplateValid, + Reason: mcpv1beta1.ConditionReasonPodTemplateValid, Message: "No PodTemplateSpec provided", ObservedGeneration: embedding.Generation, }) @@ -362,12 +362,12 @@ func (r *EmbeddingServerReconciler) validateAndUpdatePodTemplateStatus( _, err := ctrlutil.NewPodTemplateSpecBuilder(embedding.Spec.PodTemplateSpec, embeddingContainerName) if err != nil { ctxLogger.Error(err, "Invalid PodTemplateSpec") - embedding.Status.Phase = mcpv1alpha1.EmbeddingServerPhaseFailed + embedding.Status.Phase = mcpv1beta1.EmbeddingServerPhaseFailed embedding.Status.Message = fmt.Sprintf("Invalid PodTemplateSpec: %v", err) meta.SetStatusCondition(&embedding.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionPodTemplateValid, + Type: mcpv1beta1.ConditionPodTemplateValid, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonPodTemplateInvalid, + Reason: mcpv1beta1.ConditionReasonPodTemplateInvalid, Message: fmt.Sprintf("Invalid PodTemplateSpec: %v", err), ObservedGeneration: embedding.Generation, }) @@ -384,9 +384,9 @@ func (r *EmbeddingServerReconciler) validateAndUpdatePodTemplateStatus( } meta.SetStatusCondition(&embedding.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionPodTemplateValid, + Type: mcpv1beta1.ConditionPodTemplateValid, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonPodTemplateValid, + Reason: mcpv1beta1.ConditionReasonPodTemplateValid, Message: "PodTemplateSpec is valid", ObservedGeneration: embedding.Generation, }) @@ -397,7 +397,7 @@ func (r *EmbeddingServerReconciler) validateAndUpdatePodTemplateStatus( // statefulSetForEmbedding creates a StatefulSet for the embedding server func (r *EmbeddingServerReconciler) statefulSetForEmbedding( _ context.Context, - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, ) *appsv1.StatefulSet { replicas := embedding.GetReplicas() labels := r.labelsForEmbedding(embedding) @@ -446,7 +446,7 @@ func (r *EmbeddingServerReconciler) statefulSetForEmbedding( // buildVolumeClaimTemplates builds the volumeClaimTemplates for the StatefulSet func (r *EmbeddingServerReconciler) buildVolumeClaimTemplates( - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, ) []corev1.PersistentVolumeClaim { size := "10Gi" if embedding.Spec.ModelCache.Size != "" { @@ -494,7 +494,7 @@ func (r *EmbeddingServerReconciler) buildVolumeClaimTemplates( } // buildEmbeddingContainer builds the container spec for the embedding server -func (r *EmbeddingServerReconciler) buildEmbeddingContainer(embedding *mcpv1alpha1.EmbeddingServer) corev1.Container { +func (r *EmbeddingServerReconciler) buildEmbeddingContainer(embedding *mcpv1beta1.EmbeddingServer) corev1.Container { // Build container args args := []string{ "--model-id", embedding.Spec.Model, @@ -544,7 +544,7 @@ func (r *EmbeddingServerReconciler) buildEmbeddingContainer(embedding *mcpv1alph } // buildEnvVars builds environment variables for the container -func (*EmbeddingServerReconciler) buildEnvVars(embedding *mcpv1alpha1.EmbeddingServer) []corev1.EnvVar { +func (*EmbeddingServerReconciler) buildEnvVars(embedding *mcpv1beta1.EmbeddingServer) []corev1.EnvVar { envVars := []corev1.EnvVar{ { Name: "MODEL_ID", @@ -577,7 +577,7 @@ func (*EmbeddingServerReconciler) buildEnvVars(embedding *mcpv1alpha1.EmbeddingS } // buildLivenessProbe builds the liveness probe for the container -func (*EmbeddingServerReconciler) buildLivenessProbe(embedding *mcpv1alpha1.EmbeddingServer) *corev1.Probe { +func (*EmbeddingServerReconciler) buildLivenessProbe(embedding *mcpv1beta1.EmbeddingServer) *corev1.Probe { return &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ @@ -593,7 +593,7 @@ func (*EmbeddingServerReconciler) buildLivenessProbe(embedding *mcpv1alpha1.Embe } // buildReadinessProbe builds the readiness probe for the container -func (*EmbeddingServerReconciler) buildReadinessProbe(embedding *mcpv1alpha1.EmbeddingServer) *corev1.Probe { +func (*EmbeddingServerReconciler) buildReadinessProbe(embedding *mcpv1beta1.EmbeddingServer) *corev1.Probe { return &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ @@ -609,7 +609,7 @@ func (*EmbeddingServerReconciler) buildReadinessProbe(embedding *mcpv1alpha1.Emb } // applyResourceRequirements applies resource requirements to the container -func (*EmbeddingServerReconciler) applyResourceRequirements(embedding *mcpv1alpha1.EmbeddingServer, container *corev1.Container) { +func (*EmbeddingServerReconciler) applyResourceRequirements(embedding *mcpv1beta1.EmbeddingServer, container *corev1.Container) { if embedding.Spec.Resources.Limits.CPU == "" && embedding.Spec.Resources.Limits.Memory == "" && embedding.Spec.Resources.Requests.CPU == "" && embedding.Spec.Resources.Requests.Memory == "" { return @@ -636,7 +636,7 @@ func (*EmbeddingServerReconciler) applyResourceRequirements(embedding *mcpv1alph // buildPodTemplate builds the pod template for the statefulset func (r *EmbeddingServerReconciler) buildPodTemplate( - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, labels map[string]string, container corev1.Container, ) corev1.PodTemplateSpec { @@ -660,7 +660,7 @@ func (r *EmbeddingServerReconciler) buildPodTemplate( // mergePodTemplateSpec merges user-provided PodTemplateSpec customizations func (r *EmbeddingServerReconciler) mergePodTemplateSpec( - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, podTemplate *corev1.PodTemplateSpec, ) { if embedding.Spec.PodTemplateSpec == nil { @@ -719,7 +719,7 @@ func (*EmbeddingServerReconciler) mergeContainerSecurityContext( // applyStatefulSetOverrides applies statefulset-level overrides and returns annotations and labels func (*EmbeddingServerReconciler) applyStatefulSetOverrides( - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, podTemplate *corev1.PodTemplateSpec, ) (map[string]string, map[string]string) { annotations := make(map[string]string) @@ -758,7 +758,7 @@ func (*EmbeddingServerReconciler) applyStatefulSetOverrides( // serviceForEmbedding creates a Service for the embedding server func (r *EmbeddingServerReconciler) serviceForEmbedding( _ context.Context, - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, ) *corev1.Service { labels := r.labelsForEmbedding(embedding) annotations := make(map[string]string) @@ -803,7 +803,7 @@ func (r *EmbeddingServerReconciler) serviceForEmbedding( } // labelsForEmbedding returns the labels for the embedding resources -func (*EmbeddingServerReconciler) labelsForEmbedding(embedding *mcpv1alpha1.EmbeddingServer) map[string]string { +func (*EmbeddingServerReconciler) labelsForEmbedding(embedding *mcpv1beta1.EmbeddingServer) map[string]string { return map[string]string{ "app.kubernetes.io/name": "embeddingserver", "app.kubernetes.io/instance": embedding.Name, @@ -816,7 +816,7 @@ func (*EmbeddingServerReconciler) labelsForEmbedding(embedding *mcpv1alpha1.Embe func (r *EmbeddingServerReconciler) statefulSetNeedsUpdate( ctx context.Context, currentSts *appsv1.StatefulSet, - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, ) bool { // Generate the expected StatefulSet from the current spec newSts := r.statefulSetForEmbedding(ctx, embedding) @@ -922,7 +922,7 @@ func (*EmbeddingServerReconciler) podTemplateMetadataChanged(currentSts, newSts // updateEmbeddingServerStatus updates the status based on statefulset state func (r *EmbeddingServerReconciler) updateEmbeddingServerStatus( ctx context.Context, - embedding *mcpv1alpha1.EmbeddingServer, + embedding *mcpv1beta1.EmbeddingServer, ) error { ctxLogger := log.FromContext(ctx) @@ -936,7 +936,7 @@ func (r *EmbeddingServerReconciler) updateEmbeddingServerStatus( err := r.Get(ctx, types.NamespacedName{Name: embedding.Name, Namespace: embedding.Namespace}, statefulSet) if err != nil { if errors.IsNotFound(err) { - embedding.Status.Phase = mcpv1alpha1.EmbeddingServerPhasePending + embedding.Status.Phase = mcpv1beta1.EmbeddingServerPhasePending embedding.Status.ReadyReplicas = 0 } else { return err @@ -947,26 +947,26 @@ func (r *EmbeddingServerReconciler) updateEmbeddingServerStatus( // Determine phase and message based on statefulset status using immutable assignment type phaseInfo struct { - phase mcpv1alpha1.EmbeddingServerPhase + phase mcpv1beta1.EmbeddingServerPhase message string } info := func() phaseInfo { if statefulSet.Status.ReadyReplicas > 0 { return phaseInfo{ - phase: mcpv1alpha1.EmbeddingServerPhaseReady, + phase: mcpv1beta1.EmbeddingServerPhaseReady, message: "Embedding server is running", } } if statefulSet.Status.Replicas > 0 && statefulSet.Status.ReadyReplicas == 0 { // Check if pods are downloading the model return phaseInfo{ - phase: mcpv1alpha1.EmbeddingServerPhaseDownloading, + phase: mcpv1beta1.EmbeddingServerPhaseDownloading, message: "Downloading embedding model", } } return phaseInfo{ - phase: mcpv1alpha1.EmbeddingServerPhasePending, + phase: mcpv1beta1.EmbeddingServerPhasePending, message: "Waiting for statefulset", } }() @@ -985,12 +985,12 @@ func (r *EmbeddingServerReconciler) updateEmbeddingServerStatus( } // finalizeEmbeddingServer performs cleanup before the EmbeddingServer is deleted -func (r *EmbeddingServerReconciler) finalizeEmbeddingServer(ctx context.Context, embedding *mcpv1alpha1.EmbeddingServer) { +func (r *EmbeddingServerReconciler) finalizeEmbeddingServer(ctx context.Context, embedding *mcpv1beta1.EmbeddingServer) { ctxLogger := log.FromContext(ctx) ctxLogger.Info("Finalizing EmbeddingServer", "name", embedding.Name) // Update status to Terminating - embedding.Status.Phase = mcpv1alpha1.EmbeddingServerPhaseTerminating + embedding.Status.Phase = mcpv1beta1.EmbeddingServerPhaseTerminating if err := r.Status().Update(ctx, embedding); err != nil { ctxLogger.Error(err, "Failed to update EmbeddingServer status to Terminating") } @@ -1004,7 +1004,7 @@ func (r *EmbeddingServerReconciler) finalizeEmbeddingServer(ctx context.Context, // SetupWithManager sets up the controller with the Manager. func (r *EmbeddingServerReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1alpha1.EmbeddingServer{}). + For(&mcpv1beta1.EmbeddingServer{}). Owns(&appsv1.StatefulSet{}). Owns(&corev1.Service{}). Owns(&corev1.PersistentVolumeClaim{}). diff --git a/cmd/thv-operator/controllers/embeddingserver_controller_test.go b/cmd/thv-operator/controllers/embeddingserver_controller_test.go index 9b051b506e..b9df287883 100644 --- a/cmd/thv-operator/controllers/embeddingserver_controller_test.go +++ b/cmd/thv-operator/controllers/embeddingserver_controller_test.go @@ -20,7 +20,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -50,8 +50,8 @@ func TestEmbeddingServer_GetPort(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - embedding := &mcpv1alpha1.EmbeddingServer{ - Spec: mcpv1alpha1.EmbeddingServerSpec{ + embedding := &mcpv1beta1.EmbeddingServer{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Port: tt.port, }, } @@ -86,8 +86,8 @@ func TestEmbeddingServer_GetReplicas(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - embedding := &mcpv1alpha1.EmbeddingServer{ - Spec: mcpv1alpha1.EmbeddingServerSpec{ + embedding := &mcpv1beta1.EmbeddingServer{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Replicas: tt.replicas, }, } @@ -102,7 +102,7 @@ func TestEmbeddingServer_IsModelCacheEnabled(t *testing.T) { tests := []struct { name string - modelCache *mcpv1alpha1.ModelCacheConfig + modelCache *mcpv1beta1.ModelCacheConfig expected bool }{ { @@ -112,14 +112,14 @@ func TestEmbeddingServer_IsModelCacheEnabled(t *testing.T) { }, { name: "model cache disabled", - modelCache: &mcpv1alpha1.ModelCacheConfig{ + modelCache: &mcpv1beta1.ModelCacheConfig{ Enabled: false, }, expected: false, }, { name: "model cache enabled", - modelCache: &mcpv1alpha1.ModelCacheConfig{ + modelCache: &mcpv1beta1.ModelCacheConfig{ Enabled: true, }, expected: true, @@ -130,8 +130,8 @@ func TestEmbeddingServer_IsModelCacheEnabled(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - embedding := &mcpv1alpha1.EmbeddingServer{ - Spec: mcpv1alpha1.EmbeddingServerSpec{ + embedding := &mcpv1beta1.EmbeddingServer{ + Spec: mcpv1beta1.EmbeddingServerSpec{ ModelCache: tt.modelCache, }, } @@ -175,8 +175,8 @@ func TestEmbeddingServer_GetImagePullPolicy(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - embedding := &mcpv1alpha1.EmbeddingServer{ - Spec: mcpv1alpha1.EmbeddingServerSpec{ + embedding := &mcpv1beta1.EmbeddingServer{ + Spec: mcpv1beta1.EmbeddingServerSpec{ ImagePullPolicy: tt.imagePullPolicy, }, } @@ -239,8 +239,8 @@ func TestEmbeddingServerPodTemplateSpecValidation(t *testing.T) { func TestEmbeddingServer_Labels(t *testing.T) { t.Parallel() - embedding := &mcpv1alpha1.EmbeddingServer{ - Spec: mcpv1alpha1.EmbeddingServerSpec{ + embedding := &mcpv1beta1.EmbeddingServer{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "test-model", }, } @@ -263,13 +263,13 @@ func TestEmbeddingServer_ModelCacheConfig(t *testing.T) { storageClassName := "fast-ssd" tests := []struct { name string - modelCache *mcpv1alpha1.ModelCacheConfig + modelCache *mcpv1beta1.ModelCacheConfig expectedSize string expectedAccess string }{ { name: "default values", - modelCache: &mcpv1alpha1.ModelCacheConfig{ + modelCache: &mcpv1beta1.ModelCacheConfig{ Enabled: true, }, expectedSize: "10Gi", @@ -277,7 +277,7 @@ func TestEmbeddingServer_ModelCacheConfig(t *testing.T) { }, { name: "custom values", - modelCache: &mcpv1alpha1.ModelCacheConfig{ + modelCache: &mcpv1beta1.ModelCacheConfig{ Enabled: true, Size: "20Gi", AccessMode: "ReadWriteMany", @@ -292,8 +292,8 @@ func TestEmbeddingServer_ModelCacheConfig(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - embedding := &mcpv1alpha1.EmbeddingServer{ - Spec: mcpv1alpha1.EmbeddingServerSpec{ + embedding := &mcpv1beta1.EmbeddingServer{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "test-model", ModelCache: tt.modelCache, }, @@ -335,18 +335,18 @@ func createEmbeddingServerTestScheme() *runtime.Scheme { testScheme := runtime.NewScheme() _ = corev1.AddToScheme(testScheme) _ = appsv1.AddToScheme(testScheme) - _ = mcpv1alpha1.AddToScheme(testScheme) + _ = mcpv1beta1.AddToScheme(testScheme) return testScheme } -func createTestEmbeddingServer(name, namespace, image, model string) *mcpv1alpha1.EmbeddingServer { - return &mcpv1alpha1.EmbeddingServer{ +func createTestEmbeddingServer(name, namespace, image, model string) *mcpv1beta1.EmbeddingServer { + return &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, Generation: 1, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Image: image, Model: model, }, @@ -414,7 +414,7 @@ func TestReconcile_CreateResources(t *testing.T) { assert.Equal(t, ctrl.Result{}, result) // Verify finalizer was added - updatedEmbedding := &mcpv1alpha1.EmbeddingServer{} + updatedEmbedding := &mcpv1beta1.EmbeddingServer{} err = fakeClient.Get(ctx, types.NamespacedName{ Name: embedding.Name, Namespace: embedding.Namespace, @@ -453,13 +453,13 @@ func TestStatefulSetNeedsUpdate(t *testing.T) { } // Helper to generate a StatefulSet from an embedding using the reconciler - generateSts := func(e *mcpv1alpha1.EmbeddingServer) *appsv1.StatefulSet { + generateSts := func(e *mcpv1beta1.EmbeddingServer) *appsv1.StatefulSet { return reconciler.statefulSetForEmbedding(context.TODO(), e) } tests := []struct { name string - embedding *mcpv1alpha1.EmbeddingServer + embedding *mcpv1beta1.EmbeddingServer existingSts *appsv1.StatefulSet expectedUpdate bool updateReason string @@ -486,9 +486,9 @@ func TestStatefulSetNeedsUpdate(t *testing.T) { }, { name: "update needed - port changed", - embedding: &mcpv1alpha1.EmbeddingServer{ + embedding: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: testNamespaceDefault, Generation: 1}, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Image: "image:v1", Model: "model1", Port: 9090, @@ -517,20 +517,20 @@ func TestHandleDeletion(t *testing.T) { tests := []struct { name string - embedding *mcpv1alpha1.EmbeddingServer + embedding *mcpv1beta1.EmbeddingServer expectDone bool expectError bool expectFinalizer bool }{ { name: "not being deleted", - embedding: &mcpv1alpha1.EmbeddingServer{ + embedding: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test", Namespace: testNamespaceDefault, Finalizers: []string{embeddingFinalizerName}, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Image: "test:latest", Model: "test-model", }, @@ -541,14 +541,14 @@ func TestHandleDeletion(t *testing.T) { }, { name: "being deleted with finalizer", - embedding: &mcpv1alpha1.EmbeddingServer{ + embedding: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test", Namespace: testNamespaceDefault, Finalizers: []string{embeddingFinalizerName}, DeletionTimestamp: &metav1.Time{Time: time.Now()}, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Image: "test:latest", Model: "test-model", }, @@ -591,7 +591,7 @@ func TestHandleDeletion(t *testing.T) { // Verify finalizer state if not being deleted if tt.embedding.DeletionTimestamp == nil { - updatedEmbedding := &mcpv1alpha1.EmbeddingServer{} + updatedEmbedding := &mcpv1beta1.EmbeddingServer{} err := fakeClient.Get(context.TODO(), types.NamespacedName{ Name: tt.embedding.Name, Namespace: tt.embedding.Namespace, @@ -617,7 +617,7 @@ func TestEnsureStatefulSet(t *testing.T) { tests := []struct { name string - embedding *mcpv1alpha1.EmbeddingServer + embedding *mcpv1beta1.EmbeddingServer existingSts *appsv1.StatefulSet expectCreate bool expectUpdate bool @@ -632,7 +632,7 @@ func TestEnsureStatefulSet(t *testing.T) { }, { name: "update replicas", - embedding: func() *mcpv1alpha1.EmbeddingServer { + embedding: func() *mcpv1beta1.EmbeddingServer { e := createTestEmbeddingServer("test", testNamespaceDefault, "image:v1", "model1") replicas := int32(3) e.Spec.Replicas = &replicas @@ -718,16 +718,16 @@ func TestUpdateEmbeddingServerStatus(t *testing.T) { tests := []struct { name string - embedding *mcpv1alpha1.EmbeddingServer + embedding *mcpv1beta1.EmbeddingServer statefulSet *appsv1.StatefulSet - expectedPhase mcpv1alpha1.EmbeddingServerPhase + expectedPhase mcpv1beta1.EmbeddingServerPhase expectedURL string }{ { name: "no statefulset - pending", embedding: createTestEmbeddingServer("test", testNamespaceDefault, "image:v1", "model1"), statefulSet: nil, - expectedPhase: mcpv1alpha1.EmbeddingServerPhasePending, + expectedPhase: mcpv1beta1.EmbeddingServerPhasePending, expectedURL: "http://test.default.svc.cluster.local:8080", }, { @@ -743,7 +743,7 @@ func TestUpdateEmbeddingServerStatus(t *testing.T) { ReadyReplicas: 1, }, }, - expectedPhase: mcpv1alpha1.EmbeddingServerPhaseReady, + expectedPhase: mcpv1beta1.EmbeddingServerPhaseReady, expectedURL: "http://test.default.svc.cluster.local:8080", }, { @@ -759,7 +759,7 @@ func TestUpdateEmbeddingServerStatus(t *testing.T) { ReadyReplicas: 0, }, }, - expectedPhase: mcpv1alpha1.EmbeddingServerPhaseDownloading, + expectedPhase: mcpv1beta1.EmbeddingServerPhaseDownloading, expectedURL: "http://test.default.svc.cluster.local:8080", }, } @@ -789,7 +789,7 @@ func TestUpdateEmbeddingServerStatus(t *testing.T) { assert.NoError(t, err) // Verify status was updated - updatedEmbedding := &mcpv1alpha1.EmbeddingServer{} + updatedEmbedding := &mcpv1beta1.EmbeddingServer{} err = fakeClient.Get(context.TODO(), types.NamespacedName{ Name: tt.embedding.Name, Namespace: tt.embedding.Namespace, diff --git a/cmd/thv-operator/controllers/helpers_test.go b/cmd/thv-operator/controllers/helpers_test.go index ef6652c13c..35c94bcf6c 100644 --- a/cmd/thv-operator/controllers/helpers_test.go +++ b/cmd/thv-operator/controllers/helpers_test.go @@ -11,11 +11,11 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // conditionTypeValid is the condition type used across config controller tests. -const conditionTypeValid = mcpv1alpha1.ConditionTypeValid +const conditionTypeValid = mcpv1beta1.ConditionTypeValid // podTemplateSpecToRawExtension is a test helper to convert PodTemplateSpec to RawExtension func podTemplateSpecToRawExtension(t *testing.T, pts *corev1.PodTemplateSpec) *runtime.RawExtension { diff --git a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go index f8bf938ea2..373f8a65b2 100644 --- a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go +++ b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go @@ -20,7 +20,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -53,7 +53,7 @@ func (r *MCPExternalAuthConfigReconciler) Reconcile(ctx context.Context, req ctr logger := log.FromContext(ctx) // Fetch the MCPExternalAuthConfig instance - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{} err := r.Get(ctx, req.NamespacedName, externalAuthConfig) if err != nil { if errors.IsNotFound(err) { @@ -88,7 +88,7 @@ func (r *MCPExternalAuthConfigReconciler) Reconcile(ctx context.Context, req ctr logger.Error(err, "MCPExternalAuthConfig spec validation failed") // Update status with validation error meta.SetStatusCondition(&externalAuthConfig.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeValid, + Type: mcpv1beta1.ConditionTypeValid, Status: metav1.ConditionFalse, Reason: "ValidationFailed", Message: err.Error(), @@ -102,7 +102,7 @@ func (r *MCPExternalAuthConfigReconciler) Reconcile(ctx context.Context, req ctr // Validation succeeded - set Valid=True condition conditionChanged := meta.SetStatusCondition(&externalAuthConfig.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeValid, + Type: mcpv1beta1.ConditionTypeValid, Status: metav1.ConditionTrue, Reason: "ValidationSucceeded", Message: "Spec validation passed", @@ -132,14 +132,14 @@ func (r *MCPExternalAuthConfigReconciler) Reconcile(ctx context.Context, req ctr } // calculateConfigHash calculates a hash of the MCPExternalAuthConfig spec using Kubernetes utilities -func (*MCPExternalAuthConfigReconciler) calculateConfigHash(spec mcpv1alpha1.MCPExternalAuthConfigSpec) string { +func (*MCPExternalAuthConfigReconciler) calculateConfigHash(spec mcpv1beta1.MCPExternalAuthConfigSpec) string { return ctrlutil.CalculateConfigHash(spec) } // handleConfigHashChange handles the logic when the config hash changes func (r *MCPExternalAuthConfigReconciler) handleConfigHashChange( ctx context.Context, - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig, + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig, configHash string, ) (ctrl.Result, error) { logger := log.FromContext(ctx) @@ -159,9 +159,9 @@ func (r *MCPExternalAuthConfigReconciler) handleConfigHashChange( } // Update the status with the list of referencing workloads - refs := make([]mcpv1alpha1.WorkloadReference, 0, len(referencingServers)) + refs := make([]mcpv1beta1.WorkloadReference, 0, len(referencingServers)) for _, server := range referencingServers { - refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPServer, Name: server.Name}) + refs = append(refs, mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindMCPServer, Name: server.Name}) } ctrlutil.SortWorkloadRefs(refs) externalAuthConfig.Status.ReferencingWorkloads = refs @@ -195,7 +195,7 @@ func (r *MCPExternalAuthConfigReconciler) handleConfigHashChange( // handleDeletion handles the deletion of a MCPExternalAuthConfig func (r *MCPExternalAuthConfigReconciler) handleDeletion( ctx context.Context, - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig, + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig, ) (ctrl.Result, error) { logger := log.FromContext(ctx) @@ -213,7 +213,7 @@ func (r *MCPExternalAuthConfigReconciler) handleDeletion( "referencingWorkloads", referencingWorkloads) meta.SetStatusCondition(&externalAuthConfig.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeDeletionBlocked, + Type: mcpv1beta1.ConditionTypeDeletionBlocked, Status: metav1.ConditionTrue, Reason: "ReferencedByWorkloads", Message: fmt.Sprintf("Cannot delete: referenced by workloads: %v", referencingWorkloads), @@ -247,10 +247,10 @@ func (r *MCPExternalAuthConfigReconciler) handleDeletion( // config "B" will be found when reconciling either config. func (r *MCPExternalAuthConfigReconciler) findReferencingMCPServers( ctx context.Context, - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig, -) ([]mcpv1alpha1.MCPServer, error) { + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig, +) ([]mcpv1beta1.MCPServer, error) { byExtAuth, err := ctrlutil.FindReferencingMCPServers(ctx, r.Client, externalAuthConfig.Namespace, externalAuthConfig.Name, - func(server *mcpv1alpha1.MCPServer) *string { + func(server *mcpv1beta1.MCPServer) *string { if server.Spec.ExternalAuthConfigRef != nil { return &server.Spec.ExternalAuthConfigRef.Name } @@ -261,7 +261,7 @@ func (r *MCPExternalAuthConfigReconciler) findReferencingMCPServers( } byAuthServer, err := ctrlutil.FindReferencingMCPServers(ctx, r.Client, externalAuthConfig.Namespace, externalAuthConfig.Name, - func(server *mcpv1alpha1.MCPServer) *string { + func(server *mcpv1beta1.MCPServer) *string { if server.Spec.AuthServerRef != nil && server.Spec.AuthServerRef.Kind == authServerRefKindMCPExternalAuthConfig { return &server.Spec.AuthServerRef.Name } @@ -273,7 +273,7 @@ func (r *MCPExternalAuthConfigReconciler) findReferencingMCPServers( // Merge and deduplicate seen := make(map[string]struct{}, len(byExtAuth)) - result := make([]mcpv1alpha1.MCPServer, 0, len(byExtAuth)+len(byAuthServer)) + result := make([]mcpv1beta1.MCPServer, 0, len(byExtAuth)+len(byAuthServer)) for _, s := range byExtAuth { seen[s.Name] = struct{}{} result = append(result, s) @@ -293,11 +293,11 @@ func (r *MCPExternalAuthConfigReconciler) findReferencingMCPServers( // config "B" will be found when reconciling either config. func (r *MCPExternalAuthConfigReconciler) findReferencingMCPRemoteProxies( ctx context.Context, - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig, -) ([]mcpv1alpha1.MCPRemoteProxy, error) { + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig, +) ([]mcpv1beta1.MCPRemoteProxy, error) { byExtAuth, err := ctrlutil.FindReferencingMCPRemoteProxies( ctx, r.Client, externalAuthConfig.Namespace, externalAuthConfig.Name, - func(proxy *mcpv1alpha1.MCPRemoteProxy) *string { + func(proxy *mcpv1beta1.MCPRemoteProxy) *string { if proxy.Spec.ExternalAuthConfigRef != nil { return &proxy.Spec.ExternalAuthConfigRef.Name } @@ -309,7 +309,7 @@ func (r *MCPExternalAuthConfigReconciler) findReferencingMCPRemoteProxies( byAuthServer, err := ctrlutil.FindReferencingMCPRemoteProxies( ctx, r.Client, externalAuthConfig.Namespace, externalAuthConfig.Name, - func(proxy *mcpv1alpha1.MCPRemoteProxy) *string { + func(proxy *mcpv1beta1.MCPRemoteProxy) *string { if proxy.Spec.AuthServerRef != nil && proxy.Spec.AuthServerRef.Kind == authServerRefKindMCPExternalAuthConfig { return &proxy.Spec.AuthServerRef.Name } @@ -321,7 +321,7 @@ func (r *MCPExternalAuthConfigReconciler) findReferencingMCPRemoteProxies( // Merge and deduplicate seen := make(map[string]struct{}, len(byExtAuth)) - result := make([]mcpv1alpha1.MCPRemoteProxy, 0, len(byExtAuth)+len(byAuthServer)) + result := make([]mcpv1beta1.MCPRemoteProxy, 0, len(byExtAuth)+len(byAuthServer)) for _, p := range byExtAuth { seen[p.Name] = struct{}{} result = append(result, p) @@ -339,15 +339,15 @@ func (r *MCPExternalAuthConfigReconciler) findReferencingMCPRemoteProxies( // It queries separately for each ref field and merges the results, so both fields are always checked. func (r *MCPExternalAuthConfigReconciler) findReferencingWorkloads( ctx context.Context, - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig, -) ([]mcpv1alpha1.WorkloadReference, error) { + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig, +) ([]mcpv1beta1.WorkloadReference, error) { servers, err := r.findReferencingMCPServers(ctx, externalAuthConfig) if err != nil { return nil, err } - refs := make([]mcpv1alpha1.WorkloadReference, 0, len(servers)) + refs := make([]mcpv1beta1.WorkloadReference, 0, len(servers)) for _, server := range servers { - refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPServer, Name: server.Name}) + refs = append(refs, mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindMCPServer, Name: server.Name}) } proxies, err := r.findReferencingMCPRemoteProxies(ctx, externalAuthConfig) @@ -355,7 +355,7 @@ func (r *MCPExternalAuthConfigReconciler) findReferencingWorkloads( return nil, err } for _, proxy := range proxies { - refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPRemoteProxy, Name: proxy.Name}) + refs = append(refs, mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindMCPRemoteProxy, Name: proxy.Name}) } ctrlutil.SortWorkloadRefs(refs) @@ -366,13 +366,13 @@ func (r *MCPExternalAuthConfigReconciler) findReferencingWorkloads( // Watches MCPServer and MCPRemoteProxy changes to maintain accurate ReferencingWorkloads status. func (r *MCPExternalAuthConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1alpha1.MCPExternalAuthConfig{}). + For(&mcpv1beta1.MCPExternalAuthConfig{}). Watches( - &mcpv1alpha1.MCPServer{}, + &mcpv1beta1.MCPServer{}, handler.EnqueueRequestsFromMapFunc(r.mapMCPServerToExternalAuthConfig), ). Watches( - &mcpv1alpha1.MCPRemoteProxy{}, + &mcpv1beta1.MCPRemoteProxy{}, handler.EnqueueRequestsFromMapFunc(r.mapMCPRemoteProxyToExternalAuthConfig), ). Complete(r) @@ -384,7 +384,7 @@ func (r *MCPExternalAuthConfigReconciler) SetupWithManager(mgr ctrl.Manager) err func (r *MCPExternalAuthConfigReconciler) mapMCPServerToExternalAuthConfig( ctx context.Context, obj client.Object, ) []reconcile.Request { - server, ok := obj.(*mcpv1alpha1.MCPServer) + server, ok := obj.(*mcpv1beta1.MCPServer) if !ok { return nil } @@ -416,7 +416,7 @@ func (r *MCPExternalAuthConfigReconciler) mapMCPServerToExternalAuthConfig( // Also enqueue any MCPExternalAuthConfig that still lists this server in // ReferencingWorkloads — handles ref-removal and server-deletion cases. - extAuthConfigList := &mcpv1alpha1.MCPExternalAuthConfigList{} + extAuthConfigList := &mcpv1beta1.MCPExternalAuthConfigList{} if err := r.List(ctx, extAuthConfigList, client.InNamespace(server.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPExternalAuthConfigs for MCPServer watch") return requests @@ -427,7 +427,7 @@ func (r *MCPExternalAuthConfigReconciler) mapMCPServerToExternalAuthConfig( continue } for _, ref := range cfg.Status.ReferencingWorkloads { - if ref.Kind == mcpv1alpha1.WorkloadKindMCPServer && ref.Name == server.Name { + if ref.Kind == mcpv1beta1.WorkloadKindMCPServer && ref.Name == server.Name { requests = append(requests, reconcile.Request{NamespacedName: nn}) break } @@ -443,7 +443,7 @@ func (r *MCPExternalAuthConfigReconciler) mapMCPServerToExternalAuthConfig( func (r *MCPExternalAuthConfigReconciler) mapMCPRemoteProxyToExternalAuthConfig( ctx context.Context, obj client.Object, ) []reconcile.Request { - proxy, ok := obj.(*mcpv1alpha1.MCPRemoteProxy) + proxy, ok := obj.(*mcpv1beta1.MCPRemoteProxy) if !ok { return nil } @@ -475,7 +475,7 @@ func (r *MCPExternalAuthConfigReconciler) mapMCPRemoteProxyToExternalAuthConfig( // Also enqueue any MCPExternalAuthConfig that still lists this proxy in // ReferencingWorkloads — handles ref-removal and proxy-deletion cases. - extAuthConfigList := &mcpv1alpha1.MCPExternalAuthConfigList{} + extAuthConfigList := &mcpv1beta1.MCPExternalAuthConfigList{} if err := r.List(ctx, extAuthConfigList, client.InNamespace(proxy.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPExternalAuthConfigs for MCPRemoteProxy watch") return requests @@ -486,7 +486,7 @@ func (r *MCPExternalAuthConfigReconciler) mapMCPRemoteProxyToExternalAuthConfig( continue } for _, ref := range cfg.Status.ReferencingWorkloads { - if ref.Kind == mcpv1alpha1.WorkloadKindMCPRemoteProxy && ref.Name == proxy.Name { + if ref.Kind == mcpv1beta1.WorkloadKindMCPRemoteProxy && ref.Name == proxy.Name { requests = append(requests, reconcile.Request{NamespacedName: nn}) break } @@ -499,7 +499,7 @@ func (r *MCPExternalAuthConfigReconciler) mapMCPRemoteProxyToExternalAuthConfig( // updateReferencingWorkloads finds referencing workloads and updates the status if the list changed func (r *MCPExternalAuthConfigReconciler) updateReferencingWorkloads( ctx context.Context, - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig, + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig, ) (ctrl.Result, error) { refs, err := r.findReferencingWorkloads(ctx, externalAuthConfig) if err != nil { @@ -525,15 +525,15 @@ func (r *MCPExternalAuthConfigReconciler) updateReferencingWorkloads( func GetExternalAuthConfigForMCPServer( ctx context.Context, c client.Client, - mcpServer *mcpv1alpha1.MCPServer, -) (*mcpv1alpha1.MCPExternalAuthConfig, error) { + mcpServer *mcpv1beta1.MCPServer, +) (*mcpv1beta1.MCPExternalAuthConfig, error) { if mcpServer.Spec.ExternalAuthConfigRef == nil { // We throw an error because in this case you assume there is a ExternalAuthConfig // but there isn't one referenced. return nil, fmt.Errorf("MCPServer %s does not reference a MCPExternalAuthConfig", mcpServer.Name) } - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{} err := c.Get(ctx, types.NamespacedName{ Name: mcpServer.Spec.ExternalAuthConfigRef.Name, Namespace: mcpServer.Namespace, // Same namespace as MCPServer diff --git a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go index 2f56a8dda2..68ec522b35 100644 --- a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go +++ b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestMCPExternalAuthConfigReconciler_calculateConfigHash(t *testing.T) { @@ -25,22 +25,22 @@ func TestMCPExternalAuthConfigReconciler_calculateConfigHash(t *testing.T) { tests := []struct { name string - spec mcpv1alpha1.MCPExternalAuthConfigSpec + spec mcpv1beta1.MCPExternalAuthConfigSpec }{ { name: "empty spec", - spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, }, }, { name: "with token exchange config", - spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, @@ -51,12 +51,12 @@ func TestMCPExternalAuthConfigReconciler_calculateConfigHash(t *testing.T) { }, { name: "with custom header", - spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, @@ -86,24 +86,24 @@ func TestMCPExternalAuthConfigReconciler_calculateConfigHash(t *testing.T) { t.Run("different specs produce different hashes", func(t *testing.T) { t.Parallel() r := &MCPExternalAuthConfigReconciler{} - spec1 := mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + spec1 := mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "client1", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "secret1", Key: "key1", }, Audience: "audience1", }, } - spec2 := mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + spec2 := mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "client2", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "secret2", Key: "key2", }, @@ -123,24 +123,24 @@ func TestMCPExternalAuthConfigReconciler_Reconcile(t *testing.T) { tests := []struct { name string - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig - existingMCPServer *mcpv1alpha1.MCPServer + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig + existingMCPServer *mcpv1beta1.MCPServer expectFinalizer bool expectHash bool }{ { name: "new external auth config without references", - externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, @@ -153,17 +153,17 @@ func TestMCPExternalAuthConfigReconciler_Reconcile(t *testing.T) { }, { name: "external auth config with referencing mcpserver", - externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, @@ -172,14 +172,14 @@ func TestMCPExternalAuthConfigReconciler_Reconcile(t *testing.T) { }, }, }, - existingMCPServer: &mcpv1alpha1.MCPServer{ + existingMCPServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-config", }, }, @@ -196,7 +196,7 @@ func TestMCPExternalAuthConfigReconciler_Reconcile(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // Create fake client with objects @@ -207,7 +207,7 @@ func TestMCPExternalAuthConfigReconciler_Reconcile(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPExternalAuthConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPExternalAuthConfig{}). Build() r := &MCPExternalAuthConfigReconciler{ @@ -236,7 +236,7 @@ func TestMCPExternalAuthConfigReconciler_Reconcile(t *testing.T) { } // Check the updated MCPExternalAuthConfig - var updatedConfig mcpv1alpha1.MCPExternalAuthConfig + var updatedConfig mcpv1beta1.MCPExternalAuthConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) @@ -255,7 +255,7 @@ func TestMCPExternalAuthConfigReconciler_Reconcile(t *testing.T) { // Check referencing workloads in status if tt.existingMCPServer != nil { assert.Contains(t, updatedConfig.Status.ReferencingWorkloads, - mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: tt.existingMCPServer.Name}, + mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: tt.existingMCPServer.Name}, "Status should contain referencing MCPServer as WorkloadReference") } }) @@ -266,19 +266,19 @@ func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads(t *testing.T) t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, @@ -287,38 +287,38 @@ func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads(t *testing.T) }, } - mcpServer1 := &mcpv1alpha1.MCPServer{ + mcpServer1 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-config", }, }, } - mcpServer2 := &mcpv1alpha1.MCPServer{ + mcpServer2 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-config", }, }, } - mcpServer3 := &mcpv1alpha1.MCPServer{ + mcpServer3 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server3", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", // No ExternalAuthConfigRef }, @@ -339,9 +339,9 @@ func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads(t *testing.T) require.NoError(t, err) assert.Len(t, refs, 2, "Should find 2 referencing workloads") - assert.Contains(t, refs, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server1"}) - assert.Contains(t, refs, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server2"}) - assert.NotContains(t, refs, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server3"}) + assert.Contains(t, refs, mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server1"}) + assert.Contains(t, refs, mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server2"}) + assert.NotContains(t, refs, mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server3"}) } func TestGetExternalAuthConfigForMCPServer(t *testing.T) { @@ -349,19 +349,19 @@ func TestGetExternalAuthConfigForMCPServer(t *testing.T) { tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer - existingConfig *mcpv1alpha1.MCPExternalAuthConfig + mcpServer *mcpv1beta1.MCPServer + existingConfig *mcpv1beta1.MCPExternalAuthConfig expectConfig bool expectError bool }{ { name: "mcpserver without external auth config ref", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", }, }, @@ -370,29 +370,29 @@ func TestGetExternalAuthConfigForMCPServer(t *testing.T) { }, { name: "mcpserver with existing external auth config", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-config", }, }, }, - existingConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + existingConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, @@ -405,14 +405,14 @@ func TestGetExternalAuthConfigForMCPServer(t *testing.T) { }, { name: "mcpserver with non-existent external auth config", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "non-existent", }, }, @@ -429,7 +429,7 @@ func TestGetExternalAuthConfigForMCPServer(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) objs := []client.Object{} if tt.existingConfig != nil { @@ -464,14 +464,14 @@ func TestMCPExternalAuthConfigReconciler_handleDeletion(t *testing.T) { tests := []struct { name string - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig - referencingServers []*mcpv1alpha1.MCPServer + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig + referencingServers []*mcpv1beta1.MCPServer expectRequeue bool expectFinalizerRemoved bool }{ { name: "delete config without references", - externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", @@ -480,12 +480,12 @@ func TestMCPExternalAuthConfigReconciler_handleDeletion(t *testing.T) { Time: time.Now(), }, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, @@ -498,7 +498,7 @@ func TestMCPExternalAuthConfigReconciler_handleDeletion(t *testing.T) { }, { name: "delete config with references", - externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", @@ -507,12 +507,12 @@ func TestMCPExternalAuthConfigReconciler_handleDeletion(t *testing.T) { Time: time.Now(), }, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, @@ -520,15 +520,15 @@ func TestMCPExternalAuthConfigReconciler_handleDeletion(t *testing.T) { }, }, }, - referencingServers: []*mcpv1alpha1.MCPServer{ + referencingServers: []*mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-config", }, }, @@ -546,7 +546,7 @@ func TestMCPExternalAuthConfigReconciler_handleDeletion(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // Build objects list objs := []client.Object{tt.externalAuthConfig} @@ -557,7 +557,7 @@ func TestMCPExternalAuthConfigReconciler_handleDeletion(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPExternalAuthConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPExternalAuthConfig{}). Build() r := &MCPExternalAuthConfigReconciler{ @@ -594,21 +594,21 @@ func TestMCPExternalAuthConfigReconciler_ConfigChangeTriggersReconciliation(t *t ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", Generation: 1, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, @@ -617,14 +617,14 @@ func TestMCPExternalAuthConfigReconciler_ConfigChangeTriggersReconciliation(t *t }, } - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-config", }, }, @@ -633,7 +633,7 @@ func TestMCPExternalAuthConfigReconciler_ConfigChangeTriggersReconciliation(t *t fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(externalAuthConfig, mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPExternalAuthConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPExternalAuthConfig{}). Build() r := &MCPExternalAuthConfigReconciler{ @@ -659,7 +659,7 @@ func TestMCPExternalAuthConfigReconciler_ConfigChangeTriggersReconciliation(t *t assert.Equal(t, time.Duration(0), result.RequeueAfter) // Get updated config and check hash was set - var updatedConfig mcpv1alpha1.MCPExternalAuthConfig + var updatedConfig mcpv1beta1.MCPExternalAuthConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) assert.NotEmpty(t, updatedConfig.Status.ConfigHash, "Config hash should be set") @@ -676,7 +676,7 @@ func TestMCPExternalAuthConfigReconciler_ConfigChangeTriggersReconciliation(t *t require.NoError(t, err) // Get final config and verify hash changed - var finalConfig mcpv1alpha1.MCPExternalAuthConfig + var finalConfig mcpv1beta1.MCPExternalAuthConfig err = fakeClient.Get(ctx, req.NamespacedName, &finalConfig) require.NoError(t, err) assert.NotEmpty(t, finalConfig.Status.ConfigHash, "Config hash should still be set") @@ -684,7 +684,7 @@ func TestMCPExternalAuthConfigReconciler_ConfigChangeTriggersReconciliation(t *t assert.Equal(t, int64(2), finalConfig.Status.ObservedGeneration, "ObservedGeneration should be updated") // Verify MCPServer has annotation with new hash - var updatedServer mcpv1alpha1.MCPServer + var updatedServer mcpv1beta1.MCPServer err = fakeClient.Get(ctx, types.NamespacedName{ Name: mcpServer.Name, Namespace: mcpServer.Namespace, @@ -701,21 +701,21 @@ func TestMCPExternalAuthConfigReconciler_ReferencingWorkloadsUpdatedWithoutHashC ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", Generation: 1, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, @@ -727,7 +727,7 @@ func TestMCPExternalAuthConfigReconciler_ReferencingWorkloadsUpdatedWithoutHashC fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(externalAuthConfig). - WithStatusSubresource(&mcpv1alpha1.MCPExternalAuthConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPExternalAuthConfig{}). Build() r := &MCPExternalAuthConfigReconciler{ @@ -751,21 +751,21 @@ func TestMCPExternalAuthConfigReconciler_ReferencingWorkloadsUpdatedWithoutHashC _, err = r.Reconcile(ctx, req) require.NoError(t, err) - var updatedConfig mcpv1alpha1.MCPExternalAuthConfig + var updatedConfig mcpv1beta1.MCPExternalAuthConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) assert.NotEmpty(t, updatedConfig.Status.ConfigHash) assert.Empty(t, updatedConfig.Status.ReferencingWorkloads, "No workloads should be referencing yet") // Now add an MCPServer that references this config (without changing the config spec) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "new-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-config", }, }, @@ -779,7 +779,7 @@ func TestMCPExternalAuthConfigReconciler_ReferencingWorkloadsUpdatedWithoutHashC err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) assert.Contains(t, updatedConfig.Status.ReferencingWorkloads, - mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "new-server"}, + mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "new-server"}, "ReferencingWorkloads should be updated even without hash change") } @@ -789,21 +789,21 @@ func TestMCPExternalAuthConfigReconciler_ReferencingWorkloadsRemovedOnServerDele ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", Generation: 1, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, @@ -812,14 +812,14 @@ func TestMCPExternalAuthConfigReconciler_ReferencingWorkloadsRemovedOnServerDele }, } - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-to-delete", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-config", }, }, @@ -828,7 +828,7 @@ func TestMCPExternalAuthConfigReconciler_ReferencingWorkloadsRemovedOnServerDele fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(externalAuthConfig, mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPExternalAuthConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPExternalAuthConfig{}). Build() r := &MCPExternalAuthConfigReconciler{ @@ -852,11 +852,11 @@ func TestMCPExternalAuthConfigReconciler_ReferencingWorkloadsRemovedOnServerDele _, err = r.Reconcile(ctx, req) require.NoError(t, err) - var updatedConfig mcpv1alpha1.MCPExternalAuthConfig + var updatedConfig mcpv1beta1.MCPExternalAuthConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) assert.Contains(t, updatedConfig.Status.ReferencingWorkloads, - mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server-to-delete"}) + mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server-to-delete"}) // Delete the MCPServer require.NoError(t, fakeClient.Delete(ctx, mcpServer)) @@ -875,22 +875,22 @@ func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads_authServerRef( t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-server-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", AuthorizationEndpointBaseURL: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -898,14 +898,14 @@ func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads_authServerRef( } // Server referencing via authServerRef - serverViaAuthServerRef := &mcpv1alpha1.MCPServer{ + serverViaAuthServerRef := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-via-authserverref", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - AuthServerRef: &mcpv1alpha1.AuthServerRef{ + AuthServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "auth-server-config", }, @@ -913,26 +913,26 @@ func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads_authServerRef( } // Server referencing via externalAuthConfigRef - serverViaExtAuth := &mcpv1alpha1.MCPServer{ + serverViaExtAuth := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-via-extauth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-server-config", }, }, } // Server not referencing this config at all - serverNoRef := &mcpv1alpha1.MCPServer{ + serverNoRef := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-no-ref", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", }, } @@ -952,32 +952,32 @@ func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads_authServerRef( require.NoError(t, err) assert.Len(t, refs, 2, "Should find 2 referencing workloads (one via authServerRef, one via externalAuthConfigRef)") - assert.Contains(t, refs, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server-via-authserverref"}) - assert.Contains(t, refs, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server-via-extauth"}) - assert.NotContains(t, refs, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server-no-ref"}) + assert.Contains(t, refs, mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server-via-authserverref"}) + assert.Contains(t, refs, mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server-via-extauth"}) + assert.NotContains(t, refs, mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server-no-ref"}) } func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads_bothRefsOnSameServer(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // A server has externalAuthConfigRef pointing to "token-exchange-config" // AND authServerRef pointing to "embedded-auth-config". // Both configs should discover this server during reconciliation. - tokenExchangeConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + tokenExchangeConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "token-exchange-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, @@ -986,20 +986,20 @@ func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads_bothRefsOnSame }, } - embeddedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + embeddedAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "embedded-auth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", AuthorizationEndpointBaseURL: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -1007,17 +1007,17 @@ func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads_bothRefsOnSame } // Server with both refs pointing to different configs - serverWithBothRefs := &mcpv1alpha1.MCPServer{ + serverWithBothRefs := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-with-both-refs", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "token-exchange-config", }, - AuthServerRef: &mcpv1alpha1.AuthServerRef{ + AuthServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "embedded-auth-config", }, @@ -1040,13 +1040,13 @@ func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads_bothRefsOnSame refsForTokenExchange, err := r.findReferencingWorkloads(ctx, tokenExchangeConfig) require.NoError(t, err) assert.Len(t, refsForTokenExchange, 1, "token-exchange-config should find server via externalAuthConfigRef") - assert.Contains(t, refsForTokenExchange, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server-with-both-refs"}) + assert.Contains(t, refsForTokenExchange, mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server-with-both-refs"}) // Reconciling the embedded-auth-config should find the server via authServerRef refsForEmbedded, err := r.findReferencingWorkloads(ctx, embeddedAuthConfig) require.NoError(t, err) assert.Len(t, refsForEmbedded, 1, "embedded-auth-config should find server via authServerRef") - assert.Contains(t, refsForEmbedded, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server-with-both-refs"}) + assert.Contains(t, refsForEmbedded, mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server-with-both-refs"}) // Also verify findReferencingMCPServers returns the server for both configs serversForTokenExchange, err := r.findReferencingMCPServers(ctx, tokenExchangeConfig) @@ -1064,21 +1064,21 @@ func TestMCPExternalAuthConfigReconciler_findReferencingMCPServers_deduplicates( t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // A server has both externalAuthConfigRef and authServerRef pointing to the SAME config. // The server should appear only once in the results. - config := &mcpv1alpha1.MCPExternalAuthConfig{ + config := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "shared-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, @@ -1087,17 +1087,17 @@ func TestMCPExternalAuthConfigReconciler_findReferencingMCPServers_deduplicates( }, } - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-both-same", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "shared-config", }, - AuthServerRef: &mcpv1alpha1.AuthServerRef{ + AuthServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "shared-config", }, @@ -1125,22 +1125,22 @@ func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads_mcpRemoteProxy t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - config := &mcpv1alpha1.MCPExternalAuthConfig{ + config := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", AuthorizationEndpointBaseURL: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -1148,28 +1148,28 @@ func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads_mcpRemoteProxy } // MCPRemoteProxy referencing via externalAuthConfigRef - proxyViaExtAuth := &mcpv1alpha1.MCPRemoteProxy{ + proxyViaExtAuth := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "proxy-via-extauth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote.example.com", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-config", }, }, } // MCPRemoteProxy referencing via authServerRef - proxyViaAuthServerRef := &mcpv1alpha1.MCPRemoteProxy{ + proxyViaAuthServerRef := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "proxy-via-authserverref", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote.example.com", - AuthServerRef: &mcpv1alpha1.AuthServerRef{ + AuthServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "auth-config", }, @@ -1177,25 +1177,25 @@ func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads_mcpRemoteProxy } // MCPRemoteProxy not referencing this config - proxyNoRef := &mcpv1alpha1.MCPRemoteProxy{ + proxyNoRef := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "proxy-no-ref", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote.example.com", }, } // MCPServer also referencing the same config - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-ref", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-config", }, }, @@ -1216,8 +1216,8 @@ func TestMCPExternalAuthConfigReconciler_findReferencingWorkloads_mcpRemoteProxy require.NoError(t, err) assert.Len(t, refs, 3, "Should find 3 referencing workloads (1 MCPServer + 2 MCPRemoteProxies)") - assert.Contains(t, refs, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server-ref"}) - assert.Contains(t, refs, mcpv1alpha1.WorkloadReference{Kind: "MCPRemoteProxy", Name: "proxy-via-extauth"}) - assert.Contains(t, refs, mcpv1alpha1.WorkloadReference{Kind: "MCPRemoteProxy", Name: "proxy-via-authserverref"}) - assert.NotContains(t, refs, mcpv1alpha1.WorkloadReference{Kind: "MCPRemoteProxy", Name: "proxy-no-ref"}) + assert.Contains(t, refs, mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server-ref"}) + assert.Contains(t, refs, mcpv1beta1.WorkloadReference{Kind: "MCPRemoteProxy", Name: "proxy-via-extauth"}) + assert.Contains(t, refs, mcpv1beta1.WorkloadReference{Kind: "MCPRemoteProxy", Name: "proxy-via-authserverref"}) + assert.NotContains(t, refs, mcpv1beta1.WorkloadReference{Kind: "MCPRemoteProxy", Name: "proxy-no-ref"}) } diff --git a/cmd/thv-operator/controllers/mcpgroup_controller.go b/cmd/thv-operator/controllers/mcpgroup_controller.go index 41efb7c108..0c86821500 100644 --- a/cmd/thv-operator/controllers/mcpgroup_controller.go +++ b/cmd/thv-operator/controllers/mcpgroup_controller.go @@ -18,7 +18,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) const ( @@ -48,7 +48,7 @@ func (r *MCPGroupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c ctxLogger.Info("Reconciling MCPGroup", "mcpgroup", req.NamespacedName) // Fetch the MCPGroup instance - mcpGroup := &mcpv1alpha1.MCPGroup{} + mcpGroup := &mcpv1beta1.MCPGroup{} err := r.Get(ctx, req.NamespacedName, mcpGroup) if err != nil { if errors.IsNotFound(err) { @@ -86,7 +86,7 @@ func (r *MCPGroupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c // and updates the MCPGroup status accordingly. func (r *MCPGroupReconciler) updateGroupMemberStatus( ctx context.Context, - mcpGroup *mcpv1alpha1.MCPGroup, + mcpGroup *mcpv1beta1.MCPGroup, ) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) @@ -109,9 +109,9 @@ func (r *MCPGroupReconciler) updateGroupMemberStatus( } meta.SetStatusCondition(&mcpGroup.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServersChecked, + Type: mcpv1beta1.ConditionTypeMCPServersChecked, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonListMCPServersSucceeded, + Reason: mcpv1beta1.ConditionReasonListMCPServersSucceeded, Message: "Successfully listed MCPServers, MCPRemoteProxies, and MCPServerEntries in namespace", ObservedGeneration: mcpGroup.Generation, }) @@ -125,7 +125,7 @@ func (r *MCPGroupReconciler) updateGroupMemberStatus( // Set MCPGroup status fields for MCPServerEntries r.populateEntryStatus(mcpGroup, mcpServerEntries) - mcpGroup.Status.Phase = mcpv1alpha1.MCPGroupPhaseReady + mcpGroup.Status.Phase = mcpv1beta1.MCPGroupPhaseReady // Update ObservedGeneration to reflect that we've processed this generation mcpGroup.Status.ObservedGeneration = mcpGroup.Generation @@ -149,18 +149,18 @@ func (r *MCPGroupReconciler) updateGroupMemberStatus( // handleListFailure handles the case when listing MCPServers, MCPRemoteProxies, or MCPServerEntries fails. func (r *MCPGroupReconciler) handleListFailure( ctx context.Context, - mcpGroup *mcpv1alpha1.MCPGroup, + mcpGroup *mcpv1beta1.MCPGroup, listErr error, resourceType string, ) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) ctxLogger.Error(listErr, "Failed to list "+resourceType) - mcpGroup.Status.Phase = mcpv1alpha1.MCPGroupPhaseFailed + mcpGroup.Status.Phase = mcpv1beta1.MCPGroupPhaseFailed meta.SetStatusCondition(&mcpGroup.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServersChecked, + Type: mcpv1beta1.ConditionTypeMCPServersChecked, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonListMCPServersFailed, + Reason: mcpv1beta1.ConditionReasonListMCPServersFailed, Message: "Failed to list " + resourceType + " in namespace", ObservedGeneration: mcpGroup.Generation, }) @@ -187,8 +187,8 @@ func (r *MCPGroupReconciler) handleListFailure( // populateServerStatus populates the MCPGroup status with MCPServer information. func (*MCPGroupReconciler) populateServerStatus( - mcpGroup *mcpv1alpha1.MCPGroup, - mcpServers []mcpv1alpha1.MCPServer, + mcpGroup *mcpv1beta1.MCPGroup, + mcpServers []mcpv1beta1.MCPServer, ) { mcpGroup.Status.ServerCount = int32(len(mcpServers)) //nolint:gosec // count is bounded by k8s list size if len(mcpServers) == 0 { @@ -204,8 +204,8 @@ func (*MCPGroupReconciler) populateServerStatus( // populateRemoteProxyStatus populates the MCPGroup status with MCPRemoteProxy information. func (*MCPGroupReconciler) populateRemoteProxyStatus( - mcpGroup *mcpv1alpha1.MCPGroup, - mcpRemoteProxies []mcpv1alpha1.MCPRemoteProxy, + mcpGroup *mcpv1beta1.MCPGroup, + mcpRemoteProxies []mcpv1beta1.MCPRemoteProxy, ) { mcpGroup.Status.RemoteProxyCount = int32(len(mcpRemoteProxies)) //nolint:gosec // count is bounded by k8s list size if len(mcpRemoteProxies) == 0 { @@ -221,8 +221,8 @@ func (*MCPGroupReconciler) populateRemoteProxyStatus( // populateEntryStatus populates the MCPGroup status with MCPServerEntry information. func (*MCPGroupReconciler) populateEntryStatus( - mcpGroup *mcpv1alpha1.MCPGroup, - mcpServerEntries []mcpv1alpha1.MCPServerEntry, + mcpGroup *mcpv1beta1.MCPGroup, + mcpServerEntries []mcpv1beta1.MCPServerEntry, ) { mcpGroup.Status.EntryCount = int32(len(mcpServerEntries)) //nolint:gosec // count is bounded by k8s list size if len(mcpServerEntries) == 0 { @@ -237,7 +237,7 @@ func (*MCPGroupReconciler) populateEntryStatus( } // handleDeletion handles the deletion of an MCPGroup -func (r *MCPGroupReconciler) handleDeletion(ctx context.Context, mcpGroup *mcpv1alpha1.MCPGroup) (ctrl.Result, error) { +func (r *MCPGroupReconciler) handleDeletion(ctx context.Context, mcpGroup *mcpv1beta1.MCPGroup) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) if controllerutil.ContainsFinalizer(mcpGroup, MCPGroupFinalizerName) { @@ -298,9 +298,9 @@ func (r *MCPGroupReconciler) handleDeletion(ctx context.Context, mcpGroup *mcpv1 // findReferencingMCPServers finds all MCPServers that reference the given MCPGroup func (r *MCPGroupReconciler) findReferencingMCPServers( - ctx context.Context, mcpGroup *mcpv1alpha1.MCPGroup) ([]mcpv1alpha1.MCPServer, error) { + ctx context.Context, mcpGroup *mcpv1beta1.MCPGroup) ([]mcpv1beta1.MCPServer, error) { - mcpServerList := &mcpv1alpha1.MCPServerList{} + mcpServerList := &mcpv1beta1.MCPServerList{} listOpts := []client.ListOption{ client.InNamespace(mcpGroup.Namespace), client.MatchingFields{"spec.groupRef": mcpGroup.Name}, @@ -314,9 +314,9 @@ func (r *MCPGroupReconciler) findReferencingMCPServers( // findReferencingMCPRemoteProxies finds all MCPRemoteProxies that reference the given MCPGroup func (r *MCPGroupReconciler) findReferencingMCPRemoteProxies( - ctx context.Context, mcpGroup *mcpv1alpha1.MCPGroup) ([]mcpv1alpha1.MCPRemoteProxy, error) { + ctx context.Context, mcpGroup *mcpv1beta1.MCPGroup) ([]mcpv1beta1.MCPRemoteProxy, error) { - mcpRemoteProxyList := &mcpv1alpha1.MCPRemoteProxyList{} + mcpRemoteProxyList := &mcpv1beta1.MCPRemoteProxyList{} listOpts := []client.ListOption{ client.InNamespace(mcpGroup.Namespace), client.MatchingFields{"spec.groupRef": mcpGroup.Name}, @@ -330,9 +330,9 @@ func (r *MCPGroupReconciler) findReferencingMCPRemoteProxies( // findReferencingMCPServerEntries finds all MCPServerEntries that reference the given MCPGroup func (r *MCPGroupReconciler) findReferencingMCPServerEntries( - ctx context.Context, mcpGroup *mcpv1alpha1.MCPGroup) ([]mcpv1alpha1.MCPServerEntry, error) { + ctx context.Context, mcpGroup *mcpv1beta1.MCPGroup) ([]mcpv1beta1.MCPServerEntry, error) { - mcpServerEntryList := &mcpv1alpha1.MCPServerEntryList{} + mcpServerEntryList := &mcpv1beta1.MCPServerEntryList{} listOpts := []client.ListOption{ client.InNamespace(mcpGroup.Namespace), client.MatchingFields{"spec.groupRef": mcpGroup.Name}, @@ -346,15 +346,15 @@ func (r *MCPGroupReconciler) findReferencingMCPServerEntries( // updateReferencingServersOnDeletion updates the conditions on MCPServers to indicate their group is being deleted func (r *MCPGroupReconciler) updateReferencingServersOnDeletion( - ctx context.Context, servers []mcpv1alpha1.MCPServer, groupName string) { + ctx context.Context, servers []mcpv1beta1.MCPServer, groupName string) { ctxLogger := log.FromContext(ctx) for _, server := range servers { // Update the condition to indicate the group is being deleted meta.SetStatusCondition(&server.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionGroupRefValidated, + Type: mcpv1beta1.ConditionGroupRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonGroupRefNotFound, + Reason: mcpv1beta1.ConditionReasonGroupRefNotFound, Message: "Referenced MCPGroup is being deleted", ObservedGeneration: server.Generation, }) @@ -373,15 +373,15 @@ func (r *MCPGroupReconciler) updateReferencingServersOnDeletion( // updateReferencingRemoteProxiesOnDeletion updates the conditions on MCPRemoteProxies to indicate their group is being deleted func (r *MCPGroupReconciler) updateReferencingRemoteProxiesOnDeletion( - ctx context.Context, proxies []mcpv1alpha1.MCPRemoteProxy, groupName string) { + ctx context.Context, proxies []mcpv1beta1.MCPRemoteProxy, groupName string) { ctxLogger := log.FromContext(ctx) for _, proxy := range proxies { // Update the condition to indicate the group is being deleted meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyGroupRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyGroupRefNotFound, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyGroupRefNotFound, Message: "Referenced MCPGroup is being deleted", ObservedGeneration: proxy.Generation, }) @@ -400,14 +400,14 @@ func (r *MCPGroupReconciler) updateReferencingRemoteProxiesOnDeletion( // updateReferencingEntriesOnDeletion updates the conditions on MCPServerEntries to indicate their group is being deleted func (r *MCPGroupReconciler) updateReferencingEntriesOnDeletion( - ctx context.Context, entries []mcpv1alpha1.MCPServerEntry, groupName string) { + ctx context.Context, entries []mcpv1beta1.MCPServerEntry, groupName string) { ctxLogger := log.FromContext(ctx) for _, entry := range entries { meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServerEntryGroupRefValidated, + Type: mcpv1beta1.ConditionTypeMCPServerEntryGroupRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPServerEntryGroupRefNotFound, + Reason: mcpv1beta1.ConditionReasonMCPServerEntryGroupRefNotFound, Message: "Referenced MCPGroup is being deleted", ObservedGeneration: entry.Generation, }) @@ -426,7 +426,7 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPServer(ctx context.Context, obj c ctxLogger := log.FromContext(ctx) // Get the MCPServer object - mcpServer, ok := obj.(*mcpv1alpha1.MCPServer) + mcpServer, ok := obj.(*mcpv1beta1.MCPServer) if !ok { ctxLogger.Error(nil, "Object is not an MCPServer", "object", obj.GetName()) return []ctrl.Request{} @@ -446,7 +446,7 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPServer(ctx context.Context, obj c obj.GetName(), "groupRef", groupName) - group := &mcpv1alpha1.MCPGroup{} + group := &mcpv1beta1.MCPGroup{} if err := r.Get(ctx, types.NamespacedName{Namespace: obj.GetNamespace(), Name: groupName}, group); err != nil { ctxLogger.Error(err, "Failed to get MCPGroup for MCPServer", "namespace", obj.GetNamespace(), "name", groupName) return []ctrl.Request{} @@ -465,7 +465,7 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPRemoteProxy(ctx context.Context, ctxLogger := log.FromContext(ctx) // Get the MCPRemoteProxy object - mcpRemoteProxy, ok := obj.(*mcpv1alpha1.MCPRemoteProxy) + mcpRemoteProxy, ok := obj.(*mcpv1beta1.MCPRemoteProxy) if !ok { ctxLogger.Error(nil, "Object is not an MCPRemoteProxy", "object", obj.GetName()) return []ctrl.Request{} @@ -485,7 +485,7 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPRemoteProxy(ctx context.Context, obj.GetName(), "groupRef", groupName) - group := &mcpv1alpha1.MCPGroup{} + group := &mcpv1beta1.MCPGroup{} groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: groupName} if err := r.Get(ctx, groupKey, group); err != nil { ctxLogger.Error(err, "Failed to get MCPGroup for MCPRemoteProxy", @@ -505,7 +505,7 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPRemoteProxy(ctx context.Context, func (r *MCPGroupReconciler) findMCPGroupForMCPServerEntry(ctx context.Context, obj client.Object) []ctrl.Request { ctxLogger := log.FromContext(ctx) - mcpServerEntry, ok := obj.(*mcpv1alpha1.MCPServerEntry) + mcpServerEntry, ok := obj.(*mcpv1beta1.MCPServerEntry) if !ok { ctxLogger.Error(nil, "Object is not an MCPServerEntry", "object", obj.GetName()) return []ctrl.Request{} @@ -520,7 +520,7 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPServerEntry(ctx context.Context, "namespace", obj.GetNamespace(), "mcpserverentry", obj.GetName(), "groupRef", groupName) - group := &mcpv1alpha1.MCPGroup{} + group := &mcpv1beta1.MCPGroup{} groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: groupName} if err := r.Get(ctx, groupKey, group); err != nil { ctxLogger.Error(err, "Failed to get MCPGroup for MCPServerEntry", @@ -540,15 +540,15 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPServerEntry(ctx context.Context, // SetupWithManager sets up the controller with the Manager. func (r *MCPGroupReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1alpha1.MCPGroup{}). + For(&mcpv1beta1.MCPGroup{}). Watches( - &mcpv1alpha1.MCPServer{}, handler.EnqueueRequestsFromMapFunc(r.findMCPGroupForMCPServer), + &mcpv1beta1.MCPServer{}, handler.EnqueueRequestsFromMapFunc(r.findMCPGroupForMCPServer), ). Watches( - &mcpv1alpha1.MCPRemoteProxy{}, handler.EnqueueRequestsFromMapFunc(r.findMCPGroupForMCPRemoteProxy), + &mcpv1beta1.MCPRemoteProxy{}, handler.EnqueueRequestsFromMapFunc(r.findMCPGroupForMCPRemoteProxy), ). Watches( - &mcpv1alpha1.MCPServerEntry{}, handler.EnqueueRequestsFromMapFunc(r.findMCPGroupForMCPServerEntry), + &mcpv1beta1.MCPServerEntry{}, handler.EnqueueRequestsFromMapFunc(r.findMCPGroupForMCPServerEntry), ). Complete(r) } diff --git a/cmd/thv-operator/controllers/mcpgroup_controller_test.go b/cmd/thv-operator/controllers/mcpgroup_controller_test.go index 82b8b80471..9079ccbb61 100644 --- a/cmd/thv-operator/controllers/mcpgroup_controller_test.go +++ b/cmd/thv-operator/controllers/mcpgroup_controller_test.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) const ( @@ -31,32 +31,32 @@ func TestMCPGroupReconciler_Reconcile_BasicLogic(t *testing.T) { tests := []struct { name string - mcpGroup *mcpv1alpha1.MCPGroup - mcpServers []*mcpv1alpha1.MCPServer + mcpGroup *mcpv1beta1.MCPGroup + mcpServers []*mcpv1beta1.MCPServer expectedServerCount int32 expectedServerNames []string - expectedPhase mcpv1alpha1.MCPGroupPhase + expectedPhase mcpv1beta1.MCPGroupPhase }{ { name: "group with two running servers should be ready", - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, Namespace: "default", }, }, - mcpServers: []*mcpv1alpha1.MCPServer{ + mcpServers: []*mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, }, }, { @@ -64,39 +64,39 @@ func TestMCPGroupReconciler_Reconcile_BasicLogic(t *testing.T) { Name: "server2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, }, }, }, expectedServerCount: 2, expectedServerNames: []string{"server1", "server2"}, - expectedPhase: mcpv1alpha1.MCPGroupPhaseReady, + expectedPhase: mcpv1beta1.MCPGroupPhaseReady, }, { name: "group with servers regardless of status should be ready", - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, Namespace: "default", }, }, - mcpServers: []*mcpv1alpha1.MCPServer{ + mcpServers: []*mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, }, }, { @@ -104,39 +104,39 @@ func TestMCPGroupReconciler_Reconcile_BasicLogic(t *testing.T) { Name: "server2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseFailed, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseFailed, }, }, }, expectedServerCount: 2, expectedServerNames: []string{"server1", "server2"}, - expectedPhase: mcpv1alpha1.MCPGroupPhaseReady, // Controller doesn't check individual server phases + expectedPhase: mcpv1beta1.MCPGroupPhaseReady, // Controller doesn't check individual server phases }, { name: "group with mixed server phases should be ready", - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, Namespace: "default", }, }, - mcpServers: []*mcpv1alpha1.MCPServer{ + mcpServers: []*mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, }, }, { @@ -144,31 +144,31 @@ func TestMCPGroupReconciler_Reconcile_BasicLogic(t *testing.T) { Name: "server2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhasePending, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhasePending, }, }, }, expectedServerCount: 2, expectedServerNames: []string{"server1", "server2"}, - expectedPhase: mcpv1alpha1.MCPGroupPhaseReady, // Controller doesn't check individual server phases + expectedPhase: mcpv1beta1.MCPGroupPhaseReady, // Controller doesn't check individual server phases }, { name: "group with no servers should be ready", - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, Namespace: "default", }, }, - mcpServers: []*mcpv1alpha1.MCPServer{}, + mcpServers: []*mcpv1beta1.MCPServer{}, expectedServerCount: 0, expectedServerNames: []string{}, - expectedPhase: mcpv1alpha1.MCPGroupPhaseReady, + expectedPhase: mcpv1beta1.MCPGroupPhaseReady, }, } @@ -178,7 +178,7 @@ func TestMCPGroupReconciler_Reconcile_BasicLogic(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // Create fake client with objects @@ -190,23 +190,23 @@ func TestMCPGroupReconciler_Reconcile_BasicLogic(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPGroup{}). - WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + WithStatusSubresource(&mcpv1beta1.MCPGroup{}). + WithIndex(&mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) if mcpServer.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } @@ -237,7 +237,7 @@ func TestMCPGroupReconciler_Reconcile_BasicLogic(t *testing.T) { assert.False(t, result.RequeueAfter > 0, "Should not requeue") // Check the updated MCPGroup - var updatedGroup mcpv1alpha1.MCPGroup + var updatedGroup mcpv1beta1.MCPGroup err = fakeClient.Get(ctx, req.NamespacedName, &updatedGroup) require.NoError(t, err) @@ -256,7 +256,7 @@ func TestMCPGroupReconciler_ServerFiltering(t *testing.T) { name string groupName string namespace string - mcpServers []*mcpv1alpha1.MCPServer + mcpServers []*mcpv1beta1.MCPServer expectedServerNames []string expectedCount int32 }{ @@ -264,18 +264,18 @@ func TestMCPGroupReconciler_ServerFiltering(t *testing.T) { name: "filters servers by exact groupRef match", groupName: testGroupName, namespace: "default", - mcpServers: []*mcpv1alpha1.MCPServer{ + mcpServers: []*mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{Name: "server1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server2", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test", GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server3", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, }, expectedServerNames: []string{"server1", "server3"}, @@ -285,14 +285,14 @@ func TestMCPGroupReconciler_ServerFiltering(t *testing.T) { name: "excludes servers without groupRef", groupName: testGroupName, namespace: "default", - mcpServers: []*mcpv1alpha1.MCPServer{ + mcpServers: []*mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{Name: "server1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server2", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test"}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test"}, }, }, expectedServerNames: []string{"server1"}, @@ -302,14 +302,14 @@ func TestMCPGroupReconciler_ServerFiltering(t *testing.T) { name: "excludes servers from different namespaces", groupName: testGroupName, namespace: "namespace-a", - mcpServers: []*mcpv1alpha1.MCPServer{ + mcpServers: []*mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{Name: "server1", Namespace: "namespace-a"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server2", Namespace: "namespace-b"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, }, expectedServerNames: []string{"server1"}, @@ -323,10 +323,10 @@ func TestMCPGroupReconciler_ServerFiltering(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: tt.groupName, Namespace: tt.namespace, @@ -341,23 +341,23 @@ func TestMCPGroupReconciler_ServerFiltering(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPGroup{}). - WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + WithStatusSubresource(&mcpv1beta1.MCPGroup{}). + WithIndex(&mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) if mcpServer.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } @@ -386,7 +386,7 @@ func TestMCPGroupReconciler_ServerFiltering(t *testing.T) { require.NoError(t, err) assert.False(t, result.RequeueAfter > 0, "Should not requeue") - var updatedGroup mcpv1alpha1.MCPGroup + var updatedGroup mcpv1beta1.MCPGroup err = fakeClient.Get(ctx, req.NamespacedName, &updatedGroup) require.NoError(t, err) @@ -402,24 +402,24 @@ func TestMCPGroupReconciler_findMCPGroupForMCPServer(t *testing.T) { tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer - mcpGroups []*mcpv1alpha1.MCPGroup + mcpServer *mcpv1beta1.MCPServer + mcpGroups []*mcpv1beta1.MCPGroup expectedRequests int expectedGroupName string }{ { name: "server with groupRef finds matching group", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, }, - mcpGroups: []*mcpv1alpha1.MCPGroup{ + mcpGroups: []*mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, @@ -432,17 +432,17 @@ func TestMCPGroupReconciler_findMCPGroupForMCPServer(t *testing.T) { }, { name: "server without groupRef returns empty", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", // No GroupRef }, }, - mcpGroups: []*mcpv1alpha1.MCPGroup{ + mcpGroups: []*mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, @@ -454,17 +454,17 @@ func TestMCPGroupReconciler_findMCPGroupForMCPServer(t *testing.T) { }, { name: "server with non-existent groupRef returns empty", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "non-existent-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "non-existent-group"}, }, }, - mcpGroups: []*mcpv1alpha1.MCPGroup{ + mcpGroups: []*mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, @@ -476,17 +476,17 @@ func TestMCPGroupReconciler_findMCPGroupForMCPServer(t *testing.T) { }, { name: "server finds correct group among multiple groups", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-b"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-b"}, }, }, - mcpGroups: []*mcpv1alpha1.MCPGroup{ + mcpGroups: []*mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "group-a", @@ -517,7 +517,7 @@ func TestMCPGroupReconciler_findMCPGroupForMCPServer(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // Create fake client with objects @@ -529,22 +529,22 @@ func TestMCPGroupReconciler_findMCPGroupForMCPServer(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + WithIndex(&mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) if mcpServer.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } @@ -573,27 +573,27 @@ func TestMCPGroupReconciler_GroupNotFound(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + WithIndex(&mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) if mcpServer.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } @@ -624,48 +624,48 @@ func TestMCPGroupReconciler_Conditions(t *testing.T) { tests := []struct { name string - mcpGroup *mcpv1alpha1.MCPGroup - mcpServers []*mcpv1alpha1.MCPServer + mcpGroup *mcpv1beta1.MCPGroup + mcpServers []*mcpv1beta1.MCPServer expectedConditionStatus metav1.ConditionStatus expectedConditionReason string - expectedPhase mcpv1alpha1.MCPGroupPhase + expectedPhase mcpv1beta1.MCPGroupPhase }{ { name: "MCPServersChecked condition is True when listing succeeds", - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, Namespace: "default", }, }, - mcpServers: []*mcpv1alpha1.MCPServer{ + mcpServers: []*mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, }, }, expectedConditionStatus: metav1.ConditionTrue, - expectedConditionReason: mcpv1alpha1.ConditionReasonListMCPServersSucceeded, - expectedPhase: mcpv1alpha1.MCPGroupPhaseReady, + expectedConditionReason: mcpv1beta1.ConditionReasonListMCPServersSucceeded, + expectedPhase: mcpv1beta1.MCPGroupPhaseReady, }, { name: "MCPServersChecked condition is True even with no servers", - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, Namespace: "default", }, }, - mcpServers: []*mcpv1alpha1.MCPServer{}, + mcpServers: []*mcpv1beta1.MCPServer{}, expectedConditionStatus: metav1.ConditionTrue, - expectedConditionReason: mcpv1alpha1.ConditionReasonListMCPServersSucceeded, - expectedPhase: mcpv1alpha1.MCPGroupPhaseReady, + expectedConditionReason: mcpv1beta1.ConditionReasonListMCPServersSucceeded, + expectedPhase: mcpv1beta1.MCPGroupPhaseReady, }, } @@ -675,7 +675,7 @@ func TestMCPGroupReconciler_Conditions(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) objs := []client.Object{tt.mcpGroup} @@ -686,23 +686,23 @@ func TestMCPGroupReconciler_Conditions(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPGroup{}). - WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + WithStatusSubresource(&mcpv1beta1.MCPGroup{}). + WithIndex(&mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) if mcpServer.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } @@ -731,7 +731,7 @@ func TestMCPGroupReconciler_Conditions(t *testing.T) { require.NoError(t, err) assert.False(t, result.RequeueAfter > 0, "Should not requeue") - var updatedGroup mcpv1alpha1.MCPGroup + var updatedGroup mcpv1beta1.MCPGroup err = fakeClient.Get(ctx, req.NamespacedName, &updatedGroup) require.NoError(t, err) @@ -740,7 +740,7 @@ func TestMCPGroupReconciler_Conditions(t *testing.T) { // Check the MCPServersChecked condition var condition *metav1.Condition for i := range updatedGroup.Status.Conditions { - if updatedGroup.Status.Conditions[i].Type == mcpv1alpha1.ConditionTypeMCPServersChecked { + if updatedGroup.Status.Conditions[i].Type == mcpv1beta1.ConditionTypeMCPServersChecked { condition = &updatedGroup.Status.Conditions[i] break } @@ -761,10 +761,10 @@ func TestMCPGroupReconciler_Finalizer(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, Namespace: "default", @@ -774,23 +774,23 @@ func TestMCPGroupReconciler_Finalizer(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(mcpGroup). - WithStatusSubresource(&mcpv1alpha1.MCPGroup{}, &mcpv1alpha1.MCPServer{}). - WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + WithStatusSubresource(&mcpv1beta1.MCPGroup{}, &mcpv1beta1.MCPServer{}). + WithIndex(&mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) if mcpServer.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } @@ -815,7 +815,7 @@ func TestMCPGroupReconciler_Finalizer(t *testing.T) { assert.True(t, result.RequeueAfter > 0, "Should requeue after adding finalizer") // Verify finalizer was added - var updatedGroup mcpv1alpha1.MCPGroup + var updatedGroup mcpv1beta1.MCPGroup err = fakeClient.Get(ctx, req.NamespacedName, &updatedGroup) require.NoError(t, err) assert.Contains(t, updatedGroup.Finalizers, MCPGroupFinalizerName) @@ -832,21 +832,21 @@ func TestMCPGroupReconciler_Deletion(t *testing.T) { tests := []struct { name string - mcpServers []*mcpv1alpha1.MCPServer + mcpServers []*mcpv1beta1.MCPServer expectedServerConditionType string shouldUpdateServers bool }{ { name: "deletion updates referencing servers", - mcpServers: []*mcpv1alpha1.MCPServer{ + mcpServers: []*mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, }, { @@ -854,26 +854,26 @@ func TestMCPGroupReconciler_Deletion(t *testing.T) { Name: "server2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, }, }, - expectedServerConditionType: mcpv1alpha1.ConditionGroupRefValidated, + expectedServerConditionType: mcpv1beta1.ConditionGroupRefValidated, shouldUpdateServers: true, }, { name: "deletion with no referencing servers succeeds", - mcpServers: []*mcpv1alpha1.MCPServer{ + mcpServers: []*mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}, }, }, }, @@ -887,12 +887,12 @@ func TestMCPGroupReconciler_Deletion(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // Create group with finalizer and deletion timestamp now := metav1.Now() - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, Namespace: "default", @@ -909,23 +909,23 @@ func TestMCPGroupReconciler_Deletion(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPGroup{}, &mcpv1alpha1.MCPServer{}). - WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + WithStatusSubresource(&mcpv1beta1.MCPGroup{}, &mcpv1beta1.MCPServer{}). + WithIndex(&mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) if mcpServer.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } @@ -950,7 +950,7 @@ func TestMCPGroupReconciler_Deletion(t *testing.T) { assert.False(t, result.RequeueAfter > 0, "Should not requeue on deletion") // Verify finalizer was removed (group might already be deleted by fake client) - var updatedGroup mcpv1alpha1.MCPGroup + var updatedGroup mcpv1beta1.MCPGroup err = fakeClient.Get(ctx, req.NamespacedName, &updatedGroup) // If the group still exists, verify finalizer was removed if err == nil { @@ -961,7 +961,7 @@ func TestMCPGroupReconciler_Deletion(t *testing.T) { if tt.shouldUpdateServers { for _, server := range tt.mcpServers { if server.Spec.GroupRef.GetName() == testGroupName { - var updatedServer mcpv1alpha1.MCPServer + var updatedServer mcpv1beta1.MCPServer err = fakeClient.Get(ctx, types.NamespacedName{ Name: server.Name, Namespace: server.Namespace, @@ -979,7 +979,7 @@ func TestMCPGroupReconciler_Deletion(t *testing.T) { require.NotNil(t, condition, "GroupRefValidated condition should be present") assert.Equal(t, metav1.ConditionFalse, condition.Status) - assert.Equal(t, mcpv1alpha1.ConditionReasonGroupRefNotFound, condition.Reason) + assert.Equal(t, mcpv1beta1.ConditionReasonGroupRefNotFound, condition.Reason) assert.Contains(t, condition.Message, "being deleted") } } @@ -996,7 +996,7 @@ func TestMCPGroupReconciler_findReferencingMCPServers(t *testing.T) { name string groupName string namespace string - mcpServers []*mcpv1alpha1.MCPServer + mcpServers []*mcpv1beta1.MCPServer expectedCount int expectedNames []string }{ @@ -1004,18 +1004,18 @@ func TestMCPGroupReconciler_findReferencingMCPServers(t *testing.T) { name: "finds servers with matching groupRef", groupName: testGroupName, namespace: "default", - mcpServers: []*mcpv1alpha1.MCPServer{ + mcpServers: []*mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{Name: "server1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server2", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test", GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server3", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, }, expectedCount: 2, @@ -1025,10 +1025,10 @@ func TestMCPGroupReconciler_findReferencingMCPServers(t *testing.T) { name: "returns empty when no servers reference the group", groupName: testGroupName, namespace: "default", - mcpServers: []*mcpv1alpha1.MCPServer{ + mcpServers: []*mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{Name: "server1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test", GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}}, }, }, expectedCount: 0, @@ -1038,14 +1038,14 @@ func TestMCPGroupReconciler_findReferencingMCPServers(t *testing.T) { name: "excludes servers from different namespaces", groupName: testGroupName, namespace: "namespace-a", - mcpServers: []*mcpv1alpha1.MCPServer{ + mcpServers: []*mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{Name: "server1", Namespace: "namespace-a"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server2", Namespace: "namespace-b"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, }, expectedCount: 1, @@ -1059,10 +1059,10 @@ func TestMCPGroupReconciler_findReferencingMCPServers(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: tt.groupName, Namespace: tt.namespace, @@ -1077,22 +1077,22 @@ func TestMCPGroupReconciler_findReferencingMCPServers(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + WithIndex(&mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) if mcpServer.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } @@ -1127,7 +1127,7 @@ func TestMCPGroupReconciler_findReferencingMCPRemoteProxies(t *testing.T) { name string groupName string namespace string - mcpRemoteProxies []*mcpv1alpha1.MCPRemoteProxy + mcpRemoteProxies []*mcpv1beta1.MCPRemoteProxy expectedCount int expectedNames []string }{ @@ -1135,18 +1135,18 @@ func TestMCPGroupReconciler_findReferencingMCPRemoteProxies(t *testing.T) { name: "finds remote proxies with matching groupRef", groupName: testGroupName, namespace: "default", - mcpRemoteProxies: []*mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxies: []*mcpv1beta1.MCPRemoteProxy{ { ObjectMeta: metav1.ObjectMeta{Name: "proxy1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, + Spec: mcpv1beta1.MCPRemoteProxySpec{GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "proxy2", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}}, + Spec: mcpv1beta1.MCPRemoteProxySpec{GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "proxy3", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, + Spec: mcpv1beta1.MCPRemoteProxySpec{GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, }, expectedCount: 2, @@ -1156,10 +1156,10 @@ func TestMCPGroupReconciler_findReferencingMCPRemoteProxies(t *testing.T) { name: "returns empty when no remote proxies reference the group", groupName: testGroupName, namespace: "default", - mcpRemoteProxies: []*mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxies: []*mcpv1beta1.MCPRemoteProxy{ { ObjectMeta: metav1.ObjectMeta{Name: "proxy1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}}, + Spec: mcpv1beta1.MCPRemoteProxySpec{GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}}, }, }, expectedCount: 0, @@ -1169,14 +1169,14 @@ func TestMCPGroupReconciler_findReferencingMCPRemoteProxies(t *testing.T) { name: "excludes remote proxies from different namespaces", groupName: testGroupName, namespace: "namespace-a", - mcpRemoteProxies: []*mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxies: []*mcpv1beta1.MCPRemoteProxy{ { ObjectMeta: metav1.ObjectMeta{Name: "proxy1", Namespace: "namespace-a"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, + Spec: mcpv1beta1.MCPRemoteProxySpec{GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "proxy2", Namespace: "namespace-b"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}}, + Spec: mcpv1beta1.MCPRemoteProxySpec{GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, }, expectedCount: 1, @@ -1186,7 +1186,7 @@ func TestMCPGroupReconciler_findReferencingMCPRemoteProxies(t *testing.T) { name: "returns empty when no remote proxies exist", groupName: testGroupName, namespace: "default", - mcpRemoteProxies: []*mcpv1alpha1.MCPRemoteProxy{}, + mcpRemoteProxies: []*mcpv1beta1.MCPRemoteProxy{}, expectedCount: 0, expectedNames: []string{}, }, @@ -1198,10 +1198,10 @@ func TestMCPGroupReconciler_findReferencingMCPRemoteProxies(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: tt.groupName, Namespace: tt.namespace, @@ -1216,22 +1216,22 @@ func TestMCPGroupReconciler_findReferencingMCPRemoteProxies(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + WithIndex(&mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) if mcpServer.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } @@ -1264,23 +1264,23 @@ func TestMCPGroupReconciler_findMCPGroupForMCPRemoteProxy(t *testing.T) { tests := []struct { name string - mcpRemoteProxy *mcpv1alpha1.MCPRemoteProxy - mcpGroups []*mcpv1alpha1.MCPGroup + mcpRemoteProxy *mcpv1beta1.MCPRemoteProxy + mcpGroups []*mcpv1beta1.MCPGroup expectedRequests int expectedGroupName string }{ { name: "remote proxy with groupRef finds matching group", - mcpRemoteProxy: &mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, }, - mcpGroups: []*mcpv1alpha1.MCPGroup{ + mcpGroups: []*mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, @@ -1293,16 +1293,16 @@ func TestMCPGroupReconciler_findMCPGroupForMCPRemoteProxy(t *testing.T) { }, { name: "remote proxy without groupRef returns empty", - mcpRemoteProxy: &mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ // No GroupRef }, }, - mcpGroups: []*mcpv1alpha1.MCPGroup{ + mcpGroups: []*mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, @@ -1314,16 +1314,16 @@ func TestMCPGroupReconciler_findMCPGroupForMCPRemoteProxy(t *testing.T) { }, { name: "remote proxy with non-existent groupRef returns empty", - mcpRemoteProxy: &mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "non-existent-group"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "non-existent-group"}, }, }, - mcpGroups: []*mcpv1alpha1.MCPGroup{ + mcpGroups: []*mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, @@ -1335,16 +1335,16 @@ func TestMCPGroupReconciler_findMCPGroupForMCPRemoteProxy(t *testing.T) { }, { name: "remote proxy finds correct group among multiple groups", - mcpRemoteProxy: &mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-b"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-b"}, }, }, - mcpGroups: []*mcpv1alpha1.MCPGroup{ + mcpGroups: []*mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "group-a", @@ -1375,7 +1375,7 @@ func TestMCPGroupReconciler_findMCPGroupForMCPRemoteProxy(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // Create fake client with objects @@ -1387,22 +1387,22 @@ func TestMCPGroupReconciler_findMCPGroupForMCPRemoteProxy(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + WithIndex(&mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) if mcpServer.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } @@ -1432,20 +1432,20 @@ func TestMCPGroupReconciler_updateReferencingRemoteProxiesOnDeletion(t *testing. tests := []struct { name string groupName string - mcpRemoteProxies []mcpv1alpha1.MCPRemoteProxy + mcpRemoteProxies []mcpv1beta1.MCPRemoteProxy expectedUpdates int }{ { name: "updates conditions on remote proxies", groupName: testGroupName, - mcpRemoteProxies: []mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxies: []mcpv1beta1.MCPRemoteProxy{ { ObjectMeta: metav1.ObjectMeta{ Name: "proxy1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, }, { @@ -1453,8 +1453,8 @@ func TestMCPGroupReconciler_updateReferencingRemoteProxiesOnDeletion(t *testing. Name: "proxy2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, }, }, @@ -1463,7 +1463,7 @@ func TestMCPGroupReconciler_updateReferencingRemoteProxiesOnDeletion(t *testing. { name: "handles empty proxy list", groupName: testGroupName, - mcpRemoteProxies: []mcpv1alpha1.MCPRemoteProxy{}, + mcpRemoteProxies: []mcpv1beta1.MCPRemoteProxy{}, expectedUpdates: 0, }, } @@ -1474,7 +1474,7 @@ func TestMCPGroupReconciler_updateReferencingRemoteProxiesOnDeletion(t *testing. ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) objs := []client.Object{} @@ -1485,23 +1485,23 @@ func TestMCPGroupReconciler_updateReferencingRemoteProxiesOnDeletion(t *testing. fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPRemoteProxy{}). - WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}). + WithIndex(&mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) if mcpServer.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) if mcpRemoteProxy.Spec.GroupRef.GetName() == "" { return nil } return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } @@ -1518,7 +1518,7 @@ func TestMCPGroupReconciler_updateReferencingRemoteProxiesOnDeletion(t *testing. // Verify that the proxies have been updated with the correct condition for _, proxy := range tt.mcpRemoteProxies { - updatedProxy := &mcpv1alpha1.MCPRemoteProxy{} + updatedProxy := &mcpv1beta1.MCPRemoteProxy{} err := fakeClient.Get(ctx, types.NamespacedName{ Name: proxy.Name, Namespace: proxy.Namespace, @@ -1527,11 +1527,11 @@ func TestMCPGroupReconciler_updateReferencingRemoteProxiesOnDeletion(t *testing. // Check that the GroupRefValidated condition is set to False condition := meta.FindStatusCondition(updatedProxy.Status.Conditions, - mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated) + mcpv1beta1.ConditionTypeMCPRemoteProxyGroupRefValidated) require.NotNil(t, condition, "Expected condition %s to be set", - mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated) + mcpv1beta1.ConditionTypeMCPRemoteProxyGroupRefValidated) assert.Equal(t, metav1.ConditionFalse, condition.Status) - assert.Equal(t, mcpv1alpha1.ConditionReasonMCPRemoteProxyGroupRefNotFound, condition.Reason) + assert.Equal(t, mcpv1beta1.ConditionReasonMCPRemoteProxyGroupRefNotFound, condition.Reason) assert.Contains(t, condition.Message, "being deleted") } }) diff --git a/cmd/thv-operator/controllers/mcpoidcconfig_controller.go b/cmd/thv-operator/controllers/mcpoidcconfig_controller.go index 1db9d40973..5af6eb34e4 100644 --- a/cmd/thv-operator/controllers/mcpoidcconfig_controller.go +++ b/cmd/thv-operator/controllers/mcpoidcconfig_controller.go @@ -20,7 +20,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -54,7 +54,7 @@ func (r *MCPOIDCConfigReconciler) Reconcile(ctx context.Context, req ctrl.Reques logger := log.FromContext(ctx) // Fetch the MCPOIDCConfig instance - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{} + oidcConfig := &mcpv1beta1.MCPOIDCConfig{} err := r.Get(ctx, req.NamespacedName, oidcConfig) if err != nil { if errors.IsNotFound(err) { @@ -84,9 +84,9 @@ func (r *MCPOIDCConfigReconciler) Reconcile(ctx context.Context, req ctrl.Reques if err := oidcConfig.Validate(); err != nil { logger.Error(err, "MCPOIDCConfig spec validation failed") meta.SetStatusCondition(&oidcConfig.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeOIDCConfigValid, + Type: mcpv1beta1.ConditionTypeOIDCConfigValid, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonOIDCConfigInvalid, + Reason: mcpv1beta1.ConditionReasonOIDCConfigInvalid, Message: err.Error(), ObservedGeneration: oidcConfig.Generation, }) @@ -98,9 +98,9 @@ func (r *MCPOIDCConfigReconciler) Reconcile(ctx context.Context, req ctrl.Reques // Validation succeeded - set Valid=True condition conditionChanged := meta.SetStatusCondition(&oidcConfig.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeOIDCConfigValid, + Type: mcpv1beta1.ConditionTypeOIDCConfigValid, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonOIDCConfigValid, + Reason: mcpv1beta1.ConditionReasonOIDCConfigValid, Message: "Spec validation passed", ObservedGeneration: oidcConfig.Generation, }) @@ -146,7 +146,7 @@ func (r *MCPOIDCConfigReconciler) Reconcile(ctx context.Context, req ctrl.Reques } // calculateConfigHash calculates a hash of the MCPOIDCConfig spec using Kubernetes utilities -func (*MCPOIDCConfigReconciler) calculateConfigHash(spec mcpv1alpha1.MCPOIDCConfigSpec) string { +func (*MCPOIDCConfigReconciler) calculateConfigHash(spec mcpv1beta1.MCPOIDCConfigSpec) string { return ctrlutil.CalculateConfigHash(spec) } @@ -156,7 +156,7 @@ func (*MCPOIDCConfigReconciler) calculateConfigHash(spec mcpv1alpha1.MCPOIDCConf // and the resource can be garbage collected. func (r *MCPOIDCConfigReconciler) handleDeletion( ctx context.Context, - oidcConfig *mcpv1alpha1.MCPOIDCConfig, + oidcConfig *mcpv1beta1.MCPOIDCConfig, ) (ctrl.Result, error) { logger := log.FromContext(ctx) @@ -174,7 +174,7 @@ func (r *MCPOIDCConfigReconciler) handleDeletion( "referencingWorkloads", referencingWorkloads) meta.SetStatusCondition(&oidcConfig.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeDeletionBlocked, + Type: mcpv1beta1.ConditionTypeDeletionBlocked, Status: metav1.ConditionTrue, Reason: "ReferencedByWorkloads", Message: fmt.Sprintf("Cannot delete: referenced by workloads: %v", referencingWorkloads), @@ -204,11 +204,11 @@ func (r *MCPOIDCConfigReconciler) handleDeletion( // that reference this MCPOIDCConfig via their OIDCConfigRef field. func (r *MCPOIDCConfigReconciler) findReferencingWorkloads( ctx context.Context, - oidcConfig *mcpv1alpha1.MCPOIDCConfig, -) ([]mcpv1alpha1.WorkloadReference, error) { + oidcConfig *mcpv1beta1.MCPOIDCConfig, +) ([]mcpv1beta1.WorkloadReference, error) { // Find referencing MCPServers refs, err := ctrlutil.FindWorkloadRefsFromMCPServers(ctx, r.Client, oidcConfig.Namespace, oidcConfig.Name, - func(server *mcpv1alpha1.MCPServer) *string { + func(server *mcpv1beta1.MCPServer) *string { if server.Spec.OIDCConfigRef != nil { return &server.Spec.OIDCConfigRef.Name } @@ -219,7 +219,7 @@ func (r *MCPOIDCConfigReconciler) findReferencingWorkloads( } // Also check VirtualMCPServers - vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + vmcpList := &mcpv1beta1.VirtualMCPServerList{} if err := r.List(ctx, vmcpList, client.InNamespace(oidcConfig.Namespace)); err != nil { return nil, fmt.Errorf("failed to list VirtualMCPServers: %w", err) } @@ -227,18 +227,18 @@ func (r *MCPOIDCConfigReconciler) findReferencingWorkloads( if vmcp.Spec.IncomingAuth != nil && vmcp.Spec.IncomingAuth.OIDCConfigRef != nil && vmcp.Spec.IncomingAuth.OIDCConfigRef.Name == oidcConfig.Name { - refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindVirtualMCPServer, Name: vmcp.Name}) + refs = append(refs, mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindVirtualMCPServer, Name: vmcp.Name}) } } // Check MCPRemoteProxies - proxyList := &mcpv1alpha1.MCPRemoteProxyList{} + proxyList := &mcpv1beta1.MCPRemoteProxyList{} if err := r.List(ctx, proxyList, client.InNamespace(oidcConfig.Namespace)); err != nil { return nil, fmt.Errorf("failed to list MCPRemoteProxies: %w", err) } for _, proxy := range proxyList.Items { if proxy.Spec.OIDCConfigRef != nil && proxy.Spec.OIDCConfigRef.Name == oidcConfig.Name { - refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPRemoteProxy, Name: proxy.Name}) + refs = append(refs, mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindMCPRemoteProxy, Name: proxy.Name}) } } @@ -256,7 +256,7 @@ func (r *MCPOIDCConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { // config needs to reconcile and clean up the stale entry). mcpServerHandler := handler.EnqueueRequestsFromMapFunc( func(ctx context.Context, obj client.Object) []reconcile.Request { - server, ok := obj.(*mcpv1alpha1.MCPServer) + server, ok := obj.(*mcpv1beta1.MCPServer) if !ok { return nil } @@ -276,7 +276,7 @@ func (r *MCPOIDCConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { // Also enqueue any MCPOIDCConfig that still lists this server in // ReferencingWorkloads — handles ref-removal and server-deletion cases. - oidcConfigList := &mcpv1alpha1.MCPOIDCConfigList{} + oidcConfigList := &mcpv1beta1.MCPOIDCConfigList{} if err := r.List(ctx, oidcConfigList, client.InNamespace(server.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPOIDCConfigs for MCPServer watch") return requests @@ -287,7 +287,7 @@ func (r *MCPOIDCConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { continue } for _, ref := range cfg.Status.ReferencingWorkloads { - if ref.Kind == mcpv1alpha1.WorkloadKindMCPServer && ref.Name == server.Name { + if ref.Kind == mcpv1beta1.WorkloadKindMCPServer && ref.Name == server.Name { requests = append(requests, reconcile.Request{NamespacedName: nn}) break } @@ -299,14 +299,14 @@ func (r *MCPOIDCConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { ) return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1alpha1.MCPOIDCConfig{}). - Watches(&mcpv1alpha1.MCPServer{}, mcpServerHandler). + For(&mcpv1beta1.MCPOIDCConfig{}). + Watches(&mcpv1beta1.MCPServer{}, mcpServerHandler). Watches( - &mcpv1alpha1.VirtualMCPServer{}, + &mcpv1beta1.VirtualMCPServer{}, handler.EnqueueRequestsFromMapFunc(r.mapVirtualMCPServerToOIDCConfig), ). Watches( - &mcpv1alpha1.MCPRemoteProxy{}, + &mcpv1beta1.MCPRemoteProxy{}, handler.EnqueueRequestsFromMapFunc(r.mapMCPRemoteProxyToOIDCConfig), ). Complete(r) @@ -318,7 +318,7 @@ func (r *MCPOIDCConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { func (r *MCPOIDCConfigReconciler) mapVirtualMCPServerToOIDCConfig( ctx context.Context, obj client.Object, ) []reconcile.Request { - vmcp, ok := obj.(*mcpv1alpha1.VirtualMCPServer) + vmcp, ok := obj.(*mcpv1beta1.VirtualMCPServer) if !ok { return nil } @@ -338,7 +338,7 @@ func (r *MCPOIDCConfigReconciler) mapVirtualMCPServerToOIDCConfig( // Also enqueue any MCPOIDCConfig that still lists this VirtualMCPServer in // ReferencingWorkloads — handles ref-removal and deletion cases. - oidcConfigList := &mcpv1alpha1.MCPOIDCConfigList{} + oidcConfigList := &mcpv1beta1.MCPOIDCConfigList{} if err := r.List(ctx, oidcConfigList, client.InNamespace(vmcp.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPOIDCConfigs for VirtualMCPServer watch") return requests @@ -349,7 +349,7 @@ func (r *MCPOIDCConfigReconciler) mapVirtualMCPServerToOIDCConfig( continue } for _, ref := range cfg.Status.ReferencingWorkloads { - if ref.Kind == mcpv1alpha1.WorkloadKindVirtualMCPServer && ref.Name == vmcp.Name { + if ref.Kind == mcpv1beta1.WorkloadKindVirtualMCPServer && ref.Name == vmcp.Name { requests = append(requests, reconcile.Request{NamespacedName: nn}) break } @@ -365,7 +365,7 @@ func (r *MCPOIDCConfigReconciler) mapVirtualMCPServerToOIDCConfig( func (r *MCPOIDCConfigReconciler) mapMCPRemoteProxyToOIDCConfig( ctx context.Context, obj client.Object, ) []reconcile.Request { - proxy, ok := obj.(*mcpv1alpha1.MCPRemoteProxy) + proxy, ok := obj.(*mcpv1beta1.MCPRemoteProxy) if !ok { return nil } @@ -385,7 +385,7 @@ func (r *MCPOIDCConfigReconciler) mapMCPRemoteProxyToOIDCConfig( // Also enqueue any MCPOIDCConfig that still lists this MCPRemoteProxy in // ReferencingWorkloads — handles ref-removal and deletion cases. - oidcConfigList := &mcpv1alpha1.MCPOIDCConfigList{} + oidcConfigList := &mcpv1beta1.MCPOIDCConfigList{} if err := r.List(ctx, oidcConfigList, client.InNamespace(proxy.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPOIDCConfigs for MCPRemoteProxy watch") return requests @@ -396,7 +396,7 @@ func (r *MCPOIDCConfigReconciler) mapMCPRemoteProxyToOIDCConfig( continue } for _, ref := range cfg.Status.ReferencingWorkloads { - if ref.Kind == mcpv1alpha1.WorkloadKindMCPRemoteProxy && ref.Name == proxy.Name { + if ref.Kind == mcpv1beta1.WorkloadKindMCPRemoteProxy && ref.Name == proxy.Name { requests = append(requests, reconcile.Request{NamespacedName: nn}) break } diff --git a/cmd/thv-operator/controllers/mcpoidcconfig_controller_test.go b/cmd/thv-operator/controllers/mcpoidcconfig_controller_test.go index f34549c7ec..34a156fb44 100644 --- a/cmd/thv-operator/controllers/mcpoidcconfig_controller_test.go +++ b/cmd/thv-operator/controllers/mcpoidcconfig_controller_test.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestMCPOIDCConfigReconciler_calculateConfigHash(t *testing.T) { @@ -25,22 +25,22 @@ func TestMCPOIDCConfigReconciler_calculateConfigHash(t *testing.T) { tests := []struct { name string - spec mcpv1alpha1.MCPOIDCConfigSpec + spec mcpv1beta1.MCPOIDCConfigSpec }{ { name: "kubernetesServiceAccount spec", - spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount, - KubernetesServiceAccount: &mcpv1alpha1.KubernetesServiceAccountOIDCConfig{ + spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount, + KubernetesServiceAccount: &mcpv1beta1.KubernetesServiceAccountOIDCConfig{ Issuer: "https://kubernetes.default.svc", }, }, }, { name: "inline spec", - spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -65,16 +65,16 @@ func TestMCPOIDCConfigReconciler_calculateConfigHash(t *testing.T) { t.Run("different specs produce different hashes", func(t *testing.T) { t.Parallel() r := &MCPOIDCConfigReconciler{} - spec1 := mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + spec1 := mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "client1", }, } - spec2 := mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + spec2 := mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "client2", }, @@ -93,7 +93,7 @@ func TestMCPOIDCConfigReconciler_ReconcileNotFound(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // Empty client — no objects exist fakeClient := fake.NewClientBuilder(). @@ -123,18 +123,18 @@ func TestMCPOIDCConfigReconciler_SteadyStateNoOp(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", Generation: 1, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -144,7 +144,7 @@ func TestMCPOIDCConfigReconciler_SteadyStateNoOp(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(oidcConfig). - WithStatusSubresource(&mcpv1alpha1.MCPOIDCConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPOIDCConfig{}). Build() r := &MCPOIDCConfigReconciler{ @@ -168,7 +168,7 @@ func TestMCPOIDCConfigReconciler_SteadyStateNoOp(t *testing.T) { _, err = r.Reconcile(ctx, req) require.NoError(t, err) - var afterInitial mcpv1alpha1.MCPOIDCConfig + var afterInitial mcpv1beta1.MCPOIDCConfig err = fakeClient.Get(ctx, req.NamespacedName, &afterInitial) require.NoError(t, err) initialHash := afterInitial.Status.ConfigHash @@ -179,7 +179,7 @@ func TestMCPOIDCConfigReconciler_SteadyStateNoOp(t *testing.T) { require.NoError(t, err) assert.Equal(t, time.Duration(0), result.RequeueAfter) - var afterSteady mcpv1alpha1.MCPOIDCConfig + var afterSteady mcpv1beta1.MCPOIDCConfig err = fakeClient.Get(ctx, req.NamespacedName, &afterSteady) require.NoError(t, err) assert.Equal(t, initialHash, afterSteady.Status.ConfigHash, "Hash should not change") @@ -192,19 +192,19 @@ func TestMCPOIDCConfigReconciler_ValidationRecovery(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // Start with invalid config: type=inline but no inline config - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "recovery-config", Namespace: "default", Finalizers: []string{OIDCConfigFinalizerName}, Generation: 1, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, // Missing Inline config — invalid }, } @@ -212,7 +212,7 @@ func TestMCPOIDCConfigReconciler_ValidationRecovery(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(oidcConfig). - WithStatusSubresource(&mcpv1alpha1.MCPOIDCConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPOIDCConfig{}). Build() r := &MCPOIDCConfigReconciler{ @@ -231,13 +231,13 @@ func TestMCPOIDCConfigReconciler_ValidationRecovery(t *testing.T) { _, err := r.Reconcile(ctx, req) require.NoError(t, err) - var invalidConfig mcpv1alpha1.MCPOIDCConfig + var invalidConfig mcpv1beta1.MCPOIDCConfig err = fakeClient.Get(ctx, req.NamespacedName, &invalidConfig) require.NoError(t, err) var foundFalse bool for _, cond := range invalidConfig.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeOIDCConfigValid { + if cond.Type == mcpv1beta1.ConditionTypeOIDCConfigValid { assert.Equal(t, metav1.ConditionFalse, cond.Status) foundFalse = true } @@ -246,7 +246,7 @@ func TestMCPOIDCConfigReconciler_ValidationRecovery(t *testing.T) { assert.Empty(t, invalidConfig.Status.ConfigHash, "Hash should not be set for invalid config") // Fix the config by adding the inline spec - invalidConfig.Spec.Inline = &mcpv1alpha1.InlineOIDCSharedConfig{ + invalidConfig.Spec.Inline = &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", } @@ -258,15 +258,15 @@ func TestMCPOIDCConfigReconciler_ValidationRecovery(t *testing.T) { _, err = r.Reconcile(ctx, req) require.NoError(t, err) - var recoveredConfig mcpv1alpha1.MCPOIDCConfig + var recoveredConfig mcpv1beta1.MCPOIDCConfig err = fakeClient.Get(ctx, req.NamespacedName, &recoveredConfig) require.NoError(t, err) var foundTrue bool for _, cond := range recoveredConfig.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeOIDCConfigValid { + if cond.Type == mcpv1beta1.ConditionTypeOIDCConfigValid { assert.Equal(t, metav1.ConditionTrue, cond.Status, "Valid condition should recover to True") - assert.Equal(t, mcpv1alpha1.ConditionReasonOIDCConfigValid, cond.Reason) + assert.Equal(t, mcpv1beta1.ConditionReasonOIDCConfigValid, cond.Reason) foundTrue = true } } @@ -279,12 +279,12 @@ func TestMCPOIDCConfigReconciler_handleDeletion(t *testing.T) { tests := []struct { name string - oidcConfig *mcpv1alpha1.MCPOIDCConfig + oidcConfig *mcpv1beta1.MCPOIDCConfig expectFinalizerRemoved bool }{ { name: "delete config removes finalizer", - oidcConfig: &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig: &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", @@ -293,9 +293,9 @@ func TestMCPOIDCConfigReconciler_handleDeletion(t *testing.T) { Time: time.Now(), }, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", }, }, @@ -311,7 +311,7 @@ func TestMCPOIDCConfigReconciler_handleDeletion(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) objs := []client.Object{tt.oidcConfig} @@ -344,18 +344,18 @@ func TestMCPOIDCConfigReconciler_ConfigChangeTriggersHashUpdate(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", Generation: 1, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -365,7 +365,7 @@ func TestMCPOIDCConfigReconciler_ConfigChangeTriggersHashUpdate(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(oidcConfig). - WithStatusSubresource(&mcpv1alpha1.MCPOIDCConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPOIDCConfig{}). Build() r := &MCPOIDCConfigReconciler{ @@ -391,7 +391,7 @@ func TestMCPOIDCConfigReconciler_ConfigChangeTriggersHashUpdate(t *testing.T) { assert.Equal(t, time.Duration(0), result.RequeueAfter) // Get updated config and check hash was set - var updatedConfig mcpv1alpha1.MCPOIDCConfig + var updatedConfig mcpv1beta1.MCPOIDCConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) assert.NotEmpty(t, updatedConfig.Status.ConfigHash, "Config hash should be set") @@ -408,7 +408,7 @@ func TestMCPOIDCConfigReconciler_ConfigChangeTriggersHashUpdate(t *testing.T) { require.NoError(t, err) // Get final config and verify hash changed - var finalConfig mcpv1alpha1.MCPOIDCConfig + var finalConfig mcpv1beta1.MCPOIDCConfig err = fakeClient.Get(ctx, req.NamespacedName, &finalConfig) require.NoError(t, err) assert.NotEmpty(t, finalConfig.Status.ConfigHash, "Config hash should still be set") @@ -422,19 +422,19 @@ func TestMCPOIDCConfigReconciler_ValidationFailureSetsCondition(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // Invalid config: type is inline but no inline config set - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "invalid-config", Namespace: "default", Finalizers: []string{OIDCConfigFinalizerName}, Generation: 1, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, // Missing Inline config }, } @@ -442,7 +442,7 @@ func TestMCPOIDCConfigReconciler_ValidationFailureSetsCondition(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(oidcConfig). - WithStatusSubresource(&mcpv1alpha1.MCPOIDCConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPOIDCConfig{}). Build() r := &MCPOIDCConfigReconciler{ @@ -462,16 +462,16 @@ func TestMCPOIDCConfigReconciler_ValidationFailureSetsCondition(t *testing.T) { require.NoError(t, err) // Check that the Ready condition is set to False - var updatedConfig mcpv1alpha1.MCPOIDCConfig + var updatedConfig mcpv1beta1.MCPOIDCConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) var foundCondition bool for _, cond := range updatedConfig.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeOIDCConfigValid { + if cond.Type == mcpv1beta1.ConditionTypeOIDCConfigValid { foundCondition = true assert.Equal(t, metav1.ConditionFalse, cond.Status, "Valid condition should be False") - assert.Equal(t, mcpv1alpha1.ConditionReasonOIDCConfigInvalid, cond.Reason) + assert.Equal(t, mcpv1beta1.ConditionReasonOIDCConfigInvalid, cond.Reason) break } } @@ -483,15 +483,15 @@ func TestMCPOIDCConfig_Validate(t *testing.T) { tests := []struct { name string - config *mcpv1alpha1.MCPOIDCConfig + config *mcpv1beta1.MCPOIDCConfig expectError bool }{ { name: "valid kubernetesServiceAccount config", - config: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount, - KubernetesServiceAccount: &mcpv1alpha1.KubernetesServiceAccountOIDCConfig{ + config: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount, + KubernetesServiceAccount: &mcpv1beta1.KubernetesServiceAccountOIDCConfig{ ServiceAccount: "test-sa", Issuer: "https://kubernetes.default.svc", }, @@ -501,10 +501,10 @@ func TestMCPOIDCConfig_Validate(t *testing.T) { }, { name: "valid inline config", - config: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + config: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -514,13 +514,13 @@ func TestMCPOIDCConfig_Validate(t *testing.T) { }, { name: "invalid kubernetesServiceAccount set but type is inline", - config: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - KubernetesServiceAccount: &mcpv1alpha1.KubernetesServiceAccountOIDCConfig{ + config: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + KubernetesServiceAccount: &mcpv1beta1.KubernetesServiceAccountOIDCConfig{ ServiceAccount: "test-sa", }, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", }, }, @@ -529,22 +529,22 @@ func TestMCPOIDCConfig_Validate(t *testing.T) { }, { name: "invalid no config variant set", - config: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + config: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, }, }, expectError: true, }, { name: "invalid multiple config variants set", - config: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount, - KubernetesServiceAccount: &mcpv1alpha1.KubernetesServiceAccountOIDCConfig{ + config: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount, + KubernetesServiceAccount: &mcpv1beta1.KubernetesServiceAccountOIDCConfig{ ServiceAccount: "test-sa", }, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", }, }, diff --git a/cmd/thv-operator/controllers/mcpregistry_controller.go b/cmd/thv-operator/controllers/mcpregistry_controller.go index dc376fcd72..6e75ddf520 100644 --- a/cmd/thv-operator/controllers/mcpregistry_controller.go +++ b/cmd/thv-operator/controllers/mcpregistry_controller.go @@ -21,7 +21,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/registryapi" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/registryapi/config" ) @@ -85,7 +85,7 @@ func (r *MCPRegistryReconciler) Reconcile(ctx context.Context, req ctrl.Request) ctxLogger := log.FromContext(ctx) // 1. Fetch MCPRegistry instance - mcpRegistry := &mcpv1alpha1.MCPRegistry{} + mcpRegistry := &mcpv1beta1.MCPRegistry{} err := r.Get(ctx, req.NamespacedName, mcpRegistry) if err != nil { if kerrors.IsNotFound(err) { @@ -109,7 +109,7 @@ func (r *MCPRegistryReconciler) Reconcile(ctx context.Context, req ctrl.Request) podTemplateCondition = cond if !valid { // Write status immediately for the failure case since we return early - mcpRegistry.Status.Phase = mcpv1alpha1.MCPRegistryPhaseFailed + mcpRegistry.Status.Phase = mcpv1beta1.MCPRegistryPhaseFailed mcpRegistry.Status.Message = fmt.Sprintf("Invalid PodTemplateSpec: %v", cond.Message) meta.SetStatusCondition(&mcpRegistry.Status.Conditions, *cond) if statusErr := r.Status().Update(ctx, mcpRegistry); statusErr != nil { @@ -123,7 +123,7 @@ func (r *MCPRegistryReconciler) Reconcile(ctx context.Context, req ctrl.Request) // Validate spec fields (reserved names, mount paths, pgpassSecretRef) if err := validateSpec(mcpRegistry); err != nil { - mcpRegistry.Status.Phase = mcpv1alpha1.MCPRegistryPhaseFailed + mcpRegistry.Status.Phase = mcpv1beta1.MCPRegistryPhaseFailed mcpRegistry.Status.Message = fmt.Sprintf("Spec validation failed: %v", err) setRegistryReadyCondition(mcpRegistry, metav1.ConditionFalse, "ValidationFailed", err.Error()) @@ -215,7 +215,7 @@ func (r *MCPRegistryReconciler) Reconcile(ctx context.Context, req ctrl.Request) // SetupWithManager sets up the controller with the Manager. func (r *MCPRegistryReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1alpha1.MCPRegistry{}). + For(&mcpv1beta1.MCPRegistry{}). Owns(&appsv1.Deployment{}). Owns(&corev1.Service{}). Owns(&corev1.ConfigMap{}). @@ -229,10 +229,10 @@ func (r *MCPRegistryReconciler) SetupWithManager(mgr ctrl.Manager) error { // and persists it with a single status update. Returns whether the API is ready and any // error from the status update. func (r *MCPRegistryReconciler) updateRegistryStatus( - ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry, reconcileErr error, podTemplateCond *metav1.Condition, + ctx context.Context, mcpRegistry *mcpv1beta1.MCPRegistry, reconcileErr error, podTemplateCond *metav1.Condition, ) (bool, error) { // Refetch the latest version to avoid conflicts - latest := &mcpv1alpha1.MCPRegistry{} + latest := &mcpv1beta1.MCPRegistry{} if err := r.Get(ctx, client.ObjectKeyFromObject(mcpRegistry), latest); err != nil { return false, fmt.Errorf("failed to fetch latest MCPRegistry version: %w", err) } @@ -240,7 +240,7 @@ func (r *MCPRegistryReconciler) updateRegistryStatus( var isReady bool if reconcileErr != nil { - latest.Status.Phase = mcpv1alpha1.MCPRegistryPhaseFailed + latest.Status.Phase = mcpv1beta1.MCPRegistryPhaseFailed latest.Status.ReadyReplicas = 0 // Use structured error fields if available var apiErr *registryapi.Error @@ -250,7 +250,7 @@ func (r *MCPRegistryReconciler) updateRegistryStatus( } else { latest.Status.Message = reconcileErr.Error() setRegistryReadyCondition(latest, metav1.ConditionFalse, - mcpv1alpha1.ConditionReasonRegistryNotReady, reconcileErr.Error()) + mcpv1beta1.ConditionReasonRegistryNotReady, reconcileErr.Error()) } } else { var readyReplicas int32 @@ -260,16 +260,16 @@ func (r *MCPRegistryReconciler) updateRegistryStatus( if isReady { endpoint := fmt.Sprintf("http://%s.%s:8080", mcpRegistry.GetAPIResourceName(), mcpRegistry.Namespace) - latest.Status.Phase = mcpv1alpha1.MCPRegistryPhaseReady + latest.Status.Phase = mcpv1beta1.MCPRegistryPhaseReady latest.Status.Message = "Registry API is ready and serving requests" latest.Status.URL = endpoint setRegistryReadyCondition(latest, metav1.ConditionTrue, - mcpv1alpha1.ConditionReasonRegistryReady, "Registry API is ready and serving requests") + mcpv1beta1.ConditionReasonRegistryReady, "Registry API is ready and serving requests") } else { - latest.Status.Phase = mcpv1alpha1.MCPRegistryPhasePending + latest.Status.Phase = mcpv1beta1.MCPRegistryPhasePending latest.Status.Message = "Registry API deployment is not ready yet" setRegistryReadyCondition(latest, metav1.ConditionFalse, - mcpv1alpha1.ConditionReasonRegistryNotReady, "Registry API deployment is not ready yet") + mcpv1beta1.ConditionReasonRegistryNotReady, "Registry API deployment is not ready yet") } } @@ -286,9 +286,9 @@ func (r *MCPRegistryReconciler) updateRegistryStatus( } // setRegistryReadyCondition sets the top-level Ready condition on an MCPRegistry. -func setRegistryReadyCondition(registry *mcpv1alpha1.MCPRegistry, status metav1.ConditionStatus, reason, message string) { +func setRegistryReadyCondition(registry *mcpv1beta1.MCPRegistry, status metav1.ConditionStatus, reason, message string) { meta.SetStatusCondition(®istry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeReady, + Type: mcpv1beta1.ConditionTypeReady, Status: status, Reason: reason, Message: message, @@ -297,14 +297,14 @@ func setRegistryReadyCondition(registry *mcpv1alpha1.MCPRegistry, status metav1. } // finalizeMCPRegistry performs the finalizer logic for the MCPRegistry -func (r *MCPRegistryReconciler) finalizeMCPRegistry(ctx context.Context, registry *mcpv1alpha1.MCPRegistry) error { +func (r *MCPRegistryReconciler) finalizeMCPRegistry(ctx context.Context, registry *mcpv1beta1.MCPRegistry) error { ctxLogger := log.FromContext(ctx) // Update the MCPRegistry status to indicate termination - immediate update needed since object is being deleted - registry.Status.Phase = mcpv1alpha1.MCPRegistryPhaseTerminating + registry.Status.Phase = mcpv1beta1.MCPRegistryPhaseTerminating registry.Status.Message = "MCPRegistry is being terminated" setRegistryReadyCondition(registry, metav1.ConditionFalse, - mcpv1alpha1.ConditionReasonRegistryNotReady, "MCPRegistry is being terminated") + mcpv1beta1.ConditionReasonRegistryNotReady, "MCPRegistry is being terminated") if err := r.Status().Update(ctx, registry); err != nil { ctxLogger.Error(err, "Failed to update MCPRegistry status during finalization") return err @@ -319,7 +319,7 @@ func (r *MCPRegistryReconciler) finalizeMCPRegistry(ctx context.Context, registr // nil if the spec is valid or a descriptive error if validation fails. CEL // admission rules cover the common cases; this is defense-in-depth inside the // reconciler. -func validateSpec(mcpRegistry *mcpv1alpha1.MCPRegistry) error { +func validateSpec(mcpRegistry *mcpv1beta1.MCPRegistry) error { spec := &mcpRegistry.Spec // Parse user PodTemplateSpec once for subsequent checks @@ -358,7 +358,7 @@ func validatePGPassSecretRef(ref *corev1.SecretKeySelector) error { // validateReservedNames checks that user-provided volumes and init containers do not // collide with operator-reserved names. -func validateReservedNames(spec *mcpv1alpha1.MCPRegistrySpec, userPTS *corev1.PodTemplateSpec) error { +func validateReservedNames(spec *mcpv1beta1.MCPRegistrySpec, userPTS *corev1.PodTemplateSpec) error { reservedVolumeNames := map[string]bool{ registryapi.RegistryServerConfigVolumeName: true, } @@ -400,7 +400,7 @@ func validateReservedNames(spec *mcpv1alpha1.MCPRegistrySpec, userPTS *corev1.Po // validateMountPathCollisions detects duplicate mount paths across operator-generated mounts, // spec.VolumeMounts, and user PodTemplateSpec container mounts. -func validateMountPathCollisions(spec *mcpv1alpha1.MCPRegistrySpec, userPTS *corev1.PodTemplateSpec) error { +func validateMountPathCollisions(spec *mcpv1beta1.MCPRegistrySpec, userPTS *corev1.PodTemplateSpec) error { mountPaths := make(map[string]struct{}) // Operator-generated mounts @@ -440,23 +440,23 @@ func validateMountPathCollisions(spec *mcpv1alpha1.MCPRegistrySpec, userPTS *cor // validatePodTemplate validates the PodTemplateSpec and returns a condition reflecting the result. // Returns true if validation passes, and a condition to apply during the next status update. func (*MCPRegistryReconciler) validatePodTemplate( - mcpRegistry *mcpv1alpha1.MCPRegistry, + mcpRegistry *mcpv1beta1.MCPRegistry, ) (bool, *metav1.Condition) { err := registryapi.ValidatePodTemplateSpec(mcpRegistry.GetPodTemplateSpecRaw()) if err != nil { return false, &metav1.Condition{ - Type: mcpv1alpha1.ConditionPodTemplateValid, + Type: mcpv1beta1.ConditionPodTemplateValid, Status: metav1.ConditionFalse, ObservedGeneration: mcpRegistry.Generation, - Reason: mcpv1alpha1.ConditionReasonPodTemplateInvalid, + Reason: mcpv1beta1.ConditionReasonPodTemplateInvalid, Message: fmt.Sprintf("Failed to parse PodTemplateSpec: %v. Deployment blocked until fixed.", err), } } return true, &metav1.Condition{ - Type: mcpv1alpha1.ConditionPodTemplateValid, + Type: mcpv1beta1.ConditionPodTemplateValid, Status: metav1.ConditionTrue, ObservedGeneration: mcpRegistry.Generation, - Reason: mcpv1alpha1.ConditionReasonPodTemplateValid, + Reason: mcpv1beta1.ConditionReasonPodTemplateValid, Message: "PodTemplateSpec is valid", } } diff --git a/cmd/thv-operator/controllers/mcpregistry_controller_test.go b/cmd/thv-operator/controllers/mcpregistry_controller_test.go index a03159b060..76853fb288 100644 --- a/cmd/thv-operator/controllers/mcpregistry_controller_test.go +++ b/cmd/thv-operator/controllers/mcpregistry_controller_test.go @@ -24,7 +24,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/registryapi" registryapimocks "github.com/stacklok/toolhive/cmd/thv-operator/pkg/registryapi/mocks" ) @@ -46,7 +46,7 @@ func toRawJSONSlice[T any](t *testing.T, items []T) []apiextensionsv1.JSON { func newMCPRegistryTestScheme(t *testing.T) *runtime.Scheme { t.Helper() s := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(s)) + require.NoError(t, mcpv1beta1.AddToScheme(s)) require.NoError(t, corev1.AddToScheme(s)) require.NoError(t, appsv1.AddToScheme(s)) require.NoError(t, rbacv1.AddToScheme(s)) @@ -55,14 +55,14 @@ func newMCPRegistryTestScheme(t *testing.T) *runtime.Scheme { // newMCPRegistryWithFinalizer creates an MCPRegistry with the controller finalizer // and a minimal valid spec (configYAML) so it passes reconciler validation. -func newMCPRegistryWithFinalizer(name, namespace string) *mcpv1alpha1.MCPRegistry { //nolint:unparam - return &mcpv1alpha1.MCPRegistry{ +func newMCPRegistryWithFinalizer(name, namespace string) *mcpv1beta1.MCPRegistry { //nolint:unparam + return &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, Finalizers: []string{"mcpregistry.toolhive.stacklok.dev/finalizer"}, }, - Spec: mcpv1alpha1.MCPRegistrySpec{ + Spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: k8s\n format: upstream\n kubernetes: {}\nregistries:\n - name: default\n sources: [\"k8s\"]\ndatabase:\n host: postgres\n port: 5432\n user: db_app\n database: registry\nauth:\n mode: anonymous\n", }, } @@ -78,7 +78,7 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { tests := []struct { name string - setup func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1alpha1.MCPRegistry) + setup func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1beta1.MCPRegistry) configureMocks func(mock *registryapimocks.MockManager) expResult ctrl.Result expErr error @@ -86,12 +86,12 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { }{ { name: "resource_not_found", - setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1alpha1.MCPRegistry) { + setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1beta1.MCPRegistry) { t.Helper() builder := fake.NewClientBuilder(). WithScheme(s). - WithStatusSubresource(&mcpv1alpha1.MCPRegistry{}) - return builder, &mcpv1alpha1.MCPRegistry{ + WithStatusSubresource(&mcpv1beta1.MCPRegistry{}) + return builder, &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{Name: registryName, Namespace: registryNamespace}, } }, @@ -101,18 +101,18 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { }, { name: "adds_finalizer_on_first_reconcile", - setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1alpha1.MCPRegistry) { + setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1beta1.MCPRegistry) { t.Helper() - mcpRegistry := &mcpv1alpha1.MCPRegistry{ + mcpRegistry := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{Name: registryName, Namespace: registryNamespace}, - Spec: mcpv1alpha1.MCPRegistrySpec{ + Spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: k8s\n kubernetes: {}\n", }, } builder := fake.NewClientBuilder(). WithScheme(s). WithObjects(mcpRegistry). - WithStatusSubresource(&mcpv1alpha1.MCPRegistry{}) + WithStatusSubresource(&mcpv1beta1.MCPRegistry{}) return builder, mcpRegistry }, configureMocks: func(_ *registryapimocks.MockManager) { @@ -122,7 +122,7 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { expErr: nil, assertRegistry: func(t *testing.T, fakeClient client.Client) { t.Helper() - var updated mcpv1alpha1.MCPRegistry + var updated mcpv1beta1.MCPRegistry require.NoError(t, fakeClient.Get(t.Context(), types.NamespacedName{Name: registryName, Namespace: registryNamespace}, &updated)) assert.Contains(t, updated.Finalizers, "mcpregistry.toolhive.stacklok.dev/finalizer") @@ -132,10 +132,10 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { // finalizeMCPRegistry sets Status.Phase=Terminating then the finalizer is removed. // A second dummy finalizer keeps the object alive so we can verify both effects. name: "handles_deletion_with_finalizer_sets_terminating_status", - setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1alpha1.MCPRegistry) { + setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1beta1.MCPRegistry) { t.Helper() now := metav1.NewTime(time.Now()) - mcpRegistry := &mcpv1alpha1.MCPRegistry{ + mcpRegistry := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: registryName, Namespace: registryNamespace, @@ -145,14 +145,14 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { }, DeletionTimestamp: &now, }, - Spec: mcpv1alpha1.MCPRegistrySpec{ + Spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: k8s\n kubernetes: {}\n", }, } builder := fake.NewClientBuilder(). WithScheme(s). WithObjects(mcpRegistry). - WithStatusSubresource(&mcpv1alpha1.MCPRegistry{}) + WithStatusSubresource(&mcpv1beta1.MCPRegistry{}) return builder, mcpRegistry }, configureMocks: func(_ *registryapimocks.MockManager) { @@ -162,35 +162,35 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { expErr: nil, assertRegistry: func(t *testing.T, fakeClient client.Client) { t.Helper() - var updated mcpv1alpha1.MCPRegistry + var updated mcpv1beta1.MCPRegistry require.NoError(t, fakeClient.Get(t.Context(), types.NamespacedName{Name: registryName, Namespace: registryNamespace}, &updated)) - assert.Equal(t, mcpv1alpha1.MCPRegistryPhaseTerminating, updated.Status.Phase) + assert.Equal(t, mcpv1beta1.MCPRegistryPhaseTerminating, updated.Status.Phase) assert.NotContains(t, updated.Finalizers, "mcpregistry.toolhive.stacklok.dev/finalizer") }, }, { name: "handles_deletion_without_controller_finalizer", - setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1alpha1.MCPRegistry) { + setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1beta1.MCPRegistry) { t.Helper() // The fake client requires at least one finalizer for objects with DeletionTimestamp. // Use a non-controller finalizer so the controller skips its finalize path. now := metav1.NewTime(time.Now()) - mcpRegistry := &mcpv1alpha1.MCPRegistry{ + mcpRegistry := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: registryName, Namespace: registryNamespace, Finalizers: []string{"other.finalizer/test"}, DeletionTimestamp: &now, }, - Spec: mcpv1alpha1.MCPRegistrySpec{ + Spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: k8s\n kubernetes: {}\n", }, } builder := fake.NewClientBuilder(). WithScheme(s). WithObjects(mcpRegistry). - WithStatusSubresource(&mcpv1alpha1.MCPRegistry{}) + WithStatusSubresource(&mcpv1beta1.MCPRegistry{}) return builder, mcpRegistry }, configureMocks: func(_ *registryapimocks.MockManager) {}, @@ -201,7 +201,7 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { // validateAndUpdatePodTemplateStatus returns false → Reconcile returns early without error, // and the PodTemplateValid condition is set to False with phase Failed. name: "invalid_podtemplatespec_blocks_reconcile", - setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1alpha1.MCPRegistry) { + setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1beta1.MCPRegistry) { t.Helper() mcpRegistry := newMCPRegistryWithFinalizer(registryName, registryNamespace) mcpRegistry.Spec.PodTemplateSpec = &runtime.RawExtension{ @@ -210,7 +210,7 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { builder := fake.NewClientBuilder(). WithScheme(s). WithObjects(mcpRegistry). - WithStatusSubresource(&mcpv1alpha1.MCPRegistry{}) + WithStatusSubresource(&mcpv1beta1.MCPRegistry{}) return builder, mcpRegistry }, configureMocks: func(_ *registryapimocks.MockManager) { @@ -220,20 +220,20 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { expErr: nil, assertRegistry: func(t *testing.T, fakeClient client.Client) { t.Helper() - var updated mcpv1alpha1.MCPRegistry + var updated mcpv1beta1.MCPRegistry require.NoError(t, fakeClient.Get(t.Context(), types.NamespacedName{Name: registryName, Namespace: registryNamespace}, &updated)) - cond := k8smeta.FindStatusCondition(updated.Status.Conditions, mcpv1alpha1.ConditionPodTemplateValid) + cond := k8smeta.FindStatusCondition(updated.Status.Conditions, mcpv1beta1.ConditionPodTemplateValid) require.NotNil(t, cond, "PodTemplateValid condition must be set") assert.Equal(t, metav1.ConditionFalse, cond.Status) - assert.Equal(t, mcpv1alpha1.MCPRegistryPhaseFailed, updated.Status.Phase) + assert.Equal(t, mcpv1beta1.MCPRegistryPhaseFailed, updated.Status.Phase) }, }, { // validateAndUpdatePodTemplateStatus returns true → reconcile proceeds, setting the // PodTemplateValid condition to True and continuing to the API reconcile path. name: "valid_podtemplatespec_proceeds_to_api_reconcile", - setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1alpha1.MCPRegistry) { + setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1beta1.MCPRegistry) { t.Helper() mcpRegistry := newMCPRegistryWithFinalizer(registryName, registryNamespace) mcpRegistry.Spec.PodTemplateSpec = &runtime.RawExtension{ @@ -242,7 +242,7 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { builder := fake.NewClientBuilder(). WithScheme(s). WithObjects(mcpRegistry). - WithStatusSubresource(&mcpv1alpha1.MCPRegistry{}) + WithStatusSubresource(&mcpv1beta1.MCPRegistry{}) return builder, mcpRegistry }, configureMocks: func(mock *registryapimocks.MockManager) { @@ -253,23 +253,23 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { expErr: nil, assertRegistry: func(t *testing.T, fakeClient client.Client) { t.Helper() - var updated mcpv1alpha1.MCPRegistry + var updated mcpv1beta1.MCPRegistry require.NoError(t, fakeClient.Get(t.Context(), types.NamespacedName{Name: registryName, Namespace: registryNamespace}, &updated)) - cond := k8smeta.FindStatusCondition(updated.Status.Conditions, mcpv1alpha1.ConditionPodTemplateValid) + cond := k8smeta.FindStatusCondition(updated.Status.Conditions, mcpv1beta1.ConditionPodTemplateValid) require.NotNil(t, cond, "PodTemplateValid condition must be set") assert.Equal(t, metav1.ConditionTrue, cond.Status) }, }, { name: "api_reconcile_error", - setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1alpha1.MCPRegistry) { + setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1beta1.MCPRegistry) { t.Helper() mcpRegistry := newMCPRegistryWithFinalizer(registryName, registryNamespace) builder := fake.NewClientBuilder(). WithScheme(s). WithObjects(mcpRegistry). - WithStatusSubresource(&mcpv1alpha1.MCPRegistry{}) + WithStatusSubresource(&mcpv1beta1.MCPRegistry{}) return builder, mcpRegistry }, configureMocks: func(mock *registryapimocks.MockManager) { @@ -285,13 +285,13 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { // updateRegistryStatus sets Phase=Pending when API is not ready. // Reconcile also schedules a requeue because IsAPIReady returns false. name: "api_reconcile_success_api_not_ready", - setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1alpha1.MCPRegistry) { + setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1beta1.MCPRegistry) { t.Helper() mcpRegistry := newMCPRegistryWithFinalizer(registryName, registryNamespace) builder := fake.NewClientBuilder(). WithScheme(s). WithObjects(mcpRegistry). - WithStatusSubresource(&mcpv1alpha1.MCPRegistry{}) + WithStatusSubresource(&mcpv1beta1.MCPRegistry{}) return builder, mcpRegistry }, configureMocks: func(mock *registryapimocks.MockManager) { @@ -302,10 +302,10 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { expErr: nil, assertRegistry: func(t *testing.T, fakeClient client.Client) { t.Helper() - var updated mcpv1alpha1.MCPRegistry + var updated mcpv1beta1.MCPRegistry require.NoError(t, fakeClient.Get(t.Context(), types.NamespacedName{Name: registryName, Namespace: registryNamespace}, &updated)) - assert.Equal(t, mcpv1alpha1.MCPRegistryPhasePending, updated.Status.Phase) + assert.Equal(t, mcpv1beta1.MCPRegistryPhasePending, updated.Status.Phase) assert.Equal(t, int32(0), updated.Status.ReadyReplicas) }, }, @@ -313,13 +313,13 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { // updateRegistryStatus sets Phase=Running when API is ready. // No requeue because IsAPIReady returns true. name: "api_reconcile_success_api_ready", - setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1alpha1.MCPRegistry) { + setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1beta1.MCPRegistry) { t.Helper() mcpRegistry := newMCPRegistryWithFinalizer(registryName, registryNamespace) builder := fake.NewClientBuilder(). WithScheme(s). WithObjects(mcpRegistry). - WithStatusSubresource(&mcpv1alpha1.MCPRegistry{}) + WithStatusSubresource(&mcpv1beta1.MCPRegistry{}) return builder, mcpRegistry }, configureMocks: func(mock *registryapimocks.MockManager) { @@ -330,10 +330,10 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { expErr: nil, assertRegistry: func(t *testing.T, fakeClient client.Client) { t.Helper() - var updated mcpv1alpha1.MCPRegistry + var updated mcpv1beta1.MCPRegistry require.NoError(t, fakeClient.Get(t.Context(), types.NamespacedName{Name: registryName, Namespace: registryNamespace}, &updated)) - assert.Equal(t, mcpv1alpha1.MCPRegistryPhaseReady, updated.Status.Phase) + assert.Equal(t, mcpv1beta1.MCPRegistryPhaseReady, updated.Status.Phase) assert.Equal(t, int32(1), updated.Status.ReadyReplicas) }, }, @@ -341,13 +341,13 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { // When ReconcileAPIService fails, updateRegistryStatus sets Phase=Failed // and the Ready condition to False with the structured error reason. name: "api_reconcile_error_updates_failed_status", - setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1alpha1.MCPRegistry) { + setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1beta1.MCPRegistry) { t.Helper() mcpRegistry := newMCPRegistryWithFinalizer(registryName, registryNamespace) builder := fake.NewClientBuilder(). WithScheme(s). WithObjects(mcpRegistry). - WithStatusSubresource(&mcpv1alpha1.MCPRegistry{}) + WithStatusSubresource(&mcpv1beta1.MCPRegistry{}) return builder, mcpRegistry }, configureMocks: func(mock *registryapimocks.MockManager) { @@ -360,12 +360,12 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { expErr: ®istryapi.Error{Message: "deploy failed", ConditionReason: "DeployFailed"}, assertRegistry: func(t *testing.T, fakeClient client.Client) { t.Helper() - var updated mcpv1alpha1.MCPRegistry + var updated mcpv1beta1.MCPRegistry require.NoError(t, fakeClient.Get(t.Context(), types.NamespacedName{Name: registryName, Namespace: registryNamespace}, &updated)) - assert.Equal(t, mcpv1alpha1.MCPRegistryPhaseFailed, updated.Status.Phase) + assert.Equal(t, mcpv1beta1.MCPRegistryPhaseFailed, updated.Status.Phase) assert.Equal(t, "deploy failed", updated.Status.Message) - cond := k8smeta.FindStatusCondition(updated.Status.Conditions, mcpv1alpha1.ConditionTypeReady) + cond := k8smeta.FindStatusCondition(updated.Status.Conditions, mcpv1beta1.ConditionTypeReady) require.NotNil(t, cond, "Ready condition must be set") assert.Equal(t, metav1.ConditionFalse, cond.Status) assert.Equal(t, "DeployFailed", cond.Reason) @@ -375,13 +375,13 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { // When the API is ready, the URL should follow the in-cluster format // and the Ready condition should be True. name: "api_reconcile_success_api_ready_checks_endpoint_and_condition", - setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1alpha1.MCPRegistry) { + setup: func(t *testing.T, s *runtime.Scheme) (*fake.ClientBuilder, *mcpv1beta1.MCPRegistry) { t.Helper() mcpRegistry := newMCPRegistryWithFinalizer(registryName, registryNamespace) builder := fake.NewClientBuilder(). WithScheme(s). WithObjects(mcpRegistry). - WithStatusSubresource(&mcpv1alpha1.MCPRegistry{}) + WithStatusSubresource(&mcpv1beta1.MCPRegistry{}) return builder, mcpRegistry }, configureMocks: func(mock *registryapimocks.MockManager) { @@ -392,12 +392,12 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { expErr: nil, assertRegistry: func(t *testing.T, fakeClient client.Client) { t.Helper() - var updated mcpv1alpha1.MCPRegistry + var updated mcpv1beta1.MCPRegistry require.NoError(t, fakeClient.Get(t.Context(), types.NamespacedName{Name: registryName, Namespace: registryNamespace}, &updated)) assert.Equal(t, "http://test-registry-api.default:8080", updated.Status.URL) assert.Equal(t, int32(2), updated.Status.ReadyReplicas) - cond := k8smeta.FindStatusCondition(updated.Status.Conditions, mcpv1alpha1.ConditionTypeReady) + cond := k8smeta.FindStatusCondition(updated.Status.Conditions, mcpv1beta1.ConditionTypeReady) require.NotNil(t, cond, "Ready condition must be set") assert.Equal(t, metav1.ConditionTrue, cond.Status) }, @@ -450,18 +450,18 @@ func TestValidateSpec(t *testing.T) { tests := []struct { name string - spec mcpv1alpha1.MCPRegistrySpec + spec mcpv1beta1.MCPRegistrySpec wantErr string }{ { name: "valid configYAML with no extra fields", - spec: mcpv1alpha1.MCPRegistrySpec{ + spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n", }, }, { name: "pgpassSecretRef with empty name", - spec: mcpv1alpha1.MCPRegistrySpec{ + spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n", PGPassSecretRef: &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: ""}, @@ -472,7 +472,7 @@ func TestValidateSpec(t *testing.T) { }, { name: "pgpassSecretRef with empty key", - spec: mcpv1alpha1.MCPRegistrySpec{ + spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n", PGPassSecretRef: &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "my-pgpass"}, @@ -483,7 +483,7 @@ func TestValidateSpec(t *testing.T) { }, { name: "reserved volume name registry-server-config in spec volumes", - spec: mcpv1alpha1.MCPRegistrySpec{ + spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n", Volumes: toRawJSONSlice(t, []corev1.Volume{ {Name: registryapi.RegistryServerConfigVolumeName, VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, @@ -493,7 +493,7 @@ func TestValidateSpec(t *testing.T) { }, { name: "reserved volume name pgpass-secret when pgpassSecretRef is set", - spec: mcpv1alpha1.MCPRegistrySpec{ + spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n", PGPassSecretRef: &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "my-pgpass"}, @@ -507,7 +507,7 @@ func TestValidateSpec(t *testing.T) { }, { name: "non-reserved volume name passes", - spec: mcpv1alpha1.MCPRegistrySpec{ + spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n", Volumes: toRawJSONSlice(t, []corev1.Volume{ {Name: "my-custom-volume", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, @@ -516,7 +516,7 @@ func TestValidateSpec(t *testing.T) { }, { name: "reserved volume name pgpass-secret when pgpassSecretRef is NOT set passes", - spec: mcpv1alpha1.MCPRegistrySpec{ + spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n", Volumes: toRawJSONSlice(t, []corev1.Volume{ {Name: registryapi.PGPassSecretVolumeName, VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, @@ -526,7 +526,7 @@ func TestValidateSpec(t *testing.T) { }, { name: "reserved volume name registry-server-config in PodTemplateSpec", - spec: func() mcpv1alpha1.MCPRegistrySpec { + spec: func() mcpv1beta1.MCPRegistrySpec { pts := corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ Volumes: []corev1.Volume{ @@ -543,7 +543,7 @@ func TestValidateSpec(t *testing.T) { }, } raw, _ := json.Marshal(pts) - return mcpv1alpha1.MCPRegistrySpec{ + return mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n", PodTemplateSpec: &runtime.RawExtension{Raw: raw}, } @@ -552,7 +552,7 @@ func TestValidateSpec(t *testing.T) { }, { name: "init container setup-pgpass in PodTemplateSpec when pgpassSecretRef is set", - spec: func() mcpv1alpha1.MCPRegistrySpec { + spec: func() mcpv1beta1.MCPRegistrySpec { pts := corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ InitContainers: []corev1.Container{ @@ -564,7 +564,7 @@ func TestValidateSpec(t *testing.T) { }, } raw, _ := json.Marshal(pts) - return mcpv1alpha1.MCPRegistrySpec{ + return mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n", PGPassSecretRef: &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "my-pgpass"}, @@ -577,7 +577,7 @@ func TestValidateSpec(t *testing.T) { }, { name: "mount path collision from PodTemplateSpec container mounts", - spec: func() mcpv1alpha1.MCPRegistrySpec { + spec: func() mcpv1beta1.MCPRegistrySpec { pts := corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ Containers: []corev1.Container{ @@ -591,7 +591,7 @@ func TestValidateSpec(t *testing.T) { }, } raw, _ := json.Marshal(pts) - return mcpv1alpha1.MCPRegistrySpec{ + return mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n", PodTemplateSpec: &runtime.RawExtension{Raw: raw}, } @@ -600,7 +600,7 @@ func TestValidateSpec(t *testing.T) { }, { name: "duplicate mount path in spec volumeMounts", - spec: mcpv1alpha1.MCPRegistrySpec{ + spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n", VolumeMounts: toRawJSONSlice(t, []corev1.VolumeMount{ {Name: "vol-a", MountPath: "/data/files"}, @@ -611,7 +611,7 @@ func TestValidateSpec(t *testing.T) { }, { name: "mount path collision with operator-reserved config path", - spec: mcpv1alpha1.MCPRegistrySpec{ + spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n", VolumeMounts: toRawJSONSlice(t, []corev1.VolumeMount{ {Name: "my-vol", MountPath: "/config"}, @@ -625,7 +625,7 @@ func TestValidateSpec(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - mcpRegistry := &mcpv1alpha1.MCPRegistry{ + mcpRegistry := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-registry", Namespace: "default", diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_authserverref_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_authserverref_test.go index eccf8caf1c..9b8acdc460 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_authserverref_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_authserverref_test.go @@ -16,7 +16,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestMCPRemoteProxyReconciler_handleAuthServerRef(t *testing.T) { @@ -24,8 +24,8 @@ func TestMCPRemoteProxyReconciler_handleAuthServerRef(t *testing.T) { tests := []struct { name string - proxy func() *mcpv1alpha1.MCPRemoteProxy - authConfig func() *mcpv1alpha1.MCPExternalAuthConfig + proxy func() *mcpv1beta1.MCPRemoteProxy + authConfig func() *mcpv1beta1.MCPExternalAuthConfig expectError bool errContains string expectHash string @@ -34,11 +34,11 @@ func TestMCPRemoteProxyReconciler_handleAuthServerRef(t *testing.T) { }{ { name: "nil authServerRef removes condition and clears hash", - proxy: func() *mcpv1alpha1.MCPRemoteProxy { - return &mcpv1alpha1.MCPRemoteProxy{ + proxy: func() *mcpv1beta1.MCPRemoteProxy { + return &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{RemoteURL: "https://remote.example.com"}, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ + Spec: mcpv1beta1.MCPRemoteProxySpec{RemoteURL: "https://remote.example.com"}, + Status: mcpv1beta1.MCPRemoteProxyStatus{ AuthServerConfigHash: "old-hash", }, } @@ -47,53 +47,53 @@ func TestMCPRemoteProxyReconciler_handleAuthServerRef(t *testing.T) { }, { name: "unsupported kind sets InvalidKind condition", - proxy: func() *mcpv1alpha1.MCPRemoteProxy { - return &mcpv1alpha1.MCPRemoteProxy{ + proxy: func() *mcpv1beta1.MCPRemoteProxy { + return &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote.example.com", - AuthServerRef: &mcpv1alpha1.AuthServerRef{Kind: "Secret", Name: "foo"}, + AuthServerRef: &mcpv1beta1.AuthServerRef{Kind: "Secret", Name: "foo"}, }, } }, expectError: true, errContains: "unsupported authServerRef kind", conditionStatus: metav1.ConditionFalse, - conditionReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyAuthServerRefInvalidKind, + conditionReason: mcpv1beta1.ConditionReasonMCPRemoteProxyAuthServerRefInvalidKind, }, { name: "not found sets NotFound condition", - proxy: func() *mcpv1alpha1.MCPRemoteProxy { - return &mcpv1alpha1.MCPRemoteProxy{ + proxy: func() *mcpv1beta1.MCPRemoteProxy { + return &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote.example.com", - AuthServerRef: &mcpv1alpha1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "missing"}, + AuthServerRef: &mcpv1beta1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "missing"}, }, } }, expectError: true, errContains: "not found", conditionStatus: metav1.ConditionFalse, - conditionReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyAuthServerRefNotFound, + conditionReason: mcpv1beta1.ConditionReasonMCPRemoteProxyAuthServerRefNotFound, }, { name: "wrong type sets InvalidType condition", - proxy: func() *mcpv1alpha1.MCPRemoteProxy { - return &mcpv1alpha1.MCPRemoteProxy{ + proxy: func() *mcpv1beta1.MCPRemoteProxy { + return &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote.example.com", - AuthServerRef: &mcpv1alpha1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "sts-config"}, + AuthServerRef: &mcpv1beta1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "sts-config"}, }, } }, - authConfig: func() *mcpv1alpha1.MCPExternalAuthConfig { - return &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: func() *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "sts-config", Namespace: "default"}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeAWSSts, - AWSSts: &mcpv1alpha1.AWSStsConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeAWSSts, + AWSSts: &mcpv1beta1.AWSStsConfig{ Region: "us-east-1", }, }, @@ -102,69 +102,69 @@ func TestMCPRemoteProxyReconciler_handleAuthServerRef(t *testing.T) { expectError: true, errContains: "only embeddedAuthServer is supported", conditionStatus: metav1.ConditionFalse, - conditionReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyAuthServerRefInvalidType, + conditionReason: mcpv1beta1.ConditionReasonMCPRemoteProxyAuthServerRefInvalidType, }, { name: "multi-upstream sets MultiUpstream condition", - proxy: func() *mcpv1alpha1.MCPRemoteProxy { - return &mcpv1alpha1.MCPRemoteProxy{ + proxy: func() *mcpv1beta1.MCPRemoteProxy { + return &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote.example.com", - AuthServerRef: &mcpv1alpha1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "multi"}, + AuthServerRef: &mcpv1beta1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "multi"}, }, } }, - authConfig: func() *mcpv1alpha1.MCPExternalAuthConfig { - return &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: func() *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "multi", Namespace: "default"}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ - {Name: "a", Type: mcpv1alpha1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{IssuerURL: "https://a.com", ClientID: "a"}}, - {Name: "b", Type: mcpv1alpha1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{IssuerURL: "https://b.com", ClientID: "b"}}, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + {Name: "a", Type: mcpv1beta1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{IssuerURL: "https://a.com", ClientID: "a"}}, + {Name: "b", Type: mcpv1beta1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{IssuerURL: "https://b.com", ClientID: "b"}}, }, }, }, - Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ConfigHash: "multi-hash"}, + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ConfigHash: "multi-hash"}, } }, expectError: true, errContains: "only 1 is supported", conditionStatus: metav1.ConditionFalse, - conditionReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyAuthServerRefMultiUpstream, + conditionReason: mcpv1beta1.ConditionReasonMCPRemoteProxyAuthServerRefMultiUpstream, }, { name: "valid ref sets Valid condition and updates hash", - proxy: func() *mcpv1alpha1.MCPRemoteProxy { - return &mcpv1alpha1.MCPRemoteProxy{ + proxy: func() *mcpv1beta1.MCPRemoteProxy { + return &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote.example.com", - AuthServerRef: &mcpv1alpha1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "valid"}, + AuthServerRef: &mcpv1beta1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "valid"}, }, } }, - authConfig: func() *mcpv1alpha1.MCPExternalAuthConfig { - return &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: func() *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "valid", Namespace: "default"}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", AuthorizationEndpointBaseURL: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{{Name: "key", Key: "pem"}}, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{{Name: "hmac", Key: "secret"}}, + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{{Name: "key", Key: "pem"}}, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{{Name: "hmac", Key: "secret"}}, }, }, - Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ConfigHash: "valid-hash"}, + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ConfigHash: "valid-hash"}, } }, expectHash: "valid-hash", conditionStatus: metav1.ConditionTrue, - conditionReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyAuthServerRefValid, + conditionReason: mcpv1beta1.ConditionReasonMCPRemoteProxyAuthServerRefValid, }, } @@ -175,7 +175,7 @@ func TestMCPRemoteProxyReconciler_handleAuthServerRef(t *testing.T) { defer cancel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) proxy := tt.proxy() @@ -187,7 +187,7 @@ func TestMCPRemoteProxyReconciler_handleAuthServerRef(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPRemoteProxy{}). + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}). Build() reconciler := &MCPRemoteProxyReconciler{ @@ -204,7 +204,7 @@ func TestMCPRemoteProxyReconciler_handleAuthServerRef(t *testing.T) { assert.Equal(t, tt.expectHash, proxy.Status.AuthServerConfigHash) } - cond := meta.FindStatusCondition(proxy.Status.Conditions, mcpv1alpha1.ConditionTypeMCPRemoteProxyAuthServerRefValidated) + cond := meta.FindStatusCondition(proxy.Status.Conditions, mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated) if tt.conditionStatus != "" { require.NotNil(t, cond, "AuthServerRefValidated condition should be present") assert.Equal(t, tt.conditionStatus, cond.Status) diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go index 84b503678f..40954eea8c 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go @@ -27,7 +27,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/kubernetes/rbac" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" @@ -64,7 +64,7 @@ func (r *MCPRemoteProxyReconciler) Reconcile(ctx context.Context, req ctrl.Reque ctxLogger := log.FromContext(ctx) // Fetch the MCPRemoteProxy instance - proxy := &mcpv1alpha1.MCPRemoteProxy{} + proxy := &mcpv1beta1.MCPRemoteProxy{} err := r.Get(ctx, req.NamespacedName, proxy) if err != nil { if errors.IsNotFound(err) { @@ -95,18 +95,18 @@ func (r *MCPRemoteProxyReconciler) Reconcile(ctx context.Context, req ctrl.Reque } // validateAndHandleConfigs validates spec and handles referenced configurations -func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error { +func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { ctxLogger := log.FromContext(ctx) // Validate the spec if err := r.validateSpec(ctx, proxy); err != nil { ctxLogger.Error(err, "MCPRemoteProxy spec validation failed") - proxy.Status.Phase = mcpv1alpha1.MCPRemoteProxyPhaseFailed + proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed proxy.Status.Message = fmt.Sprintf("Validation failed: %v", err) meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeAuthConfigured, + Type: mcpv1beta1.ConditionTypeAuthConfigured, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonAuthInvalid, + Reason: mcpv1beta1.ConditionReasonAuthInvalid, Message: err.Error(), }) if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { @@ -121,7 +121,7 @@ func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context, // Handle MCPToolConfig if err := r.handleToolConfig(ctx, proxy); err != nil { ctxLogger.Error(err, "Failed to handle MCPToolConfig") - proxy.Status.Phase = mcpv1alpha1.MCPRemoteProxyPhaseFailed + proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status after MCPToolConfig error") } @@ -131,7 +131,7 @@ func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context, // Handle MCPTelemetryConfig if err := r.handleTelemetryConfig(ctx, proxy); err != nil { ctxLogger.Error(err, "Failed to handle MCPTelemetryConfig") - proxy.Status.Phase = mcpv1alpha1.MCPRemoteProxyPhaseFailed + proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status after MCPTelemetryConfig error") } @@ -141,7 +141,7 @@ func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context, // Handle MCPExternalAuthConfig if err := r.handleExternalAuthConfig(ctx, proxy); err != nil { ctxLogger.Error(err, "Failed to handle MCPExternalAuthConfig") - proxy.Status.Phase = mcpv1alpha1.MCPRemoteProxyPhaseFailed + proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status after MCPExternalAuthConfig error") } @@ -151,7 +151,7 @@ func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context, // Handle authServerRef config hash tracking if err := r.handleAuthServerRef(ctx, proxy); err != nil { ctxLogger.Error(err, "Failed to handle authServerRef") - proxy.Status.Phase = mcpv1alpha1.MCPRemoteProxyPhaseFailed + proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status after authServerRef error") } @@ -161,7 +161,7 @@ func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context, // Handle MCPOIDCConfig if err := r.handleOIDCConfig(ctx, proxy); err != nil { ctxLogger.Error(err, "Failed to handle MCPOIDCConfig") - proxy.Status.Phase = mcpv1alpha1.MCPRemoteProxyPhaseFailed + proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status after MCPOIDCConfig error") } @@ -172,7 +172,7 @@ func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context, } // ensureAllResources ensures all Kubernetes resources for the proxy -func (r *MCPRemoteProxyReconciler) ensureAllResources(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error { +func (r *MCPRemoteProxyReconciler) ensureAllResources(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { ctxLogger := log.FromContext(ctx) // Ensure RBAC resources @@ -212,7 +212,7 @@ func (r *MCPRemoteProxyReconciler) ensureAllResources(ctx context.Context, proxy } // ensureAuthzConfigMapForProxy ensures the authorization ConfigMap for inline configuration -func (r *MCPRemoteProxyReconciler) ensureAuthzConfigMapForProxy(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error { +func (r *MCPRemoteProxyReconciler) ensureAuthzConfigMapForProxy(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { authzLabels := labelsForMCPRemoteProxy(proxy.Name) authzLabels[authzLabelKey] = authzLabelValueInline return ctrlutil.EnsureAuthzConfigMap( @@ -223,7 +223,7 @@ func (r *MCPRemoteProxyReconciler) ensureAuthzConfigMapForProxy(ctx context.Cont // getRunConfigChecksum fetches the RunConfig ConfigMap checksum annotation for this proxy. // Uses the shared RunConfigChecksumFetcher to maintain consistency with MCPServer. func (r *MCPRemoteProxyReconciler) getRunConfigChecksum( - ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy, + ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy, ) (string, error) { if proxy == nil { return "", fmt.Errorf("proxy cannot be nil") @@ -245,7 +245,7 @@ func (r *MCPRemoteProxyReconciler) getRunConfigChecksum( // the function returns an error that will trigger reconciliation requeue, allowing the // ConfigMap to be created first in ensureAllResources(). func (r *MCPRemoteProxyReconciler) ensureDeployment( - ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy, + ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy, ) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) @@ -310,7 +310,7 @@ func (r *MCPRemoteProxyReconciler) ensureDeployment( // ensureService ensures the Service exists and is up to date func (r *MCPRemoteProxyReconciler) ensureService( - ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy, + ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy, ) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) @@ -357,7 +357,7 @@ func (r *MCPRemoteProxyReconciler) ensureService( } // ensureServiceURL ensures the service URL is set in the status -func (r *MCPRemoteProxyReconciler) ensureServiceURL(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error { +func (r *MCPRemoteProxyReconciler) ensureServiceURL(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { if proxy.Status.URL == "" { // Note: createProxyServiceURL uses the remote-prefixed service name proxy.Status.URL = createProxyServiceURL(proxy.Name, proxy.Namespace, int32(proxy.GetProxyPort())) @@ -367,19 +367,19 @@ func (r *MCPRemoteProxyReconciler) ensureServiceURL(ctx context.Context, proxy * } // validateSpec validates the MCPRemoteProxy spec -func (r *MCPRemoteProxyReconciler) validateSpec(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error { +func (r *MCPRemoteProxyReconciler) validateSpec(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { // Validate external auth config if referenced if proxy.Spec.ExternalAuthConfigRef != nil { externalAuthConfig, err := ctrlutil.GetExternalAuthConfigForMCPRemoteProxy(ctx, r.Client, proxy) if err != nil { return r.failValidation(proxy, - mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigFetchError, + mcpv1beta1.ConditionReasonMCPRemoteProxyExternalAuthConfigFetchError, fmt.Errorf("failed to validate external auth config: %w", err), ) } if externalAuthConfig == nil { return r.failValidation(proxy, - mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigNotFound, + mcpv1beta1.ConditionReasonMCPRemoteProxyExternalAuthConfigNotFound, fmt.Errorf("referenced MCPExternalAuthConfig %s not found", proxy.Spec.ExternalAuthConfigRef.Name), ) } @@ -387,12 +387,12 @@ func (r *MCPRemoteProxyReconciler) validateSpec(ctx context.Context, proxy *mcpv // Validate remote URL format (also rejects empty URLs) if err := validation.ValidateRemoteURL(proxy.Spec.RemoteURL); err != nil { - return r.failValidation(proxy, mcpv1alpha1.ConditionReasonRemoteURLInvalid, err) + return r.failValidation(proxy, mcpv1beta1.ConditionReasonRemoteURLInvalid, err) } // Validate inline Cedar policy syntax if err := r.validateAuthzPolicySyntax(proxy); err != nil { - return r.failValidation(proxy, mcpv1alpha1.ConditionReasonAuthzPolicySyntaxInvalid, err) + return r.failValidation(proxy, mcpv1beta1.ConditionReasonAuthzPolicySyntaxInvalid, err) } // Validate Kubernetes resource references (ConfigMaps, Secrets) @@ -402,9 +402,9 @@ func (r *MCPRemoteProxyReconciler) validateSpec(ctx context.Context, proxy *mcpv // All validations passed meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeConfigurationValid, + Type: mcpv1beta1.ConditionTypeConfigurationValid, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonConfigurationValid, + Reason: mcpv1beta1.ConditionReasonConfigurationValid, Message: "All configuration validations passed", ObservedGeneration: proxy.Generation, }) @@ -414,23 +414,23 @@ func (r *MCPRemoteProxyReconciler) validateSpec(ctx context.Context, proxy *mcpv // failValidation records a validation event, sets the ConfigurationValid condition to False, // and returns the error. This consolidates the repeated validate → event → condition → return pattern. -func (r *MCPRemoteProxyReconciler) failValidation(proxy *mcpv1alpha1.MCPRemoteProxy, reason string, err error) error { +func (r *MCPRemoteProxyReconciler) failValidation(proxy *mcpv1beta1.MCPRemoteProxy, reason string, err error) error { r.recordValidationEvent(proxy, reason, err.Error()) setConfigurationInvalidCondition(proxy, reason, err.Error()) return err } // recordValidationEvent emits a Warning event for a validation failure. -func (r *MCPRemoteProxyReconciler) recordValidationEvent(proxy *mcpv1alpha1.MCPRemoteProxy, reason, message string) { +func (r *MCPRemoteProxyReconciler) recordValidationEvent(proxy *mcpv1beta1.MCPRemoteProxy, reason, message string) { if r.Recorder != nil { r.Recorder.Eventf(proxy, nil, corev1.EventTypeWarning, reason, "ValidateSpec", message) } } // setConfigurationInvalidCondition sets the ConfigurationValid condition to False. -func setConfigurationInvalidCondition(proxy *mcpv1alpha1.MCPRemoteProxy, reason, message string) { +func setConfigurationInvalidCondition(proxy *mcpv1beta1.MCPRemoteProxy, reason, message string) { meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeConfigurationValid, + Type: mcpv1beta1.ConditionTypeConfigurationValid, Status: metav1.ConditionFalse, Reason: reason, Message: message, @@ -440,10 +440,10 @@ func setConfigurationInvalidCondition(proxy *mcpv1alpha1.MCPRemoteProxy, reason, // validateAuthzPolicySyntax validates inline Cedar authorization policy syntax. func (*MCPRemoteProxyReconciler) validateAuthzPolicySyntax( - proxy *mcpv1alpha1.MCPRemoteProxy, + proxy *mcpv1beta1.MCPRemoteProxy, ) error { if proxy.Spec.AuthzConfig == nil || - proxy.Spec.AuthzConfig.Type != mcpv1alpha1.AuthzConfigTypeInline || + proxy.Spec.AuthzConfig.Type != mcpv1beta1.AuthzConfigTypeInline || proxy.Spec.AuthzConfig.Inline == nil { return nil } @@ -452,11 +452,11 @@ func (*MCPRemoteProxyReconciler) validateAuthzPolicySyntax( // validateK8sRefs validates that referenced ConfigMaps and Secrets exist. func (r *MCPRemoteProxyReconciler) validateK8sRefs( - ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy, + ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy, ) error { // Check authz ConfigMap reference if proxy.Spec.AuthzConfig != nil && - proxy.Spec.AuthzConfig.Type == mcpv1alpha1.AuthzConfigTypeConfigMap && + proxy.Spec.AuthzConfig.Type == mcpv1beta1.AuthzConfigTypeConfigMap && proxy.Spec.AuthzConfig.ConfigMap != nil { cm := &corev1.ConfigMap{} cmName := proxy.Spec.AuthzConfig.ConfigMap.Name @@ -471,12 +471,12 @@ func (r *MCPRemoteProxyReconciler) validateK8sRefs( ) r.recordValidationEvent( proxy, - mcpv1alpha1.ConditionReasonAuthzConfigMapNotFound, + mcpv1beta1.ConditionReasonAuthzConfigMapNotFound, msg, ) setConfigurationInvalidCondition( proxy, - mcpv1alpha1.ConditionReasonAuthzConfigMapNotFound, + mcpv1beta1.ConditionReasonAuthzConfigMapNotFound, msg, ) return stderrors.New(msg) @@ -484,8 +484,8 @@ func (r *MCPRemoteProxyReconciler) validateK8sRefs( ctxLogger := log.FromContext(ctx) ctxLogger.Error(err, "Failed to fetch authorization ConfigMap", "name", cmName, "namespace", proxy.Namespace) genericMsg := fmt.Sprintf("failed to fetch authorization ConfigMap %q", cmName) - r.recordValidationEvent(proxy, mcpv1alpha1.ConditionReasonAuthzConfigMapNotFound, genericMsg) - setConfigurationInvalidCondition(proxy, mcpv1alpha1.ConditionReasonAuthzConfigMapNotFound, genericMsg) + r.recordValidationEvent(proxy, mcpv1beta1.ConditionReasonAuthzConfigMapNotFound, genericMsg) + setConfigurationInvalidCondition(proxy, mcpv1beta1.ConditionReasonAuthzConfigMapNotFound, genericMsg) return stderrors.New(genericMsg) } } @@ -509,12 +509,12 @@ func (r *MCPRemoteProxyReconciler) validateK8sRefs( ) r.recordValidationEvent( proxy, - mcpv1alpha1.ConditionReasonHeaderSecretNotFound, + mcpv1beta1.ConditionReasonHeaderSecretNotFound, msg, ) setConfigurationInvalidCondition( proxy, - mcpv1alpha1.ConditionReasonHeaderSecretNotFound, + mcpv1beta1.ConditionReasonHeaderSecretNotFound, msg, ) return stderrors.New(msg) @@ -522,8 +522,8 @@ func (r *MCPRemoteProxyReconciler) validateK8sRefs( ctxLogger := log.FromContext(ctx) ctxLogger.Error(err, "Failed to fetch secret", "name", secretName, "namespace", proxy.Namespace) genericMsg := fmt.Sprintf("failed to fetch secret %q for header %q", secretName, headerRef.HeaderName) - r.recordValidationEvent(proxy, mcpv1alpha1.ConditionReasonHeaderSecretNotFound, genericMsg) - setConfigurationInvalidCondition(proxy, mcpv1alpha1.ConditionReasonHeaderSecretNotFound, genericMsg) + r.recordValidationEvent(proxy, mcpv1beta1.ConditionReasonHeaderSecretNotFound, genericMsg) + setConfigurationInvalidCondition(proxy, mcpv1beta1.ConditionReasonHeaderSecretNotFound, genericMsg) return stderrors.New(genericMsg) } } @@ -533,11 +533,11 @@ func (r *MCPRemoteProxyReconciler) validateK8sRefs( } // handleToolConfig handles MCPToolConfig reference for an MCPRemoteProxy -func (r *MCPRemoteProxyReconciler) handleToolConfig(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error { +func (r *MCPRemoteProxyReconciler) handleToolConfig(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { ctxLogger := log.FromContext(ctx) if proxy.Spec.ToolConfigRef == nil { // Remove condition if ToolConfigRef is not set - meta.RemoveStatusCondition(&proxy.Status.Conditions, mcpv1alpha1.ConditionTypeMCPRemoteProxyToolConfigValidated) + meta.RemoveStatusCondition(&proxy.Status.Conditions, mcpv1beta1.ConditionTypeMCPRemoteProxyToolConfigValidated) if proxy.Status.ToolConfigHash != "" { proxy.Status.ToolConfigHash = "" if err := r.Status().Update(ctx, proxy); err != nil { @@ -551,9 +551,9 @@ func (r *MCPRemoteProxyReconciler) handleToolConfig(ctx context.Context, proxy * if err != nil { if errors.IsNotFound(err) { meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyToolConfigValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyToolConfigValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyToolConfigNotFound, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyToolConfigNotFound, Message: fmt.Sprintf("MCPToolConfig '%s' not found in namespace '%s'", proxy.Spec.ToolConfigRef.Name, proxy.Namespace), ObservedGeneration: proxy.Generation, @@ -562,9 +562,9 @@ func (r *MCPRemoteProxyReconciler) handleToolConfig(ctx context.Context, proxy * proxy.Spec.ToolConfigRef.Name, proxy.Namespace) } meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyToolConfigValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyToolConfigValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyToolConfigFetchError, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyToolConfigFetchError, Message: "Failed to fetch MCPToolConfig", ObservedGeneration: proxy.Generation, }) @@ -573,9 +573,9 @@ func (r *MCPRemoteProxyReconciler) handleToolConfig(ctx context.Context, proxy * // ToolConfig found and valid meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyToolConfigValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyToolConfigValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyToolConfigValid, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyToolConfigValid, Message: fmt.Sprintf("MCPToolConfig '%s' is valid", toolConfig.Name), ObservedGeneration: proxy.Generation, }) @@ -598,12 +598,12 @@ func (r *MCPRemoteProxyReconciler) handleToolConfig(ctx context.Context, proxy * // handleTelemetryConfig validates and tracks the hash of the referenced MCPTelemetryConfig. // It updates the MCPRemoteProxy status when the telemetry configuration changes. -func (r *MCPRemoteProxyReconciler) handleTelemetryConfig(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error { +func (r *MCPRemoteProxyReconciler) handleTelemetryConfig(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { ctxLogger := log.FromContext(ctx) if proxy.Spec.TelemetryConfigRef == nil { // No MCPTelemetryConfig referenced, clear any stored hash and condition. - condType := mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated + condType := mcpv1beta1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated condRemoved := meta.FindStatusCondition(proxy.Status.Conditions, condType) != nil meta.RemoveStatusCondition(&proxy.Status.Conditions, condType) if condRemoved || proxy.Status.TelemetryConfigHash != "" { @@ -620,9 +620,9 @@ func (r *MCPRemoteProxyReconciler) handleTelemetryConfig(ctx context.Context, pr if err != nil { // Transient API error (not a NotFound) meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefFetchError, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyTelemetryConfigRefFetchError, Message: err.Error(), ObservedGeneration: proxy.Generation, }) @@ -632,9 +632,9 @@ func (r *MCPRemoteProxyReconciler) handleTelemetryConfig(ctx context.Context, pr if telemetryConfig == nil { // Resource genuinely does not exist meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefNotFound, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyTelemetryConfigRefNotFound, Message: fmt.Sprintf("MCPTelemetryConfig %s not found", proxy.Spec.TelemetryConfigRef.Name), ObservedGeneration: proxy.Generation, }) @@ -644,9 +644,9 @@ func (r *MCPRemoteProxyReconciler) handleTelemetryConfig(ctx context.Context, pr // Validate that the MCPTelemetryConfig is valid (has Valid=True condition) if err := telemetryConfig.Validate(); err != nil { meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefInvalid, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyTelemetryConfigRefInvalid, Message: fmt.Sprintf("MCPTelemetryConfig %s is invalid: %v", proxy.Spec.TelemetryConfigRef.Name, err), ObservedGeneration: proxy.Generation, }) @@ -656,14 +656,14 @@ func (r *MCPRemoteProxyReconciler) handleTelemetryConfig(ctx context.Context, pr // Detect whether the condition is transitioning to True (e.g. recovering from // a transient error). Without this check the status update is skipped when the // hash is unchanged, leaving a stale False condition. - condType := mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated + condType := mcpv1beta1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated prevCondition := meta.FindStatusCondition(proxy.Status.Conditions, condType) needsUpdate := prevCondition == nil || prevCondition.Status != metav1.ConditionTrue meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefValid, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyTelemetryConfigRefValid, Message: fmt.Sprintf("MCPTelemetryConfig %s is valid", proxy.Spec.TelemetryConfigRef.Name), ObservedGeneration: proxy.Generation, }) @@ -688,11 +688,11 @@ func (r *MCPRemoteProxyReconciler) handleTelemetryConfig(ctx context.Context, pr } // handleExternalAuthConfig validates and tracks the hash of the referenced MCPExternalAuthConfig -func (r *MCPRemoteProxyReconciler) handleExternalAuthConfig(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error { +func (r *MCPRemoteProxyReconciler) handleExternalAuthConfig(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { ctxLogger := log.FromContext(ctx) if proxy.Spec.ExternalAuthConfigRef == nil { // Remove condition if ExternalAuthConfigRef is not set - meta.RemoveStatusCondition(&proxy.Status.Conditions, mcpv1alpha1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated) + meta.RemoveStatusCondition(&proxy.Status.Conditions, mcpv1beta1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated) if proxy.Status.ExternalAuthConfigHash != "" { proxy.Status.ExternalAuthConfigHash = "" if err := r.Status().Update(ctx, proxy); err != nil { @@ -706,9 +706,9 @@ func (r *MCPRemoteProxyReconciler) handleExternalAuthConfig(ctx context.Context, if err != nil { if errors.IsNotFound(err) { meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigNotFound, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyExternalAuthConfigNotFound, Message: fmt.Sprintf("MCPExternalAuthConfig '%s' not found in namespace '%s'", proxy.Spec.ExternalAuthConfigRef.Name, proxy.Namespace), ObservedGeneration: proxy.Generation, @@ -717,9 +717,9 @@ func (r *MCPRemoteProxyReconciler) handleExternalAuthConfig(ctx context.Context, proxy.Spec.ExternalAuthConfigRef.Name, proxy.Namespace) } meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigFetchError, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyExternalAuthConfigFetchError, Message: "Failed to fetch MCPExternalAuthConfig", ObservedGeneration: proxy.Generation, }) @@ -730,9 +730,9 @@ func (r *MCPRemoteProxyReconciler) handleExternalAuthConfig(ctx context.Context, // Multi-upstream requires VirtualMCPServer. if embeddedCfg := externalAuthConfig.Spec.EmbeddedAuthServer; embeddedCfg != nil && len(embeddedCfg.UpstreamProviders) > 1 { meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigMultiUpstream, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyExternalAuthConfigMultiUpstream, Message: fmt.Sprintf( "MCPRemoteProxy supports only one upstream provider (found %d); "+ "use VirtualMCPServer for multi-upstream", @@ -745,9 +745,9 @@ func (r *MCPRemoteProxyReconciler) handleExternalAuthConfig(ctx context.Context, // ExternalAuthConfig found and valid meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigValid, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyExternalAuthConfigValid, Message: fmt.Sprintf("MCPExternalAuthConfig '%s' is valid", externalAuthConfig.Name), ObservedGeneration: proxy.Generation, }) @@ -771,10 +771,10 @@ func (r *MCPRemoteProxyReconciler) handleExternalAuthConfig(ctx context.Context, // handleAuthServerRef validates and tracks the hash of the referenced authServerRef config. // It updates the MCPRemoteProxy status when the auth server configuration changes and sets // the AuthServerRefValidated condition. -func (r *MCPRemoteProxyReconciler) handleAuthServerRef(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error { +func (r *MCPRemoteProxyReconciler) handleAuthServerRef(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { ctxLogger := log.FromContext(ctx) if proxy.Spec.AuthServerRef == nil { - meta.RemoveStatusCondition(&proxy.Status.Conditions, mcpv1alpha1.ConditionTypeMCPRemoteProxyAuthServerRefValidated) + meta.RemoveStatusCondition(&proxy.Status.Conditions, mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated) if proxy.Status.AuthServerConfigHash != "" { proxy.Status.AuthServerConfigHash = "" if err := r.Status().Update(ctx, proxy); err != nil { @@ -787,9 +787,9 @@ func (r *MCPRemoteProxyReconciler) handleAuthServerRef(ctx context.Context, prox // Only MCPExternalAuthConfig kind is supported if proxy.Spec.AuthServerRef.Kind != "MCPExternalAuthConfig" { meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyAuthServerRefInvalidKind, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyAuthServerRefInvalidKind, Message: fmt.Sprintf("unsupported authServerRef kind %q: only MCPExternalAuthConfig is supported", proxy.Spec.AuthServerRef.Kind), ObservedGeneration: proxy.Generation, @@ -803,9 +803,9 @@ func (r *MCPRemoteProxyReconciler) handleAuthServerRef(ctx context.Context, prox if err != nil { if errors.IsNotFound(err) { meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyAuthServerRefNotFound, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyAuthServerRefNotFound, Message: fmt.Sprintf("MCPExternalAuthConfig '%s' not found in namespace '%s'", proxy.Spec.AuthServerRef.Name, proxy.Namespace), ObservedGeneration: proxy.Generation, @@ -814,9 +814,9 @@ func (r *MCPRemoteProxyReconciler) handleAuthServerRef(ctx context.Context, prox proxy.Spec.AuthServerRef.Name, proxy.Namespace) } meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyAuthServerRefFetchError, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyAuthServerRefFetchError, Message: fmt.Sprintf("Failed to fetch MCPExternalAuthConfig '%s'", proxy.Spec.AuthServerRef.Name), ObservedGeneration: proxy.Generation, }) @@ -824,11 +824,11 @@ func (r *MCPRemoteProxyReconciler) handleAuthServerRef(ctx context.Context, prox } // Validate the config type is embeddedAuthServer - if authConfig.Spec.Type != mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer { + if authConfig.Spec.Type != mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer { meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyAuthServerRefInvalidType, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyAuthServerRefInvalidType, Message: fmt.Sprintf("authServerRef '%s' has type %q, but only embeddedAuthServer is supported", proxy.Spec.AuthServerRef.Name, authConfig.Spec.Type), ObservedGeneration: proxy.Generation, @@ -840,9 +840,9 @@ func (r *MCPRemoteProxyReconciler) handleAuthServerRef(ctx context.Context, prox // MCPRemoteProxy supports only single-upstream embedded auth server configs if embeddedCfg := authConfig.Spec.EmbeddedAuthServer; embeddedCfg != nil && len(embeddedCfg.UpstreamProviders) > 1 { meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyAuthServerRefMultiUpstream, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyAuthServerRefMultiUpstream, Message: fmt.Sprintf("MCPRemoteProxy supports only one upstream provider (found %d); "+ "use VirtualMCPServer for multi-upstream", len(embeddedCfg.UpstreamProviders)), @@ -855,9 +855,9 @@ func (r *MCPRemoteProxyReconciler) handleAuthServerRef(ctx context.Context, prox // AuthServerRef valid meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyAuthServerRefValid, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyAuthServerRefValid, Message: fmt.Sprintf("AuthServerRef '%s' is valid", authConfig.Name), ObservedGeneration: proxy.Generation, }) @@ -882,12 +882,12 @@ func (r *MCPRemoteProxyReconciler) handleAuthServerRef(ctx context.Context, prox // handleOIDCConfig validates and tracks the hash of the referenced MCPOIDCConfig. // It updates the MCPRemoteProxy status when the OIDC configuration changes and sets // the OIDCConfigRefValidated condition. -func (r *MCPRemoteProxyReconciler) handleOIDCConfig(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error { +func (r *MCPRemoteProxyReconciler) handleOIDCConfig(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { ctxLogger := log.FromContext(ctx) if proxy.Spec.OIDCConfigRef == nil { // Remove condition if OIDCConfigRef is not set - meta.RemoveStatusCondition(&proxy.Status.Conditions, mcpv1alpha1.ConditionOIDCConfigRefValidated) + meta.RemoveStatusCondition(&proxy.Status.Conditions, mcpv1beta1.ConditionOIDCConfigRefValidated) if proxy.Status.OIDCConfigHash != "" { proxy.Status.OIDCConfigHash = "" if err := r.Status().Update(ctx, proxy); err != nil { @@ -912,13 +912,13 @@ func (r *MCPRemoteProxyReconciler) handleOIDCConfig(ctx context.Context, proxy * // Detect whether the condition is transitioning to True (e.g. recovering from // a transient error). Without this check the status update is skipped when the // hash is unchanged, leaving a stale False condition (#4511). - prevCondition := meta.FindStatusCondition(proxy.Status.Conditions, mcpv1alpha1.ConditionOIDCConfigRefValidated) + prevCondition := meta.FindStatusCondition(proxy.Status.Conditions, mcpv1beta1.ConditionOIDCConfigRefValidated) needsUpdate := prevCondition == nil || prevCondition.Status != metav1.ConditionTrue meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionOIDCConfigRefValidated, + Type: mcpv1beta1.ConditionOIDCConfigRefValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonOIDCConfigRefValid, + Reason: mcpv1beta1.ConditionReasonOIDCConfigRefValid, Message: fmt.Sprintf("MCPOIDCConfig %s is valid and ready", proxy.Spec.OIDCConfigRef.Name), ObservedGeneration: proxy.Generation, }) @@ -945,16 +945,16 @@ func (r *MCPRemoteProxyReconciler) handleOIDCConfig(ctx context.Context, proxy * // fetchAndValidateOIDCConfig fetches the referenced MCPOIDCConfig, validates it is // ready, and sets appropriate failure conditions on the MCPRemoteProxy if not. func (r *MCPRemoteProxyReconciler) fetchAndValidateOIDCConfig( - ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy, -) (*mcpv1alpha1.MCPOIDCConfig, error) { + ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy, +) (*mcpv1beta1.MCPOIDCConfig, error) { ctxLogger := log.FromContext(ctx) oidcConfig, err := ctrlutil.GetOIDCConfigForServer(ctx, r.Client, proxy.Namespace, proxy.Spec.OIDCConfigRef) if err != nil { meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionOIDCConfigRefValidated, + Type: mcpv1beta1.ConditionOIDCConfigRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonOIDCConfigRefNotFound, + Reason: mcpv1beta1.ConditionReasonOIDCConfigRefNotFound, Message: fmt.Sprintf("MCPOIDCConfig %s not found: %v", proxy.Spec.OIDCConfigRef.Name, err), ObservedGeneration: proxy.Generation, }) @@ -966,9 +966,9 @@ func (r *MCPRemoteProxyReconciler) fetchAndValidateOIDCConfig( if oidcConfig == nil { meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionOIDCConfigRefValidated, + Type: mcpv1beta1.ConditionOIDCConfigRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonOIDCConfigRefNotFound, + Reason: mcpv1beta1.ConditionReasonOIDCConfigRefNotFound, Message: fmt.Sprintf("MCPOIDCConfig %s not found", proxy.Spec.OIDCConfigRef.Name), ObservedGeneration: proxy.Generation, }) @@ -978,16 +978,16 @@ func (r *MCPRemoteProxyReconciler) fetchAndValidateOIDCConfig( return nil, fmt.Errorf("MCPOIDCConfig %s not found", proxy.Spec.OIDCConfigRef.Name) } - validCondition := meta.FindStatusCondition(oidcConfig.Status.Conditions, mcpv1alpha1.ConditionTypeOIDCConfigValid) + validCondition := meta.FindStatusCondition(oidcConfig.Status.Conditions, mcpv1beta1.ConditionTypeOIDCConfigValid) if validCondition == nil || validCondition.Status != metav1.ConditionTrue { msg := fmt.Sprintf("MCPOIDCConfig %s is not valid", proxy.Spec.OIDCConfigRef.Name) if validCondition != nil { msg = fmt.Sprintf("MCPOIDCConfig %s is not valid: %s", proxy.Spec.OIDCConfigRef.Name, validCondition.Message) } meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionOIDCConfigRefValidated, + Type: mcpv1beta1.ConditionOIDCConfigRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonOIDCConfigRefNotValid, + Reason: mcpv1beta1.ConditionReasonOIDCConfigRefNotValid, Message: msg, ObservedGeneration: proxy.Generation, }) @@ -1004,11 +1004,11 @@ func (r *MCPRemoteProxyReconciler) fetchAndValidateOIDCConfig( // the MCPOIDCConfig's ReferencingWorkloads status field. func (r *MCPRemoteProxyReconciler) updateOIDCConfigReferencingWorkloads( ctx context.Context, - oidcConfig *mcpv1alpha1.MCPOIDCConfig, + oidcConfig *mcpv1beta1.MCPOIDCConfig, proxyName string, ) error { - ref := mcpv1alpha1.WorkloadReference{ - Kind: mcpv1alpha1.WorkloadKindMCPRemoteProxy, + ref := mcpv1beta1.WorkloadReference{ + Kind: mcpv1beta1.WorkloadKindMCPRemoteProxy, Name: proxyName, } @@ -1031,11 +1031,11 @@ func (r *MCPRemoteProxyReconciler) updateOIDCConfigReferencingWorkloads( // validateGroupRef validates the GroupRef field of the MCPRemoteProxy. // This function only sets conditions on the proxy object - the caller is responsible // for persisting the status update to avoid multiple conflicting status updates. -func (r *MCPRemoteProxyReconciler) validateGroupRef(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) { +func (r *MCPRemoteProxyReconciler) validateGroupRef(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) { if proxy.Spec.GroupRef == nil { // No group reference - remove any existing GroupRefValidated condition // to avoid showing stale info from a previous reconciliation - meta.RemoveStatusCondition(&proxy.Status.Conditions, mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated) + meta.RemoveStatusCondition(&proxy.Status.Conditions, mcpv1beta1.ConditionTypeMCPRemoteProxyGroupRefValidated) return } @@ -1043,29 +1043,29 @@ func (r *MCPRemoteProxyReconciler) validateGroupRef(ctx context.Context, proxy * groupName := proxy.Spec.GroupRef.Name // Find the referenced MCPGroup - group := &mcpv1alpha1.MCPGroup{} + group := &mcpv1beta1.MCPGroup{} if err := r.Get(ctx, types.NamespacedName{Namespace: proxy.Namespace, Name: groupName}, group); err != nil { ctxLogger.Error(err, "Failed to validate GroupRef") meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyGroupRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyGroupRefNotFound, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyGroupRefNotFound, Message: fmt.Sprintf("MCPGroup '%s' not found in namespace '%s'", groupName, proxy.Namespace), ObservedGeneration: proxy.Generation, }) - } else if group.Status.Phase != mcpv1alpha1.MCPGroupPhaseReady { + } else if group.Status.Phase != mcpv1beta1.MCPGroupPhaseReady { meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyGroupRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyGroupRefNotReady, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyGroupRefNotReady, Message: fmt.Sprintf("MCPGroup '%s' is not ready (current phase: %s)", groupName, group.Status.Phase), ObservedGeneration: proxy.Generation, }) } else { meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyGroupRefValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyGroupRefValidated, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyGroupRefValidated, Message: fmt.Sprintf("MCPGroup '%s' is valid and ready", groupName), ObservedGeneration: proxy.Generation, }) @@ -1075,7 +1075,7 @@ func (r *MCPRemoteProxyReconciler) validateGroupRef(ctx context.Context, proxy * // ensureRBACResources ensures that the RBAC resources are in place for the remote proxy. // Uses the RBAC client (pkg/kubernetes/rbac) which creates or updates RBAC resources // automatically during operator upgrades. -func (r *MCPRemoteProxyReconciler) ensureRBACResources(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error { +func (r *MCPRemoteProxyReconciler) ensureRBACResources(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { // If a service account is specified, we don't need to create one if proxy.Spec.ServiceAccount != nil { return nil @@ -1096,7 +1096,7 @@ func (r *MCPRemoteProxyReconciler) ensureRBACResources(ctx context.Context, prox } // updateMCPRemoteProxyStatus updates the status of the MCPRemoteProxy -func (r *MCPRemoteProxyReconciler) updateMCPRemoteProxyStatus(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error { +func (r *MCPRemoteProxyReconciler) updateMCPRemoteProxyStatus(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { // List the pods for this MCPRemoteProxy's deployment podList := &corev1.PodList{} listOpts := []client.ListOption{ @@ -1126,42 +1126,42 @@ func (r *MCPRemoteProxyReconciler) updateMCPRemoteProxyStatus(ctx context.Contex // Update the status if running > 0 { - proxy.Status.Phase = mcpv1alpha1.MCPRemoteProxyPhaseReady + proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseReady proxy.Status.Message = "Remote proxy is running" meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeReady, + Type: mcpv1beta1.ConditionTypeReady, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonDeploymentReady, + Reason: mcpv1beta1.ConditionReasonDeploymentReady, Message: "Deployment is ready and running", ObservedGeneration: proxy.Generation, }) } else if pending > 0 { - proxy.Status.Phase = mcpv1alpha1.MCPRemoteProxyPhasePending + proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhasePending proxy.Status.Message = "Remote proxy is starting" meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeReady, + Type: mcpv1beta1.ConditionTypeReady, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonDeploymentNotReady, + Reason: mcpv1beta1.ConditionReasonDeploymentNotReady, Message: "Deployment is not yet ready", ObservedGeneration: proxy.Generation, }) } else if failed > 0 { - proxy.Status.Phase = mcpv1alpha1.MCPRemoteProxyPhaseFailed + proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed proxy.Status.Message = "Remote proxy failed to start" meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeReady, + Type: mcpv1beta1.ConditionTypeReady, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonDeploymentNotReady, + Reason: mcpv1beta1.ConditionReasonDeploymentNotReady, Message: "Deployment failed", ObservedGeneration: proxy.Generation, }) } else { - proxy.Status.Phase = mcpv1alpha1.MCPRemoteProxyPhasePending + proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhasePending proxy.Status.Message = "No pods found for remote proxy" meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeReady, + Type: mcpv1beta1.ConditionTypeReady, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonDeploymentNotReady, + Reason: mcpv1beta1.ConditionReasonDeploymentNotReady, Message: "No pods found", ObservedGeneration: proxy.Generation, }) @@ -1192,7 +1192,7 @@ func proxyRunnerServiceAccountNameForRemoteProxy(proxyName string) string { // serviceAccountNameForRemoteProxy returns the service account name for a MCPRemoteProxy // If a service account is specified in the spec, it returns that. Otherwise, returns the default. -func serviceAccountNameForRemoteProxy(proxy *mcpv1alpha1.MCPRemoteProxy) string { +func serviceAccountNameForRemoteProxy(proxy *mcpv1beta1.MCPRemoteProxy) string { if proxy.Spec.ServiceAccount != nil { return *proxy.Spec.ServiceAccount } @@ -1221,7 +1221,7 @@ func createProxyServiceURL(proxyName, namespace string, port int32) string { func (r *MCPRemoteProxyReconciler) deploymentNeedsUpdate( ctx context.Context, deployment *appsv1.Deployment, - proxy *mcpv1alpha1.MCPRemoteProxy, + proxy *mcpv1beta1.MCPRemoteProxy, runConfigChecksum string, ) bool { if deployment == nil || proxy == nil { @@ -1254,7 +1254,7 @@ func (r *MCPRemoteProxyReconciler) deploymentNeedsUpdate( func (r *MCPRemoteProxyReconciler) containerNeedsUpdate( ctx context.Context, deployment *appsv1.Deployment, - proxy *mcpv1alpha1.MCPRemoteProxy, + proxy *mcpv1beta1.MCPRemoteProxy, ) bool { if deployment == nil || proxy == nil || len(deployment.Spec.Template.Spec.Containers) == 0 { return true @@ -1312,7 +1312,7 @@ func (r *MCPRemoteProxyReconciler) containerNeedsUpdate( // from ResourceOverrides.ProxyDeployment. func (*MCPRemoteProxyReconciler) deploymentMetadataNeedsUpdate( deployment *appsv1.Deployment, - proxy *mcpv1alpha1.MCPRemoteProxy, + proxy *mcpv1beta1.MCPRemoteProxy, ) bool { if deployment == nil || proxy == nil { return true @@ -1351,7 +1351,7 @@ func (*MCPRemoteProxyReconciler) deploymentMetadataNeedsUpdate( // Also includes any user-specified overrides from ResourceOverrides.PodTemplateMetadata. func (r *MCPRemoteProxyReconciler) podTemplateMetadataNeedsUpdate( deployment *appsv1.Deployment, - proxy *mcpv1alpha1.MCPRemoteProxy, + proxy *mcpv1beta1.MCPRemoteProxy, runConfigChecksum string, ) bool { if deployment == nil || proxy == nil { @@ -1374,7 +1374,7 @@ func (r *MCPRemoteProxyReconciler) podTemplateMetadataNeedsUpdate( } // serviceNeedsUpdate checks if the service needs to be updated -func (*MCPRemoteProxyReconciler) serviceNeedsUpdate(service *corev1.Service, proxy *mcpv1alpha1.MCPRemoteProxy) bool { +func (*MCPRemoteProxyReconciler) serviceNeedsUpdate(service *corev1.Service, proxy *mcpv1beta1.MCPRemoteProxy) bool { // Check if port has changed if len(service.Spec.Ports) > 0 && service.Spec.Ports[0].Port != int32(proxy.GetProxyPort()) { return true @@ -1420,13 +1420,13 @@ func (*MCPRemoteProxyReconciler) serviceNeedsUpdate(service *corev1.Service, pro func (r *MCPRemoteProxyReconciler) mapOIDCConfigToMCPRemoteProxy( ctx context.Context, obj client.Object, ) []reconcile.Request { - oidcConfig, ok := obj.(*mcpv1alpha1.MCPOIDCConfig) + oidcConfig, ok := obj.(*mcpv1beta1.MCPOIDCConfig) if !ok { return nil } // List all MCPRemoteProxies in the same namespace - proxyList := &mcpv1alpha1.MCPRemoteProxyList{} + proxyList := &mcpv1beta1.MCPRemoteProxyList{} if err := r.List(ctx, proxyList, client.InNamespace(oidcConfig.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPRemoteProxies for MCPOIDCConfig watch") return nil @@ -1453,12 +1453,12 @@ func (r *MCPRemoteProxyReconciler) mapOIDCConfigToMCPRemoteProxy( func (r *MCPRemoteProxyReconciler) mapTelemetryConfigToMCPRemoteProxy( ctx context.Context, obj client.Object, ) []reconcile.Request { - telemetryConfig, ok := obj.(*mcpv1alpha1.MCPTelemetryConfig) + telemetryConfig, ok := obj.(*mcpv1beta1.MCPTelemetryConfig) if !ok { return nil } - proxyList := &mcpv1alpha1.MCPRemoteProxyList{} + proxyList := &mcpv1beta1.MCPRemoteProxyList{} if err := r.List(ctx, proxyList, client.InNamespace(telemetryConfig.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPRemoteProxies for MCPTelemetryConfig watch") return nil @@ -1485,13 +1485,13 @@ func (r *MCPRemoteProxyReconciler) SetupWithManager(mgr ctrl.Manager) error { // Create a handler that maps MCPExternalAuthConfig changes to MCPRemoteProxy reconciliation requests externalAuthConfigHandler := handler.EnqueueRequestsFromMapFunc( func(ctx context.Context, obj client.Object) []reconcile.Request { - externalAuthConfig, ok := obj.(*mcpv1alpha1.MCPExternalAuthConfig) + externalAuthConfig, ok := obj.(*mcpv1beta1.MCPExternalAuthConfig) if !ok { return nil } // List all MCPRemoteProxies in the same namespace - proxyList := &mcpv1alpha1.MCPRemoteProxyList{} + proxyList := &mcpv1beta1.MCPRemoteProxyList{} if err := r.List(ctx, proxyList, client.InNamespace(externalAuthConfig.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPRemoteProxies for MCPExternalAuthConfig watch") return nil @@ -1520,13 +1520,13 @@ func (r *MCPRemoteProxyReconciler) SetupWithManager(mgr ctrl.Manager) error { // Create a handler that maps MCPToolConfig changes to MCPRemoteProxy reconciliation requests toolConfigHandler := handler.EnqueueRequestsFromMapFunc( func(ctx context.Context, obj client.Object) []reconcile.Request { - toolConfig, ok := obj.(*mcpv1alpha1.MCPToolConfig) + toolConfig, ok := obj.(*mcpv1beta1.MCPToolConfig) if !ok { return nil } // List all MCPRemoteProxies in the same namespace - proxyList := &mcpv1alpha1.MCPRemoteProxyList{} + proxyList := &mcpv1beta1.MCPRemoteProxyList{} if err := r.List(ctx, proxyList, client.InNamespace(toolConfig.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPRemoteProxies for MCPToolConfig watch") return nil @@ -1551,17 +1551,17 @@ func (r *MCPRemoteProxyReconciler) SetupWithManager(mgr ctrl.Manager) error { ) return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1alpha1.MCPRemoteProxy{}). + For(&mcpv1beta1.MCPRemoteProxy{}). Owns(&appsv1.Deployment{}). Owns(&corev1.Service{}). - Watches(&mcpv1alpha1.MCPExternalAuthConfig{}, externalAuthConfigHandler). - Watches(&mcpv1alpha1.MCPToolConfig{}, toolConfigHandler). + Watches(&mcpv1beta1.MCPExternalAuthConfig{}, externalAuthConfigHandler). + Watches(&mcpv1beta1.MCPToolConfig{}, toolConfigHandler). Watches( - &mcpv1alpha1.MCPOIDCConfig{}, + &mcpv1beta1.MCPOIDCConfig{}, handler.EnqueueRequestsFromMapFunc(r.mapOIDCConfigToMCPRemoteProxy), ). Watches( - &mcpv1alpha1.MCPTelemetryConfig{}, + &mcpv1beta1.MCPTelemetryConfig{}, handler.EnqueueRequestsFromMapFunc(r.mapTelemetryConfigToMCPRemoteProxy), ). Complete(r) diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go index 09e8c0b300..6772437262 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go @@ -33,7 +33,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -43,18 +43,18 @@ func TestMCPRemoteProxyValidateSpec(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy + proxy *mcpv1beta1.MCPRemoteProxy expectError bool errContains string }{ { name: "valid spec", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "valid-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.salesforce.com", ProxyPort: 8080, }, @@ -63,12 +63,12 @@ func TestMCPRemoteProxyValidateSpec(t *testing.T) { }, { name: "missing remote URL", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "no-url-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ ProxyPort: 8080, }, }, @@ -79,15 +79,15 @@ func TestMCPRemoteProxyValidateSpec(t *testing.T) { // with kubebuilder:validation:Required, so the API server prevents resources without it { name: "with valid external auth config", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "external-auth-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "exchange-config", }, }, @@ -130,12 +130,12 @@ func TestMCPRemoteProxyValidateSpec(t *testing.T) { func TestMCPRemoteProxyReconcile_CreateResources(t *testing.T) { t.Parallel() - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.salesforce.com", ProxyPort: 8080, }, @@ -253,8 +253,8 @@ func TestHandleToolConfig(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy - toolConfig *mcpv1alpha1.MCPToolConfig + proxy *mcpv1beta1.MCPRemoteProxy + toolConfig *mcpv1beta1.MCPToolConfig interceptorFuncs *interceptor.Funcs expectError bool errContains string @@ -264,12 +264,12 @@ func TestHandleToolConfig(t *testing.T) { }{ { name: "no tool config reference", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "no-tools-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", }, }, @@ -278,80 +278,80 @@ func TestHandleToolConfig(t *testing.T) { }, { name: "valid tool config reference", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "tools-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "tool-config", }, }, }, - toolConfig: &mcpv1alpha1.MCPToolConfig{ + toolConfig: &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "tool-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1", "tool2"}, }, - Status: mcpv1alpha1.MCPToolConfigStatus{ + Status: mcpv1beta1.MCPToolConfigStatus{ ConfigHash: "abc123", }, }, expectError: false, expectCondition: true, expectedCondStatus: metav1.ConditionTrue, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyToolConfigValid, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyToolConfigValid, }, { name: "tool config hash update", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "tools-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "tool-config", }, }, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ + Status: mcpv1beta1.MCPRemoteProxyStatus{ ToolConfigHash: "old-hash", }, }, - toolConfig: &mcpv1alpha1.MCPToolConfig{ + toolConfig: &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "tool-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1", "tool2"}, }, - Status: mcpv1alpha1.MCPToolConfigStatus{ + Status: mcpv1beta1.MCPToolConfigStatus{ ConfigHash: "new-hash", }, }, expectError: false, expectCondition: true, expectedCondStatus: metav1.ConditionTrue, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyToolConfigValid, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyToolConfigValid, }, { name: "tool config reference removed", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "tools-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", }, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ + Status: mcpv1beta1.MCPRemoteProxyStatus{ ToolConfigHash: "old-hash", }, }, @@ -360,14 +360,14 @@ func TestHandleToolConfig(t *testing.T) { }, { name: "tool config not found", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "broken-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "non-existent", }, }, @@ -376,25 +376,25 @@ func TestHandleToolConfig(t *testing.T) { errContains: "not found in namespace", expectCondition: true, expectedCondStatus: metav1.ConditionFalse, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyToolConfigNotFound, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyToolConfigNotFound, }, { name: "tool config fetch error", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "error-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "tool-config", }, }, }, interceptorFuncs: &interceptor.Funcs{ Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - if _, ok := obj.(*mcpv1alpha1.MCPToolConfig); ok { + if _, ok := obj.(*mcpv1beta1.MCPToolConfig); ok { return fmt.Errorf("simulated API server error") } return c.Get(ctx, key, obj, opts...) @@ -404,7 +404,7 @@ func TestHandleToolConfig(t *testing.T) { errContains: "failed to fetch MCPToolConfig", expectCondition: true, expectedCondStatus: metav1.ConditionFalse, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyToolConfigFetchError, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyToolConfigFetchError, }, } @@ -421,7 +421,7 @@ func TestHandleToolConfig(t *testing.T) { builder := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(objects...). - WithStatusSubresource(&mcpv1alpha1.MCPRemoteProxy{}) + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}) if tt.interceptorFuncs != nil { builder = builder.WithInterceptorFuncs(*tt.interceptorFuncs) } @@ -443,7 +443,7 @@ func TestHandleToolConfig(t *testing.T) { // Verify condition on in-memory object for error cases if tt.expectCondition { cond := meta.FindStatusCondition(tt.proxy.Status.Conditions, - mcpv1alpha1.ConditionTypeMCPRemoteProxyToolConfigValidated) + mcpv1beta1.ConditionTypeMCPRemoteProxyToolConfigValidated) assert.NotNil(t, cond, "ToolConfigValidated condition should be set") if cond != nil { assert.Equal(t, tt.expectedCondStatus, cond.Status, @@ -456,7 +456,7 @@ func TestHandleToolConfig(t *testing.T) { assert.NoError(t, err) // Verify status updates - updatedProxy := &mcpv1alpha1.MCPRemoteProxy{} + updatedProxy := &mcpv1beta1.MCPRemoteProxy{} err := fakeClient.Get(context.TODO(), client.ObjectKey{ Name: tt.proxy.Name, Namespace: tt.proxy.Namespace, @@ -476,7 +476,7 @@ func TestHandleToolConfig(t *testing.T) { // Verify condition (check in-memory object since conditions are set there) if tt.expectCondition { cond := meta.FindStatusCondition(tt.proxy.Status.Conditions, - mcpv1alpha1.ConditionTypeMCPRemoteProxyToolConfigValidated) + mcpv1beta1.ConditionTypeMCPRemoteProxyToolConfigValidated) assert.NotNil(t, cond, "ToolConfigValidated condition should be set") if cond != nil { assert.Equal(t, tt.expectedCondStatus, cond.Status, @@ -486,7 +486,7 @@ func TestHandleToolConfig(t *testing.T) { } } else { cond := meta.FindStatusCondition(tt.proxy.Status.Conditions, - mcpv1alpha1.ConditionTypeMCPRemoteProxyToolConfigValidated) + mcpv1beta1.ConditionTypeMCPRemoteProxyToolConfigValidated) assert.Nil(t, cond, "ToolConfigValidated condition should not be set when no reference") } } @@ -500,8 +500,8 @@ func TestHandleExternalAuthConfig(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + proxy *mcpv1beta1.MCPRemoteProxy + externalAuth *mcpv1beta1.MCPExternalAuthConfig interceptorFuncs *interceptor.Funcs expectError bool errContains string @@ -511,12 +511,12 @@ func TestHandleExternalAuthConfig(t *testing.T) { }{ { name: "no external auth reference", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "no-auth-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", }, }, @@ -525,98 +525,98 @@ func TestHandleExternalAuthConfig(t *testing.T) { }, { name: "valid external auth reference", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://keycloak.com/token", ClientID: "client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "secret", Key: "key", }, Audience: "api", }, }, - Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ ConfigHash: "xyz789", }, }, expectError: false, expectCondition: true, expectedCondStatus: metav1.ConditionTrue, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigValid, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyExternalAuthConfigValid, }, { name: "external auth config hash update", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-config", }, }, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ + Status: mcpv1beta1.MCPRemoteProxyStatus{ ExternalAuthConfigHash: "old-hash", }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://keycloak.com/token", ClientID: "client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "secret", Key: "key", }, Audience: "api", }, }, - Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ ConfigHash: "new-hash", }, }, expectError: false, expectCondition: true, expectedCondStatus: metav1.ConditionTrue, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigValid, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyExternalAuthConfigValid, }, { name: "external auth config reference removed", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", }, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ + Status: mcpv1beta1.MCPRemoteProxyStatus{ ExternalAuthConfigHash: "old-hash", }, }, @@ -625,14 +625,14 @@ func TestHandleExternalAuthConfig(t *testing.T) { }, { name: "external auth config not found", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "broken-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "non-existent", }, }, @@ -641,25 +641,25 @@ func TestHandleExternalAuthConfig(t *testing.T) { errContains: "not found in namespace", expectCondition: true, expectedCondStatus: metav1.ConditionFalse, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigNotFound, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyExternalAuthConfigNotFound, }, { name: "external auth config fetch error", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "error-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-config", }, }, }, interceptorFuncs: &interceptor.Funcs{ Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - if _, ok := obj.(*mcpv1alpha1.MCPExternalAuthConfig); ok { + if _, ok := obj.(*mcpv1beta1.MCPExternalAuthConfig); ok { return fmt.Errorf("simulated API server error") } return c.Get(ctx, key, obj, opts...) @@ -669,44 +669,44 @@ func TestHandleExternalAuthConfig(t *testing.T) { errContains: "failed to fetch MCPExternalAuthConfig", expectCondition: true, expectedCondStatus: metav1.ConditionFalse, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigFetchError, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyExternalAuthConfigFetchError, }, { name: "embedded auth server with multiple upstreams rejected", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "multi-upstream-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "multi-upstream-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "multi-upstream-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ - {Name: "github", Type: mcpv1alpha1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{IssuerURL: "https://github.com", ClientID: "id1"}}, - {Name: "google", Type: mcpv1alpha1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{IssuerURL: "https://accounts.google.com", ClientID: "id2"}}, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + {Name: "github", Type: mcpv1beta1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{IssuerURL: "https://github.com", ClientID: "id1"}}, + {Name: "google", Type: mcpv1beta1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{IssuerURL: "https://accounts.google.com", ClientID: "id2"}}, }, }, }, - Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ConfigHash: "multi-hash"}, + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ConfigHash: "multi-hash"}, }, expectError: true, errContains: "only 1 is supported", expectCondition: true, expectedCondStatus: metav1.ConditionFalse, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigMultiUpstream, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyExternalAuthConfigMultiUpstream, }, } @@ -723,7 +723,7 @@ func TestHandleExternalAuthConfig(t *testing.T) { builder := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(objects...). - WithStatusSubresource(&mcpv1alpha1.MCPRemoteProxy{}) + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}) if tt.interceptorFuncs != nil { builder = builder.WithInterceptorFuncs(*tt.interceptorFuncs) } @@ -745,7 +745,7 @@ func TestHandleExternalAuthConfig(t *testing.T) { // Verify condition on in-memory object for error cases if tt.expectCondition { cond := meta.FindStatusCondition(tt.proxy.Status.Conditions, - mcpv1alpha1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated) + mcpv1beta1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated) assert.NotNil(t, cond, "ExternalAuthConfigValidated condition should be set") if cond != nil { assert.Equal(t, tt.expectedCondStatus, cond.Status, @@ -758,7 +758,7 @@ func TestHandleExternalAuthConfig(t *testing.T) { assert.NoError(t, err) // Verify status updates - updatedProxy := &mcpv1alpha1.MCPRemoteProxy{} + updatedProxy := &mcpv1beta1.MCPRemoteProxy{} err := fakeClient.Get(context.TODO(), client.ObjectKey{ Name: tt.proxy.Name, Namespace: tt.proxy.Namespace, @@ -778,7 +778,7 @@ func TestHandleExternalAuthConfig(t *testing.T) { // Verify condition (check in-memory object since conditions are set there) if tt.expectCondition { cond := meta.FindStatusCondition(tt.proxy.Status.Conditions, - mcpv1alpha1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated) + mcpv1beta1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated) assert.NotNil(t, cond, "ExternalAuthConfigValidated condition should be set") if cond != nil { assert.Equal(t, tt.expectedCondStatus, cond.Status, @@ -788,7 +788,7 @@ func TestHandleExternalAuthConfig(t *testing.T) { } } else { cond := meta.FindStatusCondition(tt.proxy.Status.Conditions, - mcpv1alpha1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated) + mcpv1beta1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated) assert.Nil(t, cond, "ExternalAuthConfigValidated condition should not be set when no reference") } } @@ -850,12 +850,12 @@ func TestServiceNameGeneration(t *testing.T) { func TestEnsureRBACResources(t *testing.T) { t.Parallel() - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "rbac-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, }, @@ -909,13 +909,13 @@ func TestEnsureRBACResources(t *testing.T) { func TestMCPRemoteProxyEnsureRBACResources_Update(t *testing.T) { t.Parallel() - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "update-proxy", Namespace: "default", UID: "test-uid", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, }, @@ -992,12 +992,12 @@ func TestMCPRemoteProxyEnsureRBACResources_Update(t *testing.T) { func TestMCPRemoteProxyEnsureRBACResources_Idempotency(t *testing.T) { t.Parallel() - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "idempotent-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, }, @@ -1054,12 +1054,12 @@ func TestMCPRemoteProxyEnsureRBACResources_CustomServiceAccount(t *testing.T) { t.Parallel() customSA := "custom-proxy-sa" - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "custom-sa-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, ServiceAccount: &customSA, @@ -1114,18 +1114,18 @@ func TestUpdateMCPRemoteProxyStatus(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy + proxy *mcpv1beta1.MCPRemoteProxy pods []corev1.Pod - expectedPhase mcpv1alpha1.MCPRemoteProxyPhase + expectedPhase mcpv1beta1.MCPRemoteProxyPhase }{ { name: "running pod", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "running-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", }, }, @@ -1141,16 +1141,16 @@ func TestUpdateMCPRemoteProxyStatus(t *testing.T) { }, }, }, - expectedPhase: mcpv1alpha1.MCPRemoteProxyPhaseReady, + expectedPhase: mcpv1beta1.MCPRemoteProxyPhaseReady, }, { name: "pending pod", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "pending-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", }, }, @@ -1166,16 +1166,16 @@ func TestUpdateMCPRemoteProxyStatus(t *testing.T) { }, }, }, - expectedPhase: mcpv1alpha1.MCPRemoteProxyPhasePending, + expectedPhase: mcpv1beta1.MCPRemoteProxyPhasePending, }, { name: "failed pod", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "failed-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", }, }, @@ -1191,21 +1191,21 @@ func TestUpdateMCPRemoteProxyStatus(t *testing.T) { }, }, }, - expectedPhase: mcpv1alpha1.MCPRemoteProxyPhaseFailed, + expectedPhase: mcpv1beta1.MCPRemoteProxyPhaseFailed, }, { name: "no pods", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "no-pods-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", }, }, pods: []corev1.Pod{}, - expectedPhase: mcpv1alpha1.MCPRemoteProxyPhasePending, + expectedPhase: mcpv1beta1.MCPRemoteProxyPhasePending, }, } @@ -1234,7 +1234,7 @@ func TestUpdateMCPRemoteProxyStatus(t *testing.T) { assert.NoError(t, err) // Fetch updated proxy - updatedProxy := &mcpv1alpha1.MCPRemoteProxy{} + updatedProxy := &mcpv1beta1.MCPRemoteProxy{} err = fakeClient.Get(context.TODO(), types.NamespacedName{ Name: tt.proxy.Name, Namespace: tt.proxy.Namespace, @@ -1249,23 +1249,23 @@ func TestUpdateMCPRemoteProxyStatus(t *testing.T) { func TestGetToolConfigForMCPRemoteProxy(t *testing.T) { t.Parallel() - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-tools", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1"}, }, } - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "test-tools", }, }, @@ -1287,23 +1287,23 @@ func TestGetToolConfigForMCPRemoteProxy(t *testing.T) { func TestGetExternalAuthConfigForMCPRemoteProxy(t *testing.T) { t.Parallel() - externalAuth := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, }, } - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go b/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go index ef81f83044..763dad3dab 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go @@ -14,7 +14,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" "github.com/stacklok/toolhive/pkg/container/kubernetes" @@ -22,7 +22,7 @@ import ( // deploymentForMCPRemoteProxy returns a MCPRemoteProxy Deployment object func (r *MCPRemoteProxyReconciler) deploymentForMCPRemoteProxy( - ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy, runConfigChecksum string, + ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy, runConfigChecksum string, ) *appsv1.Deployment { ls := labelsForMCPRemoteProxy(proxy.Name) replicas := int32(1) @@ -110,7 +110,7 @@ func (*MCPRemoteProxyReconciler) buildContainerArgs() []string { // Note: Embedded auth server volumes are added separately in deploymentForMCPRemoteProxy // to avoid duplicate API calls. func (*MCPRemoteProxyReconciler) buildVolumesForProxy( - proxy *mcpv1alpha1.MCPRemoteProxy, + proxy *mcpv1beta1.MCPRemoteProxy, ) ([]corev1.VolumeMount, []corev1.Volume) { volumeMounts := []corev1.VolumeMount{} volumes := []corev1.Volume{} @@ -148,7 +148,7 @@ func (*MCPRemoteProxyReconciler) buildVolumesForProxy( // Must be called from deploymentForMCPRemoteProxy where the client is available. func (r *MCPRemoteProxyReconciler) addTelemetryCABundleVolumes( ctx context.Context, - proxy *mcpv1alpha1.MCPRemoteProxy, + proxy *mcpv1beta1.MCPRemoteProxy, volumes *[]corev1.Volume, volumeMounts *[]corev1.VolumeMount, ) { @@ -169,7 +169,7 @@ func (r *MCPRemoteProxyReconciler) addTelemetryCABundleVolumes( // buildEnvVarsForProxy builds environment variables for the proxy container func (r *MCPRemoteProxyReconciler) buildEnvVarsForProxy( - ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy, + ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy, ) []corev1.EnvVar { env := r.buildOIDCClientSecretEnvVars(ctx, proxy) @@ -237,7 +237,7 @@ func (r *MCPRemoteProxyReconciler) buildEnvVarsForProxy( // buildOIDCClientSecretEnvVars returns OIDC client secret env vars when the proxy // references an MCPOIDCConfig with an inline client secret. Returns nil otherwise. func (r *MCPRemoteProxyReconciler) buildOIDCClientSecretEnvVars( - ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy, + ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy, ) []corev1.EnvVar { if proxy.Spec.OIDCConfigRef == nil { return nil @@ -248,7 +248,7 @@ func (r *MCPRemoteProxyReconciler) buildOIDCClientSecretEnvVars( return nil } if oidcCfg == nil || - oidcCfg.Spec.Type != mcpv1alpha1.MCPOIDCConfigTypeInline || + oidcCfg.Spec.Type != mcpv1beta1.MCPOIDCConfigTypeInline || oidcCfg.Spec.Inline == nil { return nil } @@ -268,7 +268,7 @@ func (r *MCPRemoteProxyReconciler) buildOIDCClientSecretEnvVars( // buildHeaderForwardSecretEnvVars builds environment variables for header forward secrets. // Each secret is mounted as an env var using Kubernetes SecretKeyRef, with a name following // the TOOLHIVE_SECRET_<identifier> pattern expected by the secrets.EnvironmentProvider. -func buildHeaderForwardSecretEnvVars(proxy *mcpv1alpha1.MCPRemoteProxy) []corev1.EnvVar { +func buildHeaderForwardSecretEnvVars(proxy *mcpv1beta1.MCPRemoteProxy) []corev1.EnvVar { var envVars []corev1.EnvVar for _, headerSecret := range proxy.Spec.HeaderForward.AddHeadersFromSecret { @@ -297,7 +297,7 @@ func buildHeaderForwardSecretEnvVars(proxy *mcpv1alpha1.MCPRemoteProxy) []corev1 // buildDeploymentMetadata builds deployment-level labels and annotations func (*MCPRemoteProxyReconciler) buildDeploymentMetadata( - baseLabels map[string]string, proxy *mcpv1alpha1.MCPRemoteProxy, + baseLabels map[string]string, proxy *mcpv1beta1.MCPRemoteProxy, ) (map[string]string, map[string]string) { deploymentLabels := baseLabels deploymentAnnotations := make(map[string]string) @@ -325,7 +325,7 @@ func (*MCPRemoteProxyReconciler) buildDeploymentMetadata( // User-specified overrides from ResourceOverrides.PodTemplateMetadataOverrides // are merged after the checksum annotation is set. func (*MCPRemoteProxyReconciler) buildPodTemplateMetadata( - baseLabels map[string]string, proxy *mcpv1alpha1.MCPRemoteProxy, runConfigChecksum string, + baseLabels map[string]string, proxy *mcpv1beta1.MCPRemoteProxy, runConfigChecksum string, ) (map[string]string, map[string]string) { templateLabels := baseLabels templateAnnotations := make(map[string]string) @@ -352,7 +352,7 @@ func (*MCPRemoteProxyReconciler) buildPodTemplateMetadata( // buildSecurityContexts builds pod and container security contexts func (r *MCPRemoteProxyReconciler) buildSecurityContexts( - ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy, + ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy, ) (*corev1.PodSecurityContext, *corev1.SecurityContext) { if r.PlatformDetector == nil { r.PlatformDetector = ctrlutil.NewSharedPlatformDetector() @@ -369,7 +369,7 @@ func (r *MCPRemoteProxyReconciler) buildSecurityContexts( } // buildContainerPorts builds container port configuration -func (*MCPRemoteProxyReconciler) buildContainerPorts(proxy *mcpv1alpha1.MCPRemoteProxy) []corev1.ContainerPort { +func (*MCPRemoteProxyReconciler) buildContainerPorts(proxy *mcpv1beta1.MCPRemoteProxy) []corev1.ContainerPort { return []corev1.ContainerPort{{ ContainerPort: int32(proxy.GetProxyPort()), Name: "http", @@ -379,7 +379,7 @@ func (*MCPRemoteProxyReconciler) buildContainerPorts(proxy *mcpv1alpha1.MCPRemot // serviceForMCPRemoteProxy returns a MCPRemoteProxy Service object func (r *MCPRemoteProxyReconciler) serviceForMCPRemoteProxy( - ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy, + ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy, ) *corev1.Service { ls := labelsForMCPRemoteProxy(proxy.Name) svcName := createProxyServiceName(proxy.Name) @@ -423,7 +423,7 @@ func (r *MCPRemoteProxyReconciler) serviceForMCPRemoteProxy( // buildServiceMetadata builds service labels and annotations func (*MCPRemoteProxyReconciler) buildServiceMetadata( - baseLabels map[string]string, proxy *mcpv1alpha1.MCPRemoteProxy, + baseLabels map[string]string, proxy *mcpv1beta1.MCPRemoteProxy, ) (map[string]string, map[string]string) { serviceLabels := baseLabels serviceAnnotations := make(map[string]string) diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go index 6686f30d76..0b128dd31d 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go @@ -28,7 +28,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -38,17 +38,17 @@ func TestDeploymentForMCPRemoteProxy(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy + proxy *mcpv1beta1.MCPRemoteProxy validate func(*testing.T, *appsv1.Deployment) }{ { name: "basic deployment", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, }, @@ -87,20 +87,20 @@ func TestDeploymentForMCPRemoteProxy(t *testing.T) { }, { name: "with resource limits", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "resources-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - Resources: mcpv1alpha1.ResourceRequirements{ - Limits: mcpv1alpha1.ResourceList{ + Resources: mcpv1beta1.ResourceRequirements{ + Limits: mcpv1beta1.ResourceList{ CPU: "1", Memory: "512Mi", }, - Requests: mcpv1alpha1.ResourceList{ + Requests: mcpv1beta1.ResourceList{ CPU: "100m", Memory: "128Mi", }, @@ -118,17 +118,17 @@ func TestDeploymentForMCPRemoteProxy(t *testing.T) { }, { name: "with resource overrides", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "override-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - ResourceMetadataOverrides: mcpv1alpha1.ResourceMetadataOverrides{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + ResourceMetadataOverrides: mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "custom-label": "custom-value", }, @@ -136,7 +136,7 @@ func TestDeploymentForMCPRemoteProxy(t *testing.T) { "custom-annotation": "custom-annotation-value", }, }, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "CUSTOM_ENV", Value: "custom-value"}, {Name: "TOOLHIVE_DEBUG", Value: "true"}, }, @@ -178,12 +178,12 @@ func TestDeploymentForMCPRemoteProxy(t *testing.T) { }, { name: "custom proxyPort", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "custom-port-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 9090, }, @@ -222,17 +222,17 @@ func TestServiceForMCPRemoteProxy(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy + proxy *mcpv1beta1.MCPRemoteProxy validate func(*testing.T, *corev1.Service) }{ { name: "basic service", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, }, @@ -256,12 +256,12 @@ func TestServiceForMCPRemoteProxy(t *testing.T) { }, { name: "service with session affinity None", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, SessionAffinity: string(corev1.ServiceAffinityNone), @@ -274,16 +274,16 @@ func TestServiceForMCPRemoteProxy(t *testing.T) { }, { name: "service with overrides", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "override-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 9090, - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyService: &mcpv1alpha1.ResourceMetadataOverrides{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyService: &mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "svc-label": "svc-value", }, @@ -329,17 +329,17 @@ func TestBuildResourceRequirements(t *testing.T) { tests := []struct { name string - resourceSpec mcpv1alpha1.ResourceRequirements + resourceSpec mcpv1beta1.ResourceRequirements validate func(*testing.T, corev1.ResourceRequirements) }{ { name: "with limits and requests", - resourceSpec: mcpv1alpha1.ResourceRequirements{ - Limits: mcpv1alpha1.ResourceList{ + resourceSpec: mcpv1beta1.ResourceRequirements{ + Limits: mcpv1beta1.ResourceList{ CPU: "2", Memory: "1Gi", }, - Requests: mcpv1alpha1.ResourceList{ + Requests: mcpv1beta1.ResourceList{ CPU: "500m", Memory: "256Mi", }, @@ -354,7 +354,7 @@ func TestBuildResourceRequirements(t *testing.T) { }, { name: "empty resources", - resourceSpec: mcpv1alpha1.ResourceRequirements{}, + resourceSpec: mcpv1beta1.ResourceRequirements{}, validate: func(t *testing.T, res corev1.ResourceRequirements) { t.Helper() assert.Nil(t, res.Limits) @@ -382,22 +382,22 @@ func TestBuildHeaderForwardSecretEnvVars(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy + proxy *mcpv1beta1.MCPRemoteProxy validate func(*testing.T, []corev1.EnvVar) }{ { name: "single header secret", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - HeaderForward: &mcpv1alpha1.HeaderForwardConfig{ - AddHeadersFromSecret: []mcpv1alpha1.HeaderFromSecret{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ + AddHeadersFromSecret: []mcpv1beta1.HeaderFromSecret{ { HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "my-secret", Key: "api-key", }, @@ -418,24 +418,24 @@ func TestBuildHeaderForwardSecretEnvVars(t *testing.T) { }, { name: "multiple header secrets", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "multi-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - HeaderForward: &mcpv1alpha1.HeaderForwardConfig{ - AddHeadersFromSecret: []mcpv1alpha1.HeaderFromSecret{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ + AddHeadersFromSecret: []mcpv1beta1.HeaderFromSecret{ { HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "secret-a", Key: "key-a", }, }, { HeaderName: "X-Token", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "secret-b", Key: "key-b", }, @@ -457,21 +457,21 @@ func TestBuildHeaderForwardSecretEnvVars(t *testing.T) { }, { name: "skip entries with nil ValueSecretRef", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "skip-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - HeaderForward: &mcpv1alpha1.HeaderForwardConfig{ - AddHeadersFromSecret: []mcpv1alpha1.HeaderFromSecret{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ + AddHeadersFromSecret: []mcpv1beta1.HeaderFromSecret{ { HeaderName: "X-Invalid", ValueSecretRef: nil, // Should be skipped }, { HeaderName: "X-Valid", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "valid-secret", Key: "valid-key", }, @@ -488,14 +488,14 @@ func TestBuildHeaderForwardSecretEnvVars(t *testing.T) { }, { name: "empty AddHeadersFromSecret", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "empty-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - HeaderForward: &mcpv1alpha1.HeaderForwardConfig{ - AddHeadersFromSecret: []mcpv1alpha1.HeaderFromSecret{}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ + AddHeadersFromSecret: []mcpv1beta1.HeaderFromSecret{}, }, }, }, @@ -541,18 +541,18 @@ func TestEnsureDeployment(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy + proxy *mcpv1beta1.MCPRemoteProxy existingDeployment *appsv1.Deployment expectRequeue bool }{ { name: "create new deployment", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "new-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, }, @@ -562,12 +562,12 @@ func TestEnsureDeployment(t *testing.T) { }, { name: "deployment exists - no update to allow HPA", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "replica-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, }, @@ -645,18 +645,18 @@ func TestEnsureService(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy + proxy *mcpv1beta1.MCPRemoteProxy existingService *corev1.Service expectRequeue bool }{ { name: "create new service", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "new-svc-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, }, @@ -705,22 +705,22 @@ func TestMCPRemoteProxyDeploymentNeedsUpdate_EmbeddedAuthLegacyEnvStable(t *test scheme := createRunConfigTestScheme() - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "embedded-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "google", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://accounts.google.com", ClientID: "client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "upstream-secret", Key: "client-secret", }, @@ -731,15 +731,15 @@ func TestMCPRemoteProxyDeploymentNeedsUpdate_EmbeddedAuthLegacyEnvStable(t *test }, } - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: authConfig.Name, }, }, @@ -767,22 +767,22 @@ func TestMCPRemoteProxyDeploymentNeedsUpdate_EmbeddedAuthAuthServerRefEnvStable( scheme := createRunConfigTestScheme() - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "embedded-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "google", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://accounts.google.com", ClientID: "client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "upstream-secret", Key: "client-secret", }, @@ -793,15 +793,15 @@ func TestMCPRemoteProxyDeploymentNeedsUpdate_EmbeddedAuthAuthServerRefEnvStable( }, } - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - AuthServerRef: &mcpv1alpha1.AuthServerRef{ + AuthServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: authConfig.Name, }, @@ -830,17 +830,17 @@ func TestMCPRemoteProxyDeploymentNeedsUpdate_TokenExchangeDoesNotDrift(t *testin scheme := createRunConfigTestScheme() - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "exchange-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "token-secret", Key: "client-secret", }, @@ -849,15 +849,15 @@ func TestMCPRemoteProxyDeploymentNeedsUpdate_TokenExchangeDoesNotDrift(t *testin }, } - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: authConfig.Name, }, }, @@ -886,19 +886,19 @@ func TestBuildEnvVarsForProxy(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + proxy *mcpv1beta1.MCPRemoteProxy + externalAuth *mcpv1beta1.MCPExternalAuthConfig clientSecret *corev1.Secret validate func(*testing.T, []corev1.EnvVar) }{ { name: "basic env vars", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", }, }, @@ -918,29 +918,29 @@ func TestBuildEnvVarsForProxy(t *testing.T) { }, { name: "with token exchange", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "exchange-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "exchange-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "exchange-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.com/token", ClientID: "client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "secret", Key: "key", }, @@ -975,25 +975,25 @@ func TestBuildEnvVarsForProxy(t *testing.T) { }, { name: "with header forward secrets", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "header-forward-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - HeaderForward: &mcpv1alpha1.HeaderForwardConfig{ - AddHeadersFromSecret: []mcpv1alpha1.HeaderFromSecret{ + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ + AddHeadersFromSecret: []mcpv1beta1.HeaderFromSecret{ { HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "api-key-secret", Key: "api-key", }, }, { HeaderName: "Authorization", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "auth-secret", Key: "token", }, @@ -1035,27 +1035,27 @@ func TestBuildEnvVarsForProxy(t *testing.T) { }, { name: "with bearer token", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "bearer-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "bearer-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "bearer-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeBearerToken, - BearerToken: &mcpv1alpha1.BearerTokenConfig{ - TokenSecretRef: &mcpv1alpha1.SecretKeyRef{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeBearerToken, + BearerToken: &mcpv1beta1.BearerTokenConfig{ + TokenSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "bearer-secret", Key: "token", }, @@ -1124,12 +1124,12 @@ func TestBuildEnvVarsForProxy(t *testing.T) { func TestMCPRemoteProxyServiceNeedsUpdate(t *testing.T) { t.Parallel() - baseProxy := &mcpv1alpha1.MCPRemoteProxy{ + baseProxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, }, @@ -1153,7 +1153,7 @@ func TestMCPRemoteProxyServiceNeedsUpdate(t *testing.T) { tests := []struct { name string service *corev1.Service - proxy *mcpv1alpha1.MCPRemoteProxy + proxy *mcpv1beta1.MCPRemoteProxy needsUpdate bool }{ { @@ -1175,7 +1175,7 @@ func TestMCPRemoteProxyServiceNeedsUpdate(t *testing.T) { { name: "session affinity spec changed to None", service: baseService.DeepCopy(), - proxy: func() *mcpv1alpha1.MCPRemoteProxy { + proxy: func() *mcpv1beta1.MCPRemoteProxy { p := baseProxy.DeepCopy() p.Spec.SessionAffinity = string(corev1.ServiceAffinityNone) return p @@ -1189,7 +1189,7 @@ func TestMCPRemoteProxyServiceNeedsUpdate(t *testing.T) { s.Spec.SessionAffinity = corev1.ServiceAffinityNone return s }(), - proxy: func() *mcpv1alpha1.MCPRemoteProxy { + proxy: func() *mcpv1beta1.MCPRemoteProxy { p := baseProxy.DeepCopy() p.Spec.SessionAffinity = string(corev1.ServiceAffinityNone) return p diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go index 81720a9fdd..32b4e993d3 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go @@ -33,7 +33,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/kubernetes/rbac" ) @@ -44,26 +44,26 @@ func TestMCPRemoteProxyFullReconciliation(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy - toolConfig *mcpv1alpha1.MCPToolConfig - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + proxy *mcpv1beta1.MCPRemoteProxy + toolConfig *mcpv1beta1.MCPToolConfig + externalAuth *mcpv1beta1.MCPExternalAuthConfig secret *corev1.Secret - validateResult func(*testing.T, *mcpv1alpha1.MCPRemoteProxy, client.Client) + validateResult func(*testing.T, *mcpv1beta1.MCPRemoteProxy, client.Client) }{ { name: "basic proxy with inline OIDC", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.salesforce.com", ProxyPort: 8080, Transport: "streamable-http", }, }, - validateResult: func(t *testing.T, proxy *mcpv1alpha1.MCPRemoteProxy, c client.Client) { + validateResult: func(t *testing.T, proxy *mcpv1beta1.MCPRemoteProxy, c client.Client) { t.Helper() // Verify ServiceAccount created @@ -118,64 +118,64 @@ func TestMCPRemoteProxyFullReconciliation(t *testing.T) { }, { name: "proxy with all features", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "full-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 9090, Transport: "sse", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "token-exchange", }, - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "tool-filter", }, - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{ `permit(principal, action == Action::"tools/list", resource);`, }, }, }, - Audit: &mcpv1alpha1.AuditConfig{ + Audit: &mcpv1beta1.AuditConfig{ Enabled: true, }, }, }, - toolConfig: &mcpv1alpha1.MCPToolConfig{ + toolConfig: &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "tool-filter", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1", "tool2"}, }, - Status: mcpv1alpha1.MCPToolConfigStatus{ + Status: mcpv1beta1.MCPToolConfigStatus{ ConfigHash: "hash123", }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "token-exchange", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "oauth-secret", Key: "client-secret", }, Audience: "api", }, }, - Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ ConfigHash: "hash456", }, }, @@ -188,7 +188,7 @@ func TestMCPRemoteProxyFullReconciliation(t *testing.T) { "client-secret": []byte("secret-value"), }, }, - validateResult: func(t *testing.T, proxy *mcpv1alpha1.MCPRemoteProxy, c client.Client) { + validateResult: func(t *testing.T, proxy *mcpv1beta1.MCPRemoteProxy, c client.Client) { t.Helper() // Verify all resources created @@ -208,7 +208,7 @@ func TestMCPRemoteProxyFullReconciliation(t *testing.T) { assert.NoError(t, err) // Fetch updated proxy and verify status hashes - updatedProxy := &mcpv1alpha1.MCPRemoteProxy{} + updatedProxy := &mcpv1beta1.MCPRemoteProxy{} err = c.Get(context.TODO(), types.NamespacedName{ Name: proxy.Name, Namespace: proxy.Namespace, @@ -220,30 +220,30 @@ func TestMCPRemoteProxyFullReconciliation(t *testing.T) { }, { name: "proxy with validation failure", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "invalid-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "non-existent", }, }, }, - validateResult: func(t *testing.T, proxy *mcpv1alpha1.MCPRemoteProxy, c client.Client) { + validateResult: func(t *testing.T, proxy *mcpv1beta1.MCPRemoteProxy, c client.Client) { t.Helper() // Fetch updated proxy and verify status shows failure - updatedProxy := &mcpv1alpha1.MCPRemoteProxy{} + updatedProxy := &mcpv1beta1.MCPRemoteProxy{} err := c.Get(context.TODO(), types.NamespacedName{ Name: proxy.Name, Namespace: proxy.Namespace, }, updatedProxy) require.NoError(t, err) - assert.Equal(t, mcpv1alpha1.MCPRemoteProxyPhaseFailed, updatedProxy.Status.Phase) + assert.Equal(t, mcpv1beta1.MCPRemoteProxyPhaseFailed, updatedProxy.Status.Phase) assert.Contains(t, updatedProxy.Status.Message, "Validation failed") }, }, @@ -271,7 +271,7 @@ func TestMCPRemoteProxyFullReconciliation(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(objects...). - WithStatusSubresource(&mcpv1alpha1.MCPRemoteProxy{}). + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}). Build() reconciler := &MCPRemoteProxyReconciler{ @@ -315,28 +315,28 @@ func TestMCPRemoteProxyFullReconciliation(t *testing.T) { func TestMCPRemoteProxyConfigChangePropagation(t *testing.T) { t.Parallel() - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "dynamic-tools", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1"}, }, - Status: mcpv1alpha1.MCPToolConfigStatus{ + Status: mcpv1beta1.MCPToolConfigStatus{ ConfigHash: "initial-hash", }, } - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "config-watch-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "dynamic-tools", }, }, @@ -349,7 +349,7 @@ func TestMCPRemoteProxyConfigChangePropagation(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(proxy, toolConfig). - WithStatusSubresource(&mcpv1alpha1.MCPRemoteProxy{}, &mcpv1alpha1.MCPToolConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}, &mcpv1beta1.MCPToolConfig{}). Build() reconciler := &MCPRemoteProxyReconciler{ @@ -372,7 +372,7 @@ func TestMCPRemoteProxyConfigChangePropagation(t *testing.T) { require.NoError(t, err) // Verify initial hash stored - updatedProxy := &mcpv1alpha1.MCPRemoteProxy{} + updatedProxy := &mcpv1beta1.MCPRemoteProxy{} err = fakeClient.Get(ctx, types.NamespacedName{ Name: proxy.Name, Namespace: proxy.Namespace, @@ -402,12 +402,12 @@ func TestMCPRemoteProxyConfigChangePropagation(t *testing.T) { func TestMCPRemoteProxyStatusProgression(t *testing.T) { t.Parallel() - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "status-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, }, @@ -420,7 +420,7 @@ func TestMCPRemoteProxyStatusProgression(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(proxy). - WithStatusSubresource(&mcpv1alpha1.MCPRemoteProxy{}). + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}). Build() reconciler := &MCPRemoteProxyReconciler{ @@ -442,13 +442,13 @@ func TestMCPRemoteProxyStatusProgression(t *testing.T) { _, err := reconciler.Reconcile(ctx, req) require.NoError(t, err) - updatedProxy := &mcpv1alpha1.MCPRemoteProxy{} + updatedProxy := &mcpv1beta1.MCPRemoteProxy{} err = fakeClient.Get(ctx, types.NamespacedName{ Name: proxy.Name, Namespace: proxy.Namespace, }, updatedProxy) require.NoError(t, err) - assert.Equal(t, mcpv1alpha1.MCPRemoteProxyPhasePending, updatedProxy.Status.Phase) + assert.Equal(t, mcpv1beta1.MCPRemoteProxyPhasePending, updatedProxy.Status.Phase) assert.Contains(t, updatedProxy.Status.Message, "No pods") // Add a running pod @@ -474,7 +474,7 @@ func TestMCPRemoteProxyStatusProgression(t *testing.T) { Namespace: proxy.Namespace, }, updatedProxy) require.NoError(t, err) - assert.Equal(t, mcpv1alpha1.MCPRemoteProxyPhaseReady, updatedProxy.Status.Phase) + assert.Equal(t, mcpv1beta1.MCPRemoteProxyPhaseReady, updatedProxy.Status.Phase) assert.Contains(t, updatedProxy.Status.Message, "running") // Verify status URL was set @@ -490,13 +490,13 @@ func TestCommonHelpers(t *testing.T) { t.Run("GetExternalAuthConfigByName", func(t *testing.T) { t.Parallel() - externalAuth := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, }, } @@ -515,9 +515,9 @@ func TestCommonHelpers(t *testing.T) { t.Run("GenerateAuthzVolumeConfig - ConfigMap", func(t *testing.T) { t.Parallel() - authzConfig := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + authzConfig := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "authz-cm", Key: "policies.json", }, @@ -534,9 +534,9 @@ func TestCommonHelpers(t *testing.T) { t.Run("GenerateAuthzVolumeConfig - Inline", func(t *testing.T) { t.Parallel() - authzConfig := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + authzConfig := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{"permit(principal, action, resource);"}, }, } @@ -552,19 +552,19 @@ func TestCommonHelpers(t *testing.T) { func TestEnsureAuthzConfigMapShared(t *testing.T) { t.Parallel() - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "authz-test-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", }, } - authzConfig := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + authzConfig := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{ `permit(principal, action == Action::"tools/list", resource);`, }, @@ -608,13 +608,13 @@ func TestEnsureAuthzConfigMapShared(t *testing.T) { func TestRBACClientIntegration(t *testing.T) { t.Parallel() - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "rbac-test-proxy", Namespace: "default", UID: "test-uid", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", }, } @@ -677,17 +677,17 @@ func TestRBACClientIntegration(t *testing.T) { func TestGenerateTokenExchangeEnvVarsShared(t *testing.T) { t.Parallel() - externalAuth := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-exchange", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.com/token", ClientID: "client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "secret", Key: "key", }, @@ -702,7 +702,7 @@ func TestGenerateTokenExchangeEnvVarsShared(t *testing.T) { WithRuntimeObjects(externalAuth). Build() - ref := &mcpv1alpha1.ExternalAuthConfigRef{ + ref := &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-exchange", } @@ -728,7 +728,7 @@ func TestValidateSpecConfigurationConditions(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy + proxy *mcpv1beta1.MCPRemoteProxy existingObjects []runtime.Object expectError bool errContains string @@ -737,25 +737,25 @@ func TestValidateSpecConfigurationConditions(t *testing.T) { }{ { name: "valid proxy with no OIDC config", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "no-oidc-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", }, }, expectError: false, - expectCondition: mcpv1alpha1.ConditionReasonConfigurationValid, + expectCondition: mcpv1beta1.ConditionReasonConfigurationValid, conditionStatus: metav1.ConditionTrue, }, { name: "invalid Cedar policy syntax is rejected", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "invalid-cedar-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{"not valid cedar"}, }, }, @@ -763,18 +763,18 @@ func TestValidateSpecConfigurationConditions(t *testing.T) { }, expectError: true, errContains: "invalid syntax", - expectCondition: mcpv1alpha1.ConditionReasonAuthzPolicySyntaxInvalid, + expectCondition: mcpv1beta1.ConditionReasonAuthzPolicySyntaxInvalid, conditionStatus: metav1.ConditionFalse, }, { name: "referenced authz ConfigMap not found is rejected", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "missing-configmap-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "does-not-exist", }, }, @@ -782,20 +782,20 @@ func TestValidateSpecConfigurationConditions(t *testing.T) { }, expectError: true, errContains: "not found", - expectCondition: mcpv1alpha1.ConditionReasonAuthzConfigMapNotFound, + expectCondition: mcpv1beta1.ConditionReasonAuthzConfigMapNotFound, conditionStatus: metav1.ConditionFalse, }, { name: "referenced header secret not found is rejected", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "missing-header-secret-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", - HeaderForward: &mcpv1alpha1.HeaderForwardConfig{ - AddHeadersFromSecret: []mcpv1alpha1.HeaderFromSecret{ + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ + AddHeadersFromSecret: []mcpv1beta1.HeaderFromSecret{ { HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "missing-secret", Key: "api-key", }, @@ -806,20 +806,20 @@ func TestValidateSpecConfigurationConditions(t *testing.T) { }, expectError: true, errContains: "not found", - expectCondition: mcpv1alpha1.ConditionReasonHeaderSecretNotFound, + expectCondition: mcpv1beta1.ConditionReasonHeaderSecretNotFound, conditionStatus: metav1.ConditionFalse, }, { name: "malformed remote URL is rejected", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "bad-scheme-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "ftp://bad-scheme.example.com", }, }, expectError: true, errContains: "scheme", - expectCondition: mcpv1alpha1.ConditionReasonRemoteURLInvalid, + expectCondition: mcpv1beta1.ConditionReasonRemoteURLInvalid, conditionStatus: metav1.ConditionFalse, }, } @@ -834,7 +834,7 @@ func TestValidateSpecConfigurationConditions(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(objects...). - WithStatusSubresource(&mcpv1alpha1.MCPRemoteProxy{}). + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}). Build() fakeRecorder := events.NewFakeRecorder(10) @@ -856,7 +856,7 @@ func TestValidateSpecConfigurationConditions(t *testing.T) { } // Verify the ConfigurationValid condition was set - cond := meta.FindStatusCondition(tt.proxy.Status.Conditions, mcpv1alpha1.ConditionTypeConfigurationValid) + cond := meta.FindStatusCondition(tt.proxy.Status.Conditions, mcpv1beta1.ConditionTypeConfigurationValid) require.NotNil(t, cond, "ConfigurationValid condition should be set") assert.Equal(t, tt.conditionStatus, cond.Status) assert.Equal(t, tt.expectCondition, cond.Reason) @@ -880,36 +880,36 @@ func TestValidateAndHandleConfigs(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy - toolConfig *mcpv1alpha1.MCPToolConfig - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + proxy *mcpv1beta1.MCPRemoteProxy + toolConfig *mcpv1beta1.MCPToolConfig + externalAuth *mcpv1beta1.MCPExternalAuthConfig expectError bool - expectPhase mcpv1alpha1.MCPRemoteProxyPhase + expectPhase mcpv1beta1.MCPRemoteProxyPhase }{ { name: "valid configs", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "valid-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "valid-tools", }, }, }, - toolConfig: &mcpv1alpha1.MCPToolConfig{ + toolConfig: &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "valid-tools", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1"}, }, - Status: mcpv1alpha1.MCPToolConfigStatus{ + Status: mcpv1beta1.MCPToolConfigStatus{ ConfigHash: "hash", }, }, @@ -917,21 +917,21 @@ func TestValidateAndHandleConfigs(t *testing.T) { }, { name: "missing tool config", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "missing-tool-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "non-existent", }, }, }, expectError: true, - expectPhase: mcpv1alpha1.MCPRemoteProxyPhaseFailed, + expectPhase: mcpv1beta1.MCPRemoteProxyPhaseFailed, }, } @@ -951,7 +951,7 @@ func TestValidateAndHandleConfigs(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(objects...). - WithStatusSubresource(&mcpv1alpha1.MCPRemoteProxy{}). + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}). Build() reconciler := &MCPRemoteProxyReconciler{ @@ -966,7 +966,7 @@ func TestValidateAndHandleConfigs(t *testing.T) { assert.Error(t, err) // Verify status was updated - updatedProxy := &mcpv1alpha1.MCPRemoteProxy{} + updatedProxy := &mcpv1beta1.MCPRemoteProxy{} getErr := fakeClient.Get(context.TODO(), types.NamespacedName{ Name: tt.proxy.Name, Namespace: tt.proxy.Namespace, diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go b/cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go index 3e6cb5e876..65cf8cb81f 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go @@ -13,7 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/kubernetes/configmaps" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" @@ -24,7 +24,7 @@ import ( ) // ensureRunConfigConfigMap ensures the RunConfig ConfigMap exists and is up to date for MCPRemoteProxy -func (r *MCPRemoteProxyReconciler) ensureRunConfigConfigMap(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error { +func (r *MCPRemoteProxyReconciler) ensureRunConfigConfigMap(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { runConfig, err := r.createRunConfigFromMCPRemoteProxy(ctx, proxy) if err != nil { return fmt.Errorf("failed to create RunConfig from MCPRemoteProxy: %w", err) @@ -72,7 +72,7 @@ func (r *MCPRemoteProxyReconciler) ensureRunConfigConfigMap(ctx context.Context, // Key difference from MCPServer: Sets RemoteURL instead of Image, and Deployer remains nil func (r *MCPRemoteProxyReconciler) createRunConfigFromMCPRemoteProxy( ctx context.Context, - proxy *mcpv1alpha1.MCPRemoteProxy, + proxy *mcpv1beta1.MCPRemoteProxy, ) (*runner.RunConfig, error) { proxyHost := defaultProxyHost if envHost := os.Getenv("TOOLHIVE_PROXY_HOST"); envHost != "" { @@ -180,7 +180,7 @@ func (r *MCPRemoteProxyReconciler) createRunConfigFromMCPRemoteProxy( // adds the appropriate runner options, and returns the resolved config. func (r *MCPRemoteProxyReconciler) resolveAndAddOIDCConfig( ctx context.Context, - proxy *mcpv1alpha1.MCPRemoteProxy, + proxy *mcpv1beta1.MCPRemoteProxy, options *[]runner.RunConfigBuilderOption, ) (*oidc.OIDCConfig, error) { if proxy.Spec.OIDCConfigRef == nil { @@ -270,7 +270,7 @@ func labelsForRunConfigRemoteProxy(proxyName string) map[string]string { // addHeaderForwardConfigOptions adds header forward configuration options to the builder options slice. // This handles both plaintext headers (stored directly in RunConfig) and secret-backed headers // (which are mounted as env vars and referenced by identifier in RunConfig). -func addHeaderForwardConfigOptions(proxy *mcpv1alpha1.MCPRemoteProxy, options *[]runner.RunConfigBuilderOption) { +func addHeaderForwardConfigOptions(proxy *mcpv1beta1.MCPRemoteProxy, options *[]runner.RunConfigBuilderOption) { if proxy.Spec.HeaderForward == nil { return } @@ -301,7 +301,7 @@ func addHeaderForwardConfigOptions(proxy *mcpv1alpha1.MCPRemoteProxy, options *[ // resolveToolConfig fetches the MCPToolConfig referenced by the proxy and // returns the tools filter and override map. func (r *MCPRemoteProxyReconciler) resolveToolConfig( - proxy *mcpv1alpha1.MCPRemoteProxy, + proxy *mcpv1beta1.MCPRemoteProxy, ) ([]string, map[string]runner.ToolOverride, error) { if proxy.Spec.ToolConfigRef == nil { return nil, nil, nil @@ -332,7 +332,7 @@ func (r *MCPRemoteProxyReconciler) resolveToolConfig( // addTelemetryOptions resolves telemetry configuration for the RunConfig. func (r *MCPRemoteProxyReconciler) addTelemetryOptions( ctx context.Context, - proxy *mcpv1alpha1.MCPRemoteProxy, + proxy *mcpv1beta1.MCPRemoteProxy, options *[]runner.RunConfigBuilderOption, ) error { if proxy.Spec.TelemetryConfigRef != nil { diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go index 388fada8e7..40ee973f4b 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go @@ -28,7 +28,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/authz" "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" "github.com/stacklok/toolhive/pkg/runner" @@ -40,19 +40,19 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { t.Parallel() tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy - toolConfig *mcpv1alpha1.MCPToolConfig + proxy *mcpv1beta1.MCPRemoteProxy + toolConfig *mcpv1beta1.MCPToolConfig expectError bool validate func(*testing.T, *runner.RunConfig) }{ { name: "basic remote proxy", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "salesforce-proxy", Namespace: "mcp-proxies", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.salesforce.com", ProxyPort: 8080, }, @@ -70,27 +70,27 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { }, { name: "with tool filtering", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "filtered-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "filter-config", }, }, }, - toolConfig: &mcpv1alpha1.MCPToolConfig{ + toolConfig: &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "filter-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"read_data", "list_resources"}, - ToolsOverride: map[string]mcpv1alpha1.ToolOverride{ + ToolsOverride: map[string]mcpv1beta1.ToolOverride{ "read_data": { Name: "read-customer-data", Description: "Read customer data from Salesforce", @@ -111,17 +111,17 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { }, { name: "with inline authorization", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "authz-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{ `permit(principal, action == Action::"tools/list", resource);`, `forbid(principal, action == Action::"tools/call", resource) when { resource.tool == "delete_resource" };`, @@ -146,12 +146,12 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { }, { name: "with trust proxy headers", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "trust-headers-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, TrustProxyHeaders: true, @@ -166,15 +166,15 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { }, { name: "with header forward plaintext only", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "plaintext-headers-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - HeaderForward: &mcpv1alpha1.HeaderForwardConfig{ + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ AddPlaintextHeaders: map[string]string{ "X-Tenant-ID": "tenant-123", "X-Correlation": "corr-abc", @@ -194,26 +194,26 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { }, { name: "with header forward secrets", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "secret-headers-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - HeaderForward: &mcpv1alpha1.HeaderForwardConfig{ - AddHeadersFromSecret: []mcpv1alpha1.HeaderFromSecret{ + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ + AddHeadersFromSecret: []mcpv1beta1.HeaderFromSecret{ { HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "api-secret", Key: "key", }, }, { HeaderName: "Authorization", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "auth-secret", Key: "token", }, @@ -236,22 +236,22 @@ func TestCreateRunConfigFromMCPRemoteProxy(t *testing.T) { }, { name: "with header forward mixed plaintext and secrets", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "mixed-headers-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - HeaderForward: &mcpv1alpha1.HeaderForwardConfig{ + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ AddPlaintextHeaders: map[string]string{ "X-Tenant-ID": "tenant-456", }, - AddHeadersFromSecret: []mcpv1alpha1.HeaderFromSecret{ + AddHeadersFromSecret: []mcpv1beta1.HeaderFromSecret{ { HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "api-secret", Key: "key", }, @@ -315,38 +315,38 @@ func TestCreateRunConfigFromMCPRemoteProxy_WithTokenExchange(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + proxy *mcpv1beta1.MCPRemoteProxy + externalAuth *mcpv1beta1.MCPExternalAuthConfig clientSecret *corev1.Secret expectError bool validate func(*testing.T, *runner.RunConfig) }{ { name: "with token exchange", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "exchange-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.salesforce.com", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "salesforce-exchange", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "salesforce-exchange", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://keycloak.company.com/token", ClientID: "exchange-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "exchange-creds", Key: "client-secret", }, @@ -392,15 +392,15 @@ func TestCreateRunConfigFromMCPRemoteProxy_WithTokenExchange(t *testing.T) { }, { name: "external auth config not found", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "broken-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "non-existent", }, }, @@ -453,36 +453,36 @@ func TestCreateRunConfigFromMCPRemoteProxy_WithBearerToken(t *testing.T) { tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + proxy *mcpv1beta1.MCPRemoteProxy + externalAuth *mcpv1beta1.MCPExternalAuthConfig bearerSecret *corev1.Secret expectError bool validate func(*testing.T, *runner.RunConfig) }{ { name: "with bearer token", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "bearer-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com/api", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "api-bearer-auth", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "api-bearer-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeBearerToken, - BearerToken: &mcpv1alpha1.BearerTokenConfig{ - TokenSecretRef: &mcpv1alpha1.SecretKeyRef{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeBearerToken, + BearerToken: &mcpv1beta1.BearerTokenConfig{ + TokenSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "api-bearer-token", Key: "token", }, @@ -511,27 +511,27 @@ func TestCreateRunConfigFromMCPRemoteProxy_WithBearerToken(t *testing.T) { }, { name: "missing TokenSecretRef", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "broken-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "broken-bearer", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "broken-bearer", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeBearerToken, - BearerToken: &mcpv1alpha1.BearerTokenConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeBearerToken, + BearerToken: &mcpv1beta1.BearerTokenConfig{ TokenSecretRef: nil, // Missing TokenSecretRef }, }, @@ -540,28 +540,28 @@ func TestCreateRunConfigFromMCPRemoteProxy_WithBearerToken(t *testing.T) { }, { name: "secret not found", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "missing-secret-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "missing-secret-bearer", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "missing-secret-bearer", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeBearerToken, - BearerToken: &mcpv1alpha1.BearerTokenConfig{ - TokenSecretRef: &mcpv1alpha1.SecretKeyRef{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeBearerToken, + BearerToken: &mcpv1beta1.BearerTokenConfig{ + TokenSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "non-existent-secret", Key: "token", }, @@ -572,28 +572,28 @@ func TestCreateRunConfigFromMCPRemoteProxy_WithBearerToken(t *testing.T) { }, { name: "secret missing key", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "missing-key-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "missing-key-bearer", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "missing-key-bearer", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeBearerToken, - BearerToken: &mcpv1alpha1.BearerTokenConfig{ - TokenSecretRef: &mcpv1alpha1.SecretKeyRef{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeBearerToken, + BearerToken: &mcpv1beta1.BearerTokenConfig{ + TokenSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "incomplete-secret", Key: "token", }, @@ -770,19 +770,19 @@ func TestEnsureRunConfigConfigMapForRemoteProxy(t *testing.T) { t.Parallel() tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy + proxy *mcpv1beta1.MCPRemoteProxy existingCM *corev1.ConfigMap expectError bool validateContent func(*testing.T, *corev1.ConfigMap) }{ { name: "create new configmap for remote proxy", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "new-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://mcp.example.com", ProxyPort: 8080, }, diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_telemetryconfig_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_telemetryconfig_test.go index da4a3ee4ee..f3817386cc 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_telemetryconfig_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_telemetryconfig_test.go @@ -13,19 +13,19 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestHandleTelemetryConfig_MCPRemoteProxy(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig + proxy *mcpv1beta1.MCPRemoteProxy + telemetryConfig *mcpv1beta1.MCPTelemetryConfig expectError bool expectedHash string expectedCondType string @@ -36,10 +36,10 @@ func TestHandleTelemetryConfig_MCPRemoteProxy(t *testing.T) { }{ { name: "nil ref clears hash and removes condition", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "test-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{TelemetryConfigRef: nil}, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ + Spec: mcpv1beta1.MCPRemoteProxySpec{TelemetryConfigRef: nil}, + Status: mcpv1beta1.MCPRemoteProxyStatus{ TelemetryConfigHash: "old-hash", }, }, @@ -49,129 +49,129 @@ func TestHandleTelemetryConfig_MCPRemoteProxy(t *testing.T) { }, { name: "valid ref sets condition true and updates hash", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "test-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "my-telemetry"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "my-telemetry"}, }, }, - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{Name: "my-telemetry", Namespace: "default"}, Spec: newTelemetrySpec("https://otel-collector:4317", true, false), - Status: mcpv1alpha1.MCPTelemetryConfigStatus{ + Status: mcpv1beta1.MCPTelemetryConfigStatus{ ConfigHash: "abc123", }, }, expectError: false, expectedHash: "abc123", - expectedCondType: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, + expectedCondType: mcpv1beta1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, expectedCondStatus: metav1.ConditionTrue, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefValid, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyTelemetryConfigRefValid, }, { name: "not found sets condition false", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "test-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "missing"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "missing"}, }, }, expectError: true, - expectedCondType: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, + expectedCondType: mcpv1beta1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, expectedCondStatus: metav1.ConditionFalse, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefNotFound, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyTelemetryConfigRefNotFound, }, { name: "invalid config sets condition false", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "test-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "invalid-telemetry"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "invalid-telemetry"}, }, }, // Spec with endpoint but no tracing/metrics enabled → Validate() fails - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{Name: "invalid-telemetry", Namespace: "default"}, - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "https://otel-collector:4317", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: false}, - Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{Enabled: false}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: false}, + Metrics: &mcpv1beta1.OpenTelemetryMetricsConfig{Enabled: false}, }, }, }, expectError: true, - expectedCondType: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, + expectedCondType: mcpv1beta1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, expectedCondStatus: metav1.ConditionFalse, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefInvalid, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyTelemetryConfigRefInvalid, }, { name: "hash change triggers update", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "test-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "my-telemetry"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "my-telemetry"}, }, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ + Status: mcpv1beta1.MCPRemoteProxyStatus{ TelemetryConfigHash: "old-hash", }, }, - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{Name: "my-telemetry", Namespace: "default"}, Spec: newTelemetrySpec("https://otel-collector:4317", true, false), - Status: mcpv1alpha1.MCPTelemetryConfigStatus{ + Status: mcpv1beta1.MCPTelemetryConfigStatus{ ConfigHash: "new-hash", }, }, expectError: false, expectedHash: "new-hash", - expectedCondType: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, + expectedCondType: mcpv1beta1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, expectedCondStatus: metav1.ConditionTrue, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefValid, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyTelemetryConfigRefValid, }, { name: "recovery from False condition persists True", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "test-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "my-telemetry"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "my-telemetry"}, }, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ + Status: mcpv1beta1.MCPRemoteProxyStatus{ TelemetryConfigHash: "abc123", Conditions: []metav1.Condition{ { - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefFetchError, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyTelemetryConfigRefFetchError, }, }, }, }, - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{Name: "my-telemetry", Namespace: "default"}, Spec: newTelemetrySpec("https://otel-collector:4317", true, false), - Status: mcpv1alpha1.MCPTelemetryConfigStatus{ + Status: mcpv1beta1.MCPTelemetryConfigStatus{ ConfigHash: "abc123", }, }, expectError: false, expectedHash: "abc123", - expectedCondType: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, + expectedCondType: mcpv1beta1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, expectedCondStatus: metav1.ConditionTrue, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefValid, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyTelemetryConfigRefValid, }, { name: "nil ref with stale condition persists removal", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "test-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{TelemetryConfigRef: nil}, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ + Spec: mcpv1beta1.MCPRemoteProxySpec{TelemetryConfigRef: nil}, + Status: mcpv1beta1.MCPRemoteProxyStatus{ Conditions: []metav1.Condition{ { - Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefNotFound, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyTelemetryConfigRefNotFound, }, }, }, @@ -191,7 +191,7 @@ func TestHandleTelemetryConfig_MCPRemoteProxy(t *testing.T) { if tt.telemetryConfig != nil { builder = builder.WithObjects(tt.telemetryConfig) } - builder = builder.WithStatusSubresource(&mcpv1alpha1.MCPRemoteProxy{}) + builder = builder.WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}) builder = builder.WithObjects(tt.proxy) fakeClient := builder.Build() @@ -213,7 +213,7 @@ func TestHandleTelemetryConfig_MCPRemoteProxy(t *testing.T) { // For error paths, conditions are set in-memory but the caller // (validateAndHandleConfigs) is responsible for persisting — so // we use in-memory state for error-path condition assertions. - persisted := &mcpv1alpha1.MCPRemoteProxy{} + persisted := &mcpv1beta1.MCPRemoteProxy{} require.NoError(t, fakeClient.Get(ctx, types.NamespacedName{ Name: tt.proxy.Name, Namespace: tt.proxy.Namespace, }, persisted)) @@ -227,7 +227,7 @@ func TestHandleTelemetryConfig_MCPRemoteProxy(t *testing.T) { if tt.expectNoCondition { for _, c := range persisted.Status.Conditions { - assert.NotEqual(t, mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, c.Type, + assert.NotEqual(t, mcpv1beta1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated, c.Type, "condition should have been removed from persisted state") } } @@ -260,23 +260,23 @@ func TestMapTelemetryConfigToMCPRemoteProxy(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - proxy1 := &mcpv1alpha1.MCPRemoteProxy{ + proxy1 := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "proxy1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "shared-telemetry"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "shared-telemetry"}, }, } - proxy2 := &mcpv1alpha1.MCPRemoteProxy{ + proxy2 := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "proxy2", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "other-telemetry"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "other-telemetry"}, }, } - proxy3 := &mcpv1alpha1.MCPRemoteProxy{ + proxy3 := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "proxy3", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{}, // no ref + Spec: mcpv1beta1.MCPRemoteProxySpec{}, // no ref } fakeClient := fake.NewClientBuilder(). @@ -291,7 +291,7 @@ func TestMapTelemetryConfigToMCPRemoteProxy(t *testing.T) { ctx := t.Context() - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{Name: "shared-telemetry", Namespace: "default"}, } diff --git a/cmd/thv-operator/controllers/mcpserver_authserverref_test.go b/cmd/thv-operator/controllers/mcpserver_authserverref_test.go index 6bf883f638..b154dcb287 100644 --- a/cmd/thv-operator/controllers/mcpserver_authserverref_test.go +++ b/cmd/thv-operator/controllers/mcpserver_authserverref_test.go @@ -16,7 +16,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/container/kubernetes" ) @@ -25,8 +25,8 @@ func TestMCPServerReconciler_handleAuthServerRef(t *testing.T) { tests := []struct { name string - mcpServer func() *mcpv1alpha1.MCPServer - authConfig func() *mcpv1alpha1.MCPExternalAuthConfig + mcpServer func() *mcpv1beta1.MCPServer + authConfig func() *mcpv1beta1.MCPExternalAuthConfig expectError bool errContains string expectHash string @@ -35,11 +35,11 @@ func TestMCPServerReconciler_handleAuthServerRef(t *testing.T) { }{ { name: "nil authServerRef removes condition and clears hash", - mcpServer: func() *mcpv1alpha1.MCPServer { - return &mcpv1alpha1.MCPServer{ + mcpServer: func() *mcpv1beta1.MCPServer { + return &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "server", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test"}, - Status: mcpv1alpha1.MCPServerStatus{ + Spec: mcpv1beta1.MCPServerSpec{Image: "test"}, + Status: mcpv1beta1.MCPServerStatus{ AuthServerConfigHash: "old-hash", }, } @@ -48,53 +48,53 @@ func TestMCPServerReconciler_handleAuthServerRef(t *testing.T) { }, { name: "unsupported kind sets InvalidKind condition", - mcpServer: func() *mcpv1alpha1.MCPServer { - return &mcpv1alpha1.MCPServer{ + mcpServer: func() *mcpv1beta1.MCPServer { + return &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "server", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test", - AuthServerRef: &mcpv1alpha1.AuthServerRef{Kind: "Secret", Name: "foo"}, + AuthServerRef: &mcpv1beta1.AuthServerRef{Kind: "Secret", Name: "foo"}, }, } }, expectError: true, errContains: "unsupported authServerRef kind", conditionStatus: metav1.ConditionFalse, - conditionReason: mcpv1alpha1.ConditionReasonAuthServerRefInvalidKind, + conditionReason: mcpv1beta1.ConditionReasonAuthServerRefInvalidKind, }, { name: "not found sets NotFound condition", - mcpServer: func() *mcpv1alpha1.MCPServer { - return &mcpv1alpha1.MCPServer{ + mcpServer: func() *mcpv1beta1.MCPServer { + return &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "server", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test", - AuthServerRef: &mcpv1alpha1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "missing"}, + AuthServerRef: &mcpv1beta1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "missing"}, }, } }, expectError: true, errContains: "not found", conditionStatus: metav1.ConditionFalse, - conditionReason: mcpv1alpha1.ConditionReasonAuthServerRefNotFound, + conditionReason: mcpv1beta1.ConditionReasonAuthServerRefNotFound, }, { name: "wrong type sets InvalidType condition", - mcpServer: func() *mcpv1alpha1.MCPServer { - return &mcpv1alpha1.MCPServer{ + mcpServer: func() *mcpv1beta1.MCPServer { + return &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "server", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test", - AuthServerRef: &mcpv1alpha1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "sts-config"}, + AuthServerRef: &mcpv1beta1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "sts-config"}, }, } }, - authConfig: func() *mcpv1alpha1.MCPExternalAuthConfig { - return &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: func() *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "sts-config", Namespace: "default"}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeAWSSts, - AWSSts: &mcpv1alpha1.AWSStsConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeAWSSts, + AWSSts: &mcpv1beta1.AWSStsConfig{ Region: "us-east-1", }, }, @@ -103,69 +103,69 @@ func TestMCPServerReconciler_handleAuthServerRef(t *testing.T) { expectError: true, errContains: "only embeddedAuthServer is supported", conditionStatus: metav1.ConditionFalse, - conditionReason: mcpv1alpha1.ConditionReasonAuthServerRefInvalidType, + conditionReason: mcpv1beta1.ConditionReasonAuthServerRefInvalidType, }, { name: "multi-upstream sets MultiUpstream condition", - mcpServer: func() *mcpv1alpha1.MCPServer { - return &mcpv1alpha1.MCPServer{ + mcpServer: func() *mcpv1beta1.MCPServer { + return &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "server", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test", - AuthServerRef: &mcpv1alpha1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "multi"}, + AuthServerRef: &mcpv1beta1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "multi"}, }, } }, - authConfig: func() *mcpv1alpha1.MCPExternalAuthConfig { - return &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: func() *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "multi", Namespace: "default"}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ - {Name: "a", Type: mcpv1alpha1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{IssuerURL: "https://a.com", ClientID: "a"}}, - {Name: "b", Type: mcpv1alpha1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{IssuerURL: "https://b.com", ClientID: "b"}}, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + {Name: "a", Type: mcpv1beta1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{IssuerURL: "https://a.com", ClientID: "a"}}, + {Name: "b", Type: mcpv1beta1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{IssuerURL: "https://b.com", ClientID: "b"}}, }, }, }, - Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ConfigHash: "multi-hash"}, + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ConfigHash: "multi-hash"}, } }, expectError: true, errContains: "only 1 is supported", conditionStatus: metav1.ConditionFalse, - conditionReason: mcpv1alpha1.ConditionReasonAuthServerRefMultiUpstream, + conditionReason: mcpv1beta1.ConditionReasonAuthServerRefMultiUpstream, }, { name: "valid ref sets Valid condition and updates hash", - mcpServer: func() *mcpv1alpha1.MCPServer { - return &mcpv1alpha1.MCPServer{ + mcpServer: func() *mcpv1beta1.MCPServer { + return &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "server", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test", - AuthServerRef: &mcpv1alpha1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "valid"}, + AuthServerRef: &mcpv1beta1.AuthServerRef{Kind: "MCPExternalAuthConfig", Name: "valid"}, }, } }, - authConfig: func() *mcpv1alpha1.MCPExternalAuthConfig { - return &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: func() *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "valid", Namespace: "default"}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", AuthorizationEndpointBaseURL: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{{Name: "key", Key: "pem"}}, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{{Name: "hmac", Key: "secret"}}, + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{{Name: "key", Key: "pem"}}, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{{Name: "hmac", Key: "secret"}}, }, }, - Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ConfigHash: "valid-hash"}, + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ConfigHash: "valid-hash"}, } }, expectHash: "valid-hash", conditionStatus: metav1.ConditionTrue, - conditionReason: mcpv1alpha1.ConditionReasonAuthServerRefValid, + conditionReason: mcpv1beta1.ConditionReasonAuthServerRefValid, }, } @@ -176,7 +176,7 @@ func TestMCPServerReconciler_handleAuthServerRef(t *testing.T) { defer cancel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) server := tt.mcpServer() @@ -188,7 +188,7 @@ func TestMCPServerReconciler_handleAuthServerRef(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) @@ -202,7 +202,7 @@ func TestMCPServerReconciler_handleAuthServerRef(t *testing.T) { assert.Equal(t, tt.expectHash, server.Status.AuthServerConfigHash) } - cond := meta.FindStatusCondition(server.Status.Conditions, mcpv1alpha1.ConditionTypeAuthServerRefValidated) + cond := meta.FindStatusCondition(server.Status.Conditions, mcpv1beta1.ConditionTypeAuthServerRefValidated) if tt.conditionStatus != "" { require.NotNil(t, cond, "AuthServerRefValidated condition should be present") assert.Equal(t, tt.conditionStatus, cond.Status) diff --git a/cmd/thv-operator/controllers/mcpserver_authz_test.go b/cmd/thv-operator/controllers/mcpserver_authz_test.go index 5d451b9552..6c6fd9ba28 100644 --- a/cmd/thv-operator/controllers/mcpserver_authz_test.go +++ b/cmd/thv-operator/controllers/mcpserver_authz_test.go @@ -16,7 +16,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/pkg/container/kubernetes" ) @@ -25,23 +25,23 @@ func TestEnsureAuthzConfigMap(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer expectConfigMap bool expectedConfigData string }{ { name: "no authz config", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "test-namespace", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", }, }, @@ -49,16 +49,16 @@ func TestEnsureAuthzConfigMap(t *testing.T) { }, { name: "configmap authz config (no inline ConfigMap needed)", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "test-namespace", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "external-authz-config", }, }, @@ -68,16 +68,16 @@ func TestEnsureAuthzConfigMap(t *testing.T) { }, { name: "inline authz config", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "test-namespace", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{ `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, @@ -91,16 +91,16 @@ func TestEnsureAuthzConfigMap(t *testing.T) { }, { name: "inline authz config with default entities", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "test-namespace", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{ `permit(principal, action, resource);`, }, @@ -163,21 +163,21 @@ func TestEnsureAuthzConfigMap_Updates(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // Create MCPServer with initial inline authz config - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "test-namespace", UID: "test-uid", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{ `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, @@ -250,23 +250,23 @@ func TestGenerateAuthzVolumeConfig(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer expectVolumeMount bool expectedConfigName string }{ { name: "no authz config", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "test-namespace", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", }, }, @@ -274,16 +274,16 @@ func TestGenerateAuthzVolumeConfig(t *testing.T) { }, { name: "configmap authz config", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "test-namespace", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "external-authz-config", Key: "custom-authz.json", }, @@ -295,16 +295,16 @@ func TestGenerateAuthzVolumeConfig(t *testing.T) { }, { name: "inline authz config", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "test-namespace", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{ `permit(principal, action, resource);`, }, diff --git a/cmd/thv-operator/controllers/mcpserver_controller.go b/cmd/thv-operator/controllers/mcpserver_controller.go index a3f955ada8..a89b46d588 100644 --- a/cmd/thv-operator/controllers/mcpserver_controller.go +++ b/cmd/thv-operator/controllers/mcpserver_controller.go @@ -33,7 +33,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/kubernetes/rbac" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" @@ -165,7 +165,7 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( ctxLogger := log.FromContext(ctx) // Fetch the MCPServer instance - mcpServer := &mcpv1alpha1.MCPServer{} + mcpServer := &mcpv1beta1.MCPServer{} err := r.Get(ctx, req.NamespacedName, mcpServer) if err != nil { if errors.IsNotFound(err) { @@ -237,8 +237,8 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( if err := r.handleToolConfig(ctx, mcpServer); err != nil { ctxLogger.Error(err, "Failed to handle MCPToolConfig") // Update status to reflect the error - mcpServer.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed - setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, err.Error()) + mcpServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed + setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, err.Error()) if statusErr := r.Status().Update(ctx, mcpServer); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPServer status after MCPToolConfig error") } @@ -248,8 +248,8 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Check if MCPTelemetryConfig is referenced and handle it if err := r.handleTelemetryConfig(ctx, mcpServer); err != nil { ctxLogger.Error(err, "Failed to handle MCPTelemetryConfig") - mcpServer.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed - setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, err.Error()) + mcpServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed + setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, err.Error()) if statusErr := r.Status().Update(ctx, mcpServer); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPServer status after MCPTelemetryConfig error") } @@ -260,8 +260,8 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( if err := r.handleExternalAuthConfig(ctx, mcpServer); err != nil { ctxLogger.Error(err, "Failed to handle MCPExternalAuthConfig") // Update status to reflect the error - mcpServer.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed - setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, err.Error()) + mcpServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed + setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, err.Error()) if statusErr := r.Status().Update(ctx, mcpServer); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPServer status after MCPExternalAuthConfig error") } @@ -271,8 +271,8 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Check if authServerRef is referenced and handle config hash tracking if err := r.handleAuthServerRef(ctx, mcpServer); err != nil { ctxLogger.Error(err, "Failed to handle authServerRef") - mcpServer.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed - setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, err.Error()) + mcpServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed + setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, err.Error()) if statusErr := r.Status().Update(ctx, mcpServer); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPServer status after authServerRef error") } @@ -282,8 +282,8 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Check if MCPOIDCConfig is referenced and handle it if err := r.handleOIDCConfig(ctx, mcpServer); err != nil { ctxLogger.Error(err, "Failed to handle MCPOIDCConfig") - mcpServer.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed - setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, err.Error()) + mcpServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed + setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, err.Error()) if statusErr := r.Status().Update(ctx, mcpServer); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPServer status after MCPOIDCConfig error") } @@ -299,9 +299,9 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // check if the RBAC resources are in place for the MCP server if err := r.ensureRBACResources(ctx, mcpServer); err != nil { ctxLogger.Error(err, "Failed to ensure RBAC resources") - mcpServer.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed + mcpServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed mcpServer.Status.Message = fmt.Sprintf("Failed to ensure RBAC resources: %s", err.Error()) - setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, mcpServer.Status.Message) + setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, mcpServer.Status.Message) if statusErr := r.Status().Update(ctx, mcpServer); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPServer status after RBAC error") } @@ -311,9 +311,9 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Ensure authorization ConfigMap for inline configuration if err := r.ensureAuthzConfigMap(ctx, mcpServer); err != nil { ctxLogger.Error(err, "Failed to ensure authorization ConfigMap") - mcpServer.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed + mcpServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed mcpServer.Status.Message = fmt.Sprintf("Failed to ensure authorization ConfigMap: %s", err.Error()) - setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, mcpServer.Status.Message) + setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, mcpServer.Status.Message) if statusErr := r.Status().Update(ctx, mcpServer); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPServer status after authz ConfigMap error") } @@ -323,9 +323,9 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Ensure RunConfig ConfigMap exists and is up to date if err := r.ensureRunConfigConfigMap(ctx, mcpServer); err != nil { ctxLogger.Error(err, "Failed to ensure RunConfig ConfigMap") - mcpServer.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed + mcpServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed mcpServer.Status.Message = fmt.Sprintf("Failed to build configuration: %s", err.Error()) - setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, mcpServer.Status.Message) + setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, mcpServer.Status.Message) if statusErr := r.Status().Update(ctx, mcpServer); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPServer status after RunConfig error") } @@ -343,9 +343,9 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{RequeueAfter: 5 * time.Second}, nil } ctxLogger.Error(err, "Failed to get RunConfig checksum") - mcpServer.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed + mcpServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed mcpServer.Status.Message = fmt.Sprintf("Failed to build configuration: %s", err.Error()) - setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, mcpServer.Status.Message) + setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, mcpServer.Status.Message) if statusErr := r.Status().Update(ctx, mcpServer); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPServer status after RunConfig checksum error") } @@ -361,9 +361,9 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( if dep == nil { ctxLogger.Error(nil, "Failed to create Deployment object") deploymentErr := fmt.Errorf("failed to create Deployment object") - mcpServer.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed + mcpServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed mcpServer.Status.Message = deploymentErr.Error() - setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, mcpServer.Status.Message) + setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, mcpServer.Status.Message) if statusErr := r.Status().Update(ctx, mcpServer); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPServer status after Deployment build failure") } @@ -373,9 +373,9 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( err = r.Create(ctx, dep) if err != nil { ctxLogger.Error(err, "Failed to create new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name) - mcpServer.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed + mcpServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed mcpServer.Status.Message = fmt.Sprintf("Failed to create Deployment: %s", err.Error()) - setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, mcpServer.Status.Message) + setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, mcpServer.Status.Message) if statusErr := r.Status().Update(ctx, mcpServer); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update MCPServer status after Deployment creation failure") } @@ -492,7 +492,7 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, nil } -func (r *MCPServerReconciler) validateGroupRef(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) { +func (r *MCPServerReconciler) validateGroupRef(ctx context.Context, mcpServer *mcpv1beta1.MCPServer) { if mcpServer.Spec.GroupRef == nil { // No group reference, nothing to validate return @@ -502,29 +502,29 @@ func (r *MCPServerReconciler) validateGroupRef(ctx context.Context, mcpServer *m groupName := mcpServer.Spec.GroupRef.Name // Find the referenced MCPGroup - group := &mcpv1alpha1.MCPGroup{} + group := &mcpv1beta1.MCPGroup{} if err := r.Get(ctx, types.NamespacedName{Namespace: mcpServer.Namespace, Name: groupName}, group); err != nil { ctxLogger.Error(err, "Failed to validate GroupRef") meta.SetStatusCondition(&mcpServer.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionGroupRefValidated, + Type: mcpv1beta1.ConditionGroupRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonGroupRefNotFound, + Reason: mcpv1beta1.ConditionReasonGroupRefNotFound, Message: fmt.Sprintf("MCPGroup '%s' not found in namespace '%s'", groupName, mcpServer.Namespace), ObservedGeneration: mcpServer.Generation, }) - } else if group.Status.Phase != mcpv1alpha1.MCPGroupPhaseReady { + } else if group.Status.Phase != mcpv1beta1.MCPGroupPhaseReady { meta.SetStatusCondition(&mcpServer.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionGroupRefValidated, + Type: mcpv1beta1.ConditionGroupRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonGroupRefNotReady, + Reason: mcpv1beta1.ConditionReasonGroupRefNotReady, Message: fmt.Sprintf("MCPGroup '%s' is not ready (current phase: %s)", groupName, group.Status.Phase), ObservedGeneration: mcpServer.Generation, }) } else { meta.SetStatusCondition(&mcpServer.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionGroupRefValidated, + Type: mcpv1beta1.ConditionGroupRefValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonGroupRefValidated, + Reason: mcpv1beta1.ConditionReasonGroupRefValidated, Message: fmt.Sprintf("MCPGroup '%s' is valid and ready", groupName), ObservedGeneration: mcpServer.Generation, }) @@ -537,9 +537,9 @@ func (r *MCPServerReconciler) validateGroupRef(ctx context.Context, mcpServer *m } // setCABundleRefCondition sets the CA bundle validation status condition -func setCABundleRefCondition(mcpServer *mcpv1alpha1.MCPServer, status metav1.ConditionStatus, reason, message string) { +func setCABundleRefCondition(mcpServer *mcpv1beta1.MCPServer, status metav1.ConditionStatus, reason, message string) { meta.SetStatusCondition(&mcpServer.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionCABundleRefValidated, + Type: mcpv1beta1.ConditionCABundleRefValidated, Status: status, Reason: reason, Message: message, @@ -549,14 +549,14 @@ func setCABundleRefCondition(mcpServer *mcpv1alpha1.MCPServer, status metav1.Con // validateCABundleRef validates the CABundleRef ConfigMap reference if specified. // Checks the MCPOIDCConfig path for CA bundle references. -func (r *MCPServerReconciler) validateCABundleRef(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) { - var caBundleRef *mcpv1alpha1.CABundleSource +func (r *MCPServerReconciler) validateCABundleRef(ctx context.Context, mcpServer *mcpv1beta1.MCPServer) { + var caBundleRef *mcpv1beta1.CABundleSource // Check MCPOIDCConfig inline CA bundle if using the reference path if mcpServer.Spec.OIDCConfigRef != nil { oidcCfg, err := ctrlutil.GetOIDCConfigForServer(ctx, r.Client, mcpServer.Namespace, mcpServer.Spec.OIDCConfigRef) if err == nil && oidcCfg != nil && - oidcCfg.Spec.Type == mcpv1alpha1.MCPOIDCConfigTypeInline && + oidcCfg.Spec.Type == mcpv1beta1.MCPOIDCConfigTypeInline && oidcCfg.Spec.Inline != nil { caBundleRef = oidcCfg.Spec.Inline.CABundleRef } @@ -571,7 +571,7 @@ func (r *MCPServerReconciler) validateCABundleRef(ctx context.Context, mcpServer // Validate the CABundleRef configuration if err := validation.ValidateCABundleSource(caBundleRef); err != nil { ctxLogger.Error(err, "Invalid CABundleRef configuration") - setCABundleRefCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonCABundleRefInvalid, err.Error()) + setCABundleRefCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonCABundleRefInvalid, err.Error()) r.updateCABundleStatus(ctx, mcpServer) return } @@ -581,7 +581,7 @@ func (r *MCPServerReconciler) validateCABundleRef(ctx context.Context, mcpServer configMap := &corev1.ConfigMap{} if err := r.Get(ctx, types.NamespacedName{Namespace: mcpServer.Namespace, Name: cmName}, configMap); err != nil { ctxLogger.Error(err, "Failed to find CA bundle ConfigMap", "configMap", cmName) - setCABundleRefCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonCABundleRefNotFound, + setCABundleRefCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonCABundleRefNotFound, fmt.Sprintf("CA bundle ConfigMap '%s' not found in namespace '%s'", cmName, mcpServer.Namespace)) r.updateCABundleStatus(ctx, mcpServer) return @@ -594,20 +594,20 @@ func (r *MCPServerReconciler) validateCABundleRef(ctx context.Context, mcpServer } if _, exists := configMap.Data[key]; !exists { ctxLogger.Error(nil, "CA bundle key not found in ConfigMap", "configMap", cmName, "key", key) - setCABundleRefCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonCABundleRefInvalid, + setCABundleRefCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonCABundleRefInvalid, fmt.Sprintf("Key '%s' not found in ConfigMap '%s'", key, cmName)) r.updateCABundleStatus(ctx, mcpServer) return } // Validation passed - setCABundleRefCondition(mcpServer, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonCABundleRefValid, + setCABundleRefCondition(mcpServer, metav1.ConditionTrue, mcpv1beta1.ConditionReasonCABundleRefValid, fmt.Sprintf("CA bundle ConfigMap '%s' is valid (key: %s)", cmName, key)) r.updateCABundleStatus(ctx, mcpServer) } // updateCABundleStatus updates the MCPServer status after CA bundle validation -func (r *MCPServerReconciler) updateCABundleStatus(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) { +func (r *MCPServerReconciler) updateCABundleStatus(ctx context.Context, mcpServer *mcpv1beta1.MCPServer) { ctxLogger := log.FromContext(ctx) if err := r.Status().Update(ctx, mcpServer); err != nil { ctxLogger.Error(err, "Failed to update MCPServer status after CABundleRef validation") @@ -615,9 +615,9 @@ func (r *MCPServerReconciler) updateCABundleStatus(ctx context.Context, mcpServe } // setReadyCondition sets the top-level Ready status condition. -func setReadyCondition(mcpServer *mcpv1alpha1.MCPServer, status metav1.ConditionStatus, reason, message string) { +func setReadyCondition(mcpServer *mcpv1beta1.MCPServer, status metav1.ConditionStatus, reason, message string) { meta.SetStatusCondition(&mcpServer.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeReady, + Type: mcpv1beta1.ConditionTypeReady, Status: status, Reason: reason, Message: message, @@ -627,7 +627,7 @@ func setReadyCondition(mcpServer *mcpv1alpha1.MCPServer, status metav1.Condition // validateAndUpdatePodTemplateStatus validates the PodTemplateSpec and updates the MCPServer status // with appropriate conditions and events -func (r *MCPServerReconciler) validateAndUpdatePodTemplateStatus(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) bool { +func (r *MCPServerReconciler) validateAndUpdatePodTemplateStatus(ctx context.Context, mcpServer *mcpv1beta1.MCPServer) bool { ctxLogger := log.FromContext(ctx) // Only validate if PodTemplateSpec is provided @@ -645,19 +645,19 @@ func (r *MCPServerReconciler) validateAndUpdatePodTemplateStatus(ctx context.Con } // Set phase and message - mcpServer.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed + mcpServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed mcpServer.Status.Message = fmt.Sprintf("Invalid PodTemplateSpec: %v", err) // Set condition for invalid PodTemplateSpec meta.SetStatusCondition(&mcpServer.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionPodTemplateValid, + Type: mcpv1beta1.ConditionPodTemplateValid, Status: metav1.ConditionFalse, ObservedGeneration: mcpServer.Generation, - Reason: mcpv1alpha1.ConditionReasonPodTemplateInvalid, + Reason: mcpv1beta1.ConditionReasonPodTemplateInvalid, Message: fmt.Sprintf("Failed to parse PodTemplateSpec: %v. Deployment blocked until fixed.", err), }) - setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, + setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, fmt.Sprintf("Invalid PodTemplateSpec: %v", err)) // Update status with the condition @@ -672,10 +672,10 @@ func (r *MCPServerReconciler) validateAndUpdatePodTemplateStatus(ctx context.Con // Set condition for valid PodTemplateSpec meta.SetStatusCondition(&mcpServer.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionPodTemplateValid, + Type: mcpv1beta1.ConditionPodTemplateValid, Status: metav1.ConditionTrue, ObservedGeneration: mcpServer.Generation, - Reason: mcpv1alpha1.ConditionReasonPodTemplateValid, + Reason: mcpv1beta1.ConditionReasonPodTemplateValid, Message: "PodTemplateSpec is valid", }) @@ -689,7 +689,7 @@ func (r *MCPServerReconciler) validateAndUpdatePodTemplateStatus(ctx context.Con // handleRestartAnnotation checks if the restart annotation has been updated and triggers a restart if needed // Returns true if a restart was triggered and the reconciliation should be requeued -func (r *MCPServerReconciler) handleRestartAnnotation(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) (bool, error) { +func (r *MCPServerReconciler) handleRestartAnnotation(ctx context.Context, mcpServer *mcpv1beta1.MCPServer) (bool, error) { ctxLogger := log.FromContext(ctx) // Get the current restarted-at annotation value from the CR @@ -759,7 +759,7 @@ func (r *MCPServerReconciler) handleRestartAnnotation(ctx context.Context, mcpSe } // performRestart executes the restart based on the specified strategy -func (r *MCPServerReconciler) performRestart(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer, strategy string) error { +func (r *MCPServerReconciler) performRestart(ctx context.Context, mcpServer *mcpv1beta1.MCPServer, strategy string) error { switch strategy { case RestartStrategyRolling: return r.performRollingRestart(ctx, mcpServer) @@ -775,7 +775,7 @@ func (r *MCPServerReconciler) performRestart(ctx context.Context, mcpServer *mcp // getRunConfigChecksum fetches the RunConfig ConfigMap checksum annotation for this server. // Uses the shared RunConfigChecksumFetcher to maintain consistency with MCPRemoteProxy. func (r *MCPServerReconciler) getRunConfigChecksum( - ctx context.Context, mcpServer *mcpv1alpha1.MCPServer, + ctx context.Context, mcpServer *mcpv1beta1.MCPServer, ) (string, error) { if mcpServer == nil { return "", fmt.Errorf("mcpServer cannot be nil") @@ -786,7 +786,7 @@ func (r *MCPServerReconciler) getRunConfigChecksum( } // performRollingRestart triggers a rolling restart by updating the deployment's pod template annotation -func (r *MCPServerReconciler) performRollingRestart(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) error { +func (r *MCPServerReconciler) performRollingRestart(ctx context.Context, mcpServer *mcpv1beta1.MCPServer) error { ctxLogger := log.FromContext(ctx) deployment := &appsv1.Deployment{} err := r.Get(ctx, types.NamespacedName{Name: mcpServer.Name, Namespace: mcpServer.Namespace}, deployment) @@ -814,7 +814,7 @@ func (r *MCPServerReconciler) performRollingRestart(ctx context.Context, mcpServ } // performImmediateRestart triggers an immediate restart by deleting the pods directly -func (r *MCPServerReconciler) performImmediateRestart(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) error { +func (r *MCPServerReconciler) performImmediateRestart(ctx context.Context, mcpServer *mcpv1beta1.MCPServer) error { ctxLogger := log.FromContext(ctx) // List pods belonging to this MCPServer @@ -843,7 +843,7 @@ func (r *MCPServerReconciler) performImmediateRestart(ctx context.Context, mcpSe } // handleToolConfig handles MCPToolConfig reference for an MCPServer -func (r *MCPServerReconciler) handleToolConfig(ctx context.Context, m *mcpv1alpha1.MCPServer) error { +func (r *MCPServerReconciler) handleToolConfig(ctx context.Context, m *mcpv1beta1.MCPServer) error { ctxLogger := log.FromContext(ctx) if m.Spec.ToolConfigRef == nil { // No MCPToolConfig referenced, clear any stored hash @@ -886,7 +886,7 @@ func (r *MCPServerReconciler) handleToolConfig(ctx context.Context, m *mcpv1alph return nil } -func (r *MCPServerReconciler) ensureRBACResources(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) error { +func (r *MCPServerReconciler) ensureRBACResources(ctx context.Context, mcpServer *mcpv1beta1.MCPServer) error { rbacClient := rbac.NewClient(r.Client, r.Scheme) proxyRunnerNameForRBAC := ctrlutil.ProxyRunnerServiceAccountName(mcpServer.Name) @@ -930,7 +930,7 @@ func (r *MCPServerReconciler) ensureRBACResources(ctx context.Context, mcpServer // //nolint:gocyclo func (r *MCPServerReconciler) deploymentForMCPServer( - ctx context.Context, m *mcpv1alpha1.MCPServer, runConfigChecksum string, + ctx context.Context, m *mcpv1beta1.MCPServer, runConfigChecksum string, ) *appsv1.Deployment { ls := labelsForMCPServer(m.Name) @@ -1036,7 +1036,7 @@ func (r *MCPServerReconciler) deploymentForMCPServer( // Check MCPOIDCConfig inline config for client secret oidcCfg, err := ctrlutil.GetOIDCConfigForServer(ctx, r.Client, m.Namespace, m.Spec.OIDCConfigRef) if err == nil && oidcCfg != nil && - oidcCfg.Spec.Type == mcpv1alpha1.MCPOIDCConfigTypeInline && + oidcCfg.Spec.Type == mcpv1beta1.MCPOIDCConfigTypeInline && oidcCfg.Spec.Inline != nil { oidcClientSecretEnvVar, err := ctrlutil.GenerateOIDCClientSecretEnvVar( ctx, r.Client, m.Namespace, oidcCfg.Spec.Inline.ClientSecretRef, @@ -1079,7 +1079,7 @@ func (r *MCPServerReconciler) deploymentForMCPServer( } // Add volume mount for permission profile if using configmap - if m.Spec.PermissionProfile != nil && m.Spec.PermissionProfile.Type == mcpv1alpha1.PermissionProfileTypeConfigMap { + if m.Spec.PermissionProfile != nil && m.Spec.PermissionProfile.Type == mcpv1beta1.PermissionProfileTypeConfigMap { volumeMounts = append(volumeMounts, corev1.VolumeMount{ Name: "permission-profile", MountPath: "/etc/toolhive/profiles", @@ -1298,7 +1298,7 @@ func (r *MCPServerReconciler) deploymentForMCPServer( } // serviceForMCPServer returns a MCPServer Service object -func (r *MCPServerReconciler) serviceForMCPServer(ctx context.Context, m *mcpv1alpha1.MCPServer) *corev1.Service { +func (r *MCPServerReconciler) serviceForMCPServer(ctx context.Context, m *mcpv1beta1.MCPServer) *corev1.Service { ls := labelsForMCPServer(m.Name) // we want to generate a service name that is unique for the proxy service @@ -1420,7 +1420,7 @@ func categorizePodStatus(pod corev1.Pod) (running, pending, failed int, failureR } // updateMCPServerStatus updates the status of the MCPServer -func (r *MCPServerReconciler) updateMCPServerStatus(ctx context.Context, m *mcpv1alpha1.MCPServer) error { +func (r *MCPServerReconciler) updateMCPServerStatus(ctx context.Context, m *mcpv1beta1.MCPServer) error { // Update ObservedGeneration to reflect that we've processed this generation m.Status.ObservedGeneration = m.Generation @@ -1428,10 +1428,10 @@ func (r *MCPServerReconciler) updateMCPServerStatus(ctx context.Context, m *mcpv deployment := &appsv1.Deployment{} if err := r.Get(ctx, types.NamespacedName{Name: m.Name, Namespace: m.Namespace}, deployment); err == nil { if deployment.Spec.Replicas != nil && *deployment.Spec.Replicas == 0 { - m.Status.Phase = mcpv1alpha1.MCPServerPhaseStopped + m.Status.Phase = mcpv1beta1.MCPServerPhaseStopped m.Status.Message = "MCP server is stopped (scaled to zero)" m.Status.ReadyReplicas = 0 - setReadyCondition(m, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, "MCP server is stopped (scaled to zero)") + setReadyCondition(m, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, "MCP server is stopped (scaled to zero)") return r.Status().Update(ctx, m) } } @@ -1451,11 +1451,11 @@ func (r *MCPServerReconciler) updateMCPServerStatus(ctx context.Context, m *mcpv // No Deployment pods found yet. If a previous reconciliation already set Phase=Failed // (e.g. due to a RunConfig or RBAC error), preserve that status so the failure // reason remains visible. Only reset to Pending when the phase is not Failed. - if m.Status.Phase != mcpv1alpha1.MCPServerPhaseFailed { - m.Status.Phase = mcpv1alpha1.MCPServerPhasePending + if m.Status.Phase != mcpv1beta1.MCPServerPhaseFailed { + m.Status.Phase = mcpv1beta1.MCPServerPhasePending m.Status.Message = "MCP server is being created" m.Status.ReadyReplicas = 0 - setReadyCondition(m, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, "MCP server is being created") + setReadyCondition(m, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, "MCP server is being created") return r.Status().Update(ctx, m) } return nil @@ -1480,28 +1480,28 @@ func (r *MCPServerReconciler) updateMCPServerStatus(ctx context.Context, m *mcpv // Update the status based on pod health if running > 0 { - m.Status.Phase = mcpv1alpha1.MCPServerPhaseReady + m.Status.Phase = mcpv1beta1.MCPServerPhaseReady m.Status.Message = "MCP server is running" } else if failed > 0 { - m.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed + m.Status.Phase = mcpv1beta1.MCPServerPhaseFailed if failureReason != "" { m.Status.Message = fmt.Sprintf("MCP server pod failed: %s", failureReason) } else { m.Status.Message = "MCP server pod failed" } } else if pending > 0 { - m.Status.Phase = mcpv1alpha1.MCPServerPhasePending + m.Status.Phase = mcpv1beta1.MCPServerPhasePending m.Status.Message = "MCP server is starting" } else { - m.Status.Phase = mcpv1alpha1.MCPServerPhasePending + m.Status.Phase = mcpv1beta1.MCPServerPhasePending m.Status.Message = "No healthy pods found" } // Set the top-level Ready condition based on the determined phase - if m.Status.Phase == mcpv1alpha1.MCPServerPhaseReady { - setReadyCondition(m, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonReady, "MCP server is running") + if m.Status.Phase == mcpv1beta1.MCPServerPhaseReady { + setReadyCondition(m, metav1.ConditionTrue, mcpv1beta1.ConditionReasonReady, "MCP server is running") } else { - setReadyCondition(m, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, m.Status.Message) + setReadyCondition(m, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, m.Status.Message) } // Update the status @@ -1527,11 +1527,11 @@ func (r *MCPServerReconciler) deleteIfExists(ctx context.Context, obj client.Obj } // finalizeMCPServer performs the finalizer logic for the MCPServer -func (r *MCPServerReconciler) finalizeMCPServer(ctx context.Context, m *mcpv1alpha1.MCPServer) error { +func (r *MCPServerReconciler) finalizeMCPServer(ctx context.Context, m *mcpv1beta1.MCPServer) error { // Update the MCPServer status - m.Status.Phase = mcpv1alpha1.MCPServerPhaseTerminating + m.Status.Phase = mcpv1beta1.MCPServerPhaseTerminating m.Status.Message = "MCP server is being terminated" - setReadyCondition(m, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonNotReady, "MCP server is being terminated") + setReadyCondition(m, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, "MCP server is being terminated") if err := r.Status().Update(ctx, m); err != nil { return err } @@ -1559,7 +1559,7 @@ func (r *MCPServerReconciler) finalizeMCPServer(ctx context.Context, m *mcpv1alp func (r *MCPServerReconciler) deploymentNeedsUpdate( ctx context.Context, deployment *appsv1.Deployment, - mcpServer *mcpv1alpha1.MCPServer, + mcpServer *mcpv1beta1.MCPServer, runConfigChecksum string, ) bool { if deployment == nil || mcpServer == nil { @@ -1631,7 +1631,7 @@ func (r *MCPServerReconciler) deploymentNeedsUpdate( return true } if oidcCfg != nil && - oidcCfg.Spec.Type == mcpv1alpha1.MCPOIDCConfigTypeInline && + oidcCfg.Spec.Type == mcpv1beta1.MCPOIDCConfigTypeInline && oidcCfg.Spec.Inline != nil { oidcClientSecretEnvVar, err := ctrlutil.GenerateOIDCClientSecretEnvVar( ctx, r.Client, mcpServer.Namespace, oidcCfg.Spec.Inline.ClientSecretRef, @@ -1792,7 +1792,7 @@ func (r *MCPServerReconciler) deploymentNeedsUpdate( } // serviceNeedsUpdate checks if the service needs to be updated -func serviceNeedsUpdate(service *corev1.Service, mcpServer *mcpv1alpha1.MCPServer) bool { +func serviceNeedsUpdate(service *corev1.Service, mcpServer *mcpv1beta1.MCPServer) bool { // Check if the service port has changed if len(service.Spec.Ports) > 0 && service.Spec.Ports[0].Port != mcpServer.GetProxyPort() { return true @@ -1837,7 +1837,7 @@ func serviceNeedsUpdate(service *corev1.Service, mcpServer *mcpv1alpha1.MCPServe } // resourceRequirementsForMCPServer returns the resource requirements for the MCPServer -func resourceRequirementsForMCPServer(m *mcpv1alpha1.MCPServer) corev1.ResourceRequirements { +func resourceRequirementsForMCPServer(m *mcpv1beta1.MCPServer) corev1.ResourceRequirements { resources := corev1.ResourceRequirements{} if m.Spec.Resources.Limits.CPU != "" || m.Spec.Resources.Limits.Memory != "" { resources.Limits = corev1.ResourceList{} @@ -1898,7 +1898,7 @@ func getToolhiveRunnerImage() string { // handleExternalAuthConfig validates and tracks the hash of the referenced MCPExternalAuthConfig. // It updates the MCPServer status when the external auth configuration changes. -func (r *MCPServerReconciler) handleExternalAuthConfig(ctx context.Context, m *mcpv1alpha1.MCPServer) error { +func (r *MCPServerReconciler) handleExternalAuthConfig(ctx context.Context, m *mcpv1beta1.MCPServer) error { ctxLogger := log.FromContext(ctx) if m.Spec.ExternalAuthConfigRef == nil { // No MCPExternalAuthConfig referenced, clear any stored hash @@ -1925,9 +1925,9 @@ func (r *MCPServerReconciler) handleExternalAuthConfig(ctx context.Context, m *m // Multi-upstream requires VirtualMCPServer. if embeddedCfg := externalAuthConfig.Spec.EmbeddedAuthServer; embeddedCfg != nil && len(embeddedCfg.UpstreamProviders) > 1 { meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeExternalAuthConfigValidated, + Type: mcpv1beta1.ConditionTypeExternalAuthConfigValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonExternalAuthConfigMultiUpstream, + Reason: mcpv1beta1.ConditionReasonExternalAuthConfigMultiUpstream, Message: fmt.Sprintf( "MCPServer supports only one upstream provider (found %d); "+ "use VirtualMCPServer for multi-upstream", @@ -1964,10 +1964,10 @@ func (r *MCPServerReconciler) handleExternalAuthConfig(ctx context.Context, m *m // handleAuthServerRef validates and tracks the hash of the referenced authServerRef config. // It updates the MCPServer status when the auth server configuration changes and sets // the AuthServerRefValidated condition. -func (r *MCPServerReconciler) handleAuthServerRef(ctx context.Context, m *mcpv1alpha1.MCPServer) error { +func (r *MCPServerReconciler) handleAuthServerRef(ctx context.Context, m *mcpv1beta1.MCPServer) error { ctxLogger := log.FromContext(ctx) if m.Spec.AuthServerRef == nil { - meta.RemoveStatusCondition(&m.Status.Conditions, mcpv1alpha1.ConditionTypeAuthServerRefValidated) + meta.RemoveStatusCondition(&m.Status.Conditions, mcpv1beta1.ConditionTypeAuthServerRefValidated) if m.Status.AuthServerConfigHash != "" { m.Status.AuthServerConfigHash = "" if err := r.Status().Update(ctx, m); err != nil { @@ -1980,9 +1980,9 @@ func (r *MCPServerReconciler) handleAuthServerRef(ctx context.Context, m *mcpv1a // Only MCPExternalAuthConfig kind is supported if m.Spec.AuthServerRef.Kind != "MCPExternalAuthConfig" { meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeAuthServerRefValidated, + Type: mcpv1beta1.ConditionTypeAuthServerRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonAuthServerRefInvalidKind, + Reason: mcpv1beta1.ConditionReasonAuthServerRefInvalidKind, Message: fmt.Sprintf("unsupported authServerRef kind %q: only MCPExternalAuthConfig is supported", m.Spec.AuthServerRef.Kind), ObservedGeneration: m.Generation, @@ -1995,9 +1995,9 @@ func (r *MCPServerReconciler) handleAuthServerRef(ctx context.Context, m *mcpv1a if err != nil { if errors.IsNotFound(err) { meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeAuthServerRefValidated, + Type: mcpv1beta1.ConditionTypeAuthServerRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonAuthServerRefNotFound, + Reason: mcpv1beta1.ConditionReasonAuthServerRefNotFound, Message: fmt.Sprintf("MCPExternalAuthConfig '%s' not found in namespace '%s'", m.Spec.AuthServerRef.Name, m.Namespace), ObservedGeneration: m.Generation, @@ -2006,9 +2006,9 @@ func (r *MCPServerReconciler) handleAuthServerRef(ctx context.Context, m *mcpv1a m.Spec.AuthServerRef.Name, m.Namespace) } meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeAuthServerRefValidated, + Type: mcpv1beta1.ConditionTypeAuthServerRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonAuthServerRefFetchError, + Reason: mcpv1beta1.ConditionReasonAuthServerRefFetchError, Message: fmt.Sprintf("Failed to fetch MCPExternalAuthConfig '%s'", m.Spec.AuthServerRef.Name), ObservedGeneration: m.Generation, }) @@ -2016,11 +2016,11 @@ func (r *MCPServerReconciler) handleAuthServerRef(ctx context.Context, m *mcpv1a } // Validate the config type is embeddedAuthServer - if authConfig.Spec.Type != mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer { + if authConfig.Spec.Type != mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer { meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeAuthServerRefValidated, + Type: mcpv1beta1.ConditionTypeAuthServerRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonAuthServerRefInvalidType, + Reason: mcpv1beta1.ConditionReasonAuthServerRefInvalidType, Message: fmt.Sprintf("authServerRef '%s' has type %q, but only embeddedAuthServer is supported", m.Spec.AuthServerRef.Name, authConfig.Spec.Type), ObservedGeneration: m.Generation, @@ -2032,9 +2032,9 @@ func (r *MCPServerReconciler) handleAuthServerRef(ctx context.Context, m *mcpv1a // MCPServer supports only single-upstream embedded auth server configs if embeddedCfg := authConfig.Spec.EmbeddedAuthServer; embeddedCfg != nil && len(embeddedCfg.UpstreamProviders) > 1 { meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeAuthServerRefValidated, + Type: mcpv1beta1.ConditionTypeAuthServerRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonAuthServerRefMultiUpstream, + Reason: mcpv1beta1.ConditionReasonAuthServerRefMultiUpstream, Message: fmt.Sprintf("MCPServer supports only one upstream provider (found %d); "+ "use VirtualMCPServer for multi-upstream", len(embeddedCfg.UpstreamProviders)), @@ -2047,9 +2047,9 @@ func (r *MCPServerReconciler) handleAuthServerRef(ctx context.Context, m *mcpv1a // AuthServerRef valid meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeAuthServerRefValidated, + Type: mcpv1beta1.ConditionTypeAuthServerRefValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonAuthServerRefValid, + Reason: mcpv1beta1.ConditionReasonAuthServerRefValid, Message: fmt.Sprintf("AuthServerRef '%s' is valid", authConfig.Name), ObservedGeneration: m.Generation, }) @@ -2074,7 +2074,7 @@ func (r *MCPServerReconciler) handleAuthServerRef(ctx context.Context, m *mcpv1a // handleOIDCConfig validates and tracks the hash of the referenced MCPOIDCConfig. // It updates the MCPServer status when the OIDC configuration changes and sets // the OIDCConfigRefValidated condition. -func (r *MCPServerReconciler) handleOIDCConfig(ctx context.Context, m *mcpv1alpha1.MCPServer) error { +func (r *MCPServerReconciler) handleOIDCConfig(ctx context.Context, m *mcpv1beta1.MCPServer) error { ctxLogger := log.FromContext(ctx) if m.Spec.OIDCConfigRef == nil { @@ -2103,11 +2103,11 @@ func (r *MCPServerReconciler) handleOIDCConfig(ctx context.Context, m *mcpv1alph // Detect whether the condition is transitioning to True (e.g. recovering from // a transient error). Without this check the status update is skipped when the // hash is unchanged, leaving a stale False condition (#4511). - prevCondition := meta.FindStatusCondition(m.Status.Conditions, mcpv1alpha1.ConditionOIDCConfigRefValidated) + prevCondition := meta.FindStatusCondition(m.Status.Conditions, mcpv1beta1.ConditionOIDCConfigRefValidated) needsUpdate := prevCondition == nil || prevCondition.Status != metav1.ConditionTrue setOIDCConfigRefCondition(m, metav1.ConditionTrue, - mcpv1alpha1.ConditionReasonOIDCConfigRefValid, + mcpv1beta1.ConditionReasonOIDCConfigRefValid, fmt.Sprintf("MCPOIDCConfig %s is valid and ready", m.Spec.OIDCConfigRef.Name)) if m.Status.OIDCConfigHash != oidcConfig.Status.ConfigHash { @@ -2132,14 +2132,14 @@ func (r *MCPServerReconciler) handleOIDCConfig(ctx context.Context, m *mcpv1alph // fetchAndValidateOIDCConfig fetches the referenced MCPOIDCConfig, validates it is // ready, and sets appropriate failure conditions on the MCPServer if not. func (r *MCPServerReconciler) fetchAndValidateOIDCConfig( - ctx context.Context, m *mcpv1alpha1.MCPServer, -) (*mcpv1alpha1.MCPOIDCConfig, error) { + ctx context.Context, m *mcpv1beta1.MCPServer, +) (*mcpv1beta1.MCPOIDCConfig, error) { ctxLogger := log.FromContext(ctx) oidcConfig, err := ctrlutil.GetOIDCConfigForServer(ctx, r.Client, m.Namespace, m.Spec.OIDCConfigRef) if err != nil { setOIDCConfigRefCondition(m, metav1.ConditionFalse, - mcpv1alpha1.ConditionReasonOIDCConfigRefNotFound, + mcpv1beta1.ConditionReasonOIDCConfigRefNotFound, fmt.Sprintf("MCPOIDCConfig %s not found: %v", m.Spec.OIDCConfigRef.Name, err)) if statusErr := r.Status().Update(ctx, m); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update status after MCPOIDCConfig lookup error") @@ -2149,7 +2149,7 @@ func (r *MCPServerReconciler) fetchAndValidateOIDCConfig( if oidcConfig == nil { setOIDCConfigRefCondition(m, metav1.ConditionFalse, - mcpv1alpha1.ConditionReasonOIDCConfigRefNotFound, + mcpv1beta1.ConditionReasonOIDCConfigRefNotFound, fmt.Sprintf("MCPOIDCConfig %s not found", m.Spec.OIDCConfigRef.Name)) if statusErr := r.Status().Update(ctx, m); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update status after MCPOIDCConfig not found") @@ -2157,14 +2157,14 @@ func (r *MCPServerReconciler) fetchAndValidateOIDCConfig( return nil, fmt.Errorf("MCPOIDCConfig %s not found", m.Spec.OIDCConfigRef.Name) } - validCondition := meta.FindStatusCondition(oidcConfig.Status.Conditions, mcpv1alpha1.ConditionTypeOIDCConfigValid) + validCondition := meta.FindStatusCondition(oidcConfig.Status.Conditions, mcpv1beta1.ConditionTypeOIDCConfigValid) if validCondition == nil || validCondition.Status != metav1.ConditionTrue { msg := fmt.Sprintf("MCPOIDCConfig %s is not valid", m.Spec.OIDCConfigRef.Name) if validCondition != nil { msg = fmt.Sprintf("MCPOIDCConfig %s is not valid: %s", m.Spec.OIDCConfigRef.Name, validCondition.Message) } setOIDCConfigRefCondition(m, metav1.ConditionFalse, - mcpv1alpha1.ConditionReasonOIDCConfigRefNotValid, msg) + mcpv1beta1.ConditionReasonOIDCConfigRefNotValid, msg) if statusErr := r.Status().Update(ctx, m); statusErr != nil { ctxLogger.Error(statusErr, "Failed to update status after MCPOIDCConfig validation check") } @@ -2175,9 +2175,9 @@ func (r *MCPServerReconciler) fetchAndValidateOIDCConfig( } // setOIDCConfigRefCondition sets the OIDCConfigRefValidated status condition -func setOIDCConfigRefCondition(m *mcpv1alpha1.MCPServer, status metav1.ConditionStatus, reason, message string) { +func setOIDCConfigRefCondition(m *mcpv1beta1.MCPServer, status metav1.ConditionStatus, reason, message string) { meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionOIDCConfigRefValidated, + Type: mcpv1beta1.ConditionOIDCConfigRefValidated, Status: status, Reason: reason, Message: message, @@ -2189,11 +2189,11 @@ func setOIDCConfigRefCondition(m *mcpv1alpha1.MCPServer, status metav1.Condition // the MCPOIDCConfig's ReferencingWorkloads status field. func (r *MCPServerReconciler) updateOIDCConfigReferencingWorkloads( ctx context.Context, - oidcConfig *mcpv1alpha1.MCPOIDCConfig, + oidcConfig *mcpv1beta1.MCPOIDCConfig, serverName string, ) error { - ref := mcpv1alpha1.WorkloadReference{ - Kind: mcpv1alpha1.WorkloadKindMCPServer, + ref := mcpv1beta1.WorkloadReference{ + Kind: mcpv1beta1.WorkloadKindMCPServer, Name: serverName, } @@ -2214,7 +2214,7 @@ func (r *MCPServerReconciler) updateOIDCConfigReferencingWorkloads( } // ensureAuthzConfigMap ensures the authorization ConfigMap exists for inline configuration -func (r *MCPServerReconciler) ensureAuthzConfigMap(ctx context.Context, m *mcpv1alpha1.MCPServer) error { +func (r *MCPServerReconciler) ensureAuthzConfigMap(ctx context.Context, m *mcpv1beta1.MCPServer) error { return ctrlutil.EnsureAuthzConfigMap( ctx, r.Client, r.Scheme, m, m.Namespace, m.Name, m.Spec.AuthzConfig, labelsForInlineAuthzConfig(m.Name), ) @@ -2244,9 +2244,9 @@ func resolveDeploymentReplicas(mcpTransport string, specReplicas *int32) *int32 } // setStdioReplicaCappedCondition sets the StdioReplicaCapped status condition -func setStdioReplicaCappedCondition(mcpServer *mcpv1alpha1.MCPServer, status metav1.ConditionStatus, reason, message string) { +func setStdioReplicaCappedCondition(mcpServer *mcpv1beta1.MCPServer, status metav1.ConditionStatus, reason, message string) { meta.SetStatusCondition(&mcpServer.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionStdioReplicaCapped, + Type: mcpv1beta1.ConditionStdioReplicaCapped, Status: status, Reason: reason, Message: message, @@ -2257,14 +2257,14 @@ func setStdioReplicaCappedCondition(mcpServer *mcpv1alpha1.MCPServer, status met // validateStdioReplicaCap checks if spec.replicas > 1 for stdio transport and sets a warning condition. // The deployment builder enforces the cap at 1 as defense-in-depth. // Clears the condition when transport or replica count no longer violates the cap. -func (r *MCPServerReconciler) validateStdioReplicaCap(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) { +func (r *MCPServerReconciler) validateStdioReplicaCap(ctx context.Context, mcpServer *mcpv1beta1.MCPServer) { if mcpServer.Spec.Transport == stdioTransport && mcpServer.Spec.Replicas != nil && *mcpServer.Spec.Replicas > 1 { setStdioReplicaCappedCondition(mcpServer, metav1.ConditionTrue, - mcpv1alpha1.ConditionReasonStdioReplicaCapped, + mcpv1beta1.ConditionReasonStdioReplicaCapped, "stdio transport requires exactly 1 replica; deployment will use 1 regardless of spec.replicas") } else { setStdioReplicaCappedCondition(mcpServer, metav1.ConditionFalse, - mcpv1alpha1.ConditionReasonStdioReplicaCapNotActive, + mcpv1beta1.ConditionReasonStdioReplicaCapNotActive, "stdio replica cap is not active") } if err := r.Status().Update(ctx, mcpServer); err != nil { @@ -2273,9 +2273,9 @@ func (r *MCPServerReconciler) validateStdioReplicaCap(ctx context.Context, mcpSe } // setSessionStorageCondition sets the SessionStorageWarning status condition -func setSessionStorageCondition(mcpServer *mcpv1alpha1.MCPServer, status metav1.ConditionStatus, reason, message string) { +func setSessionStorageCondition(mcpServer *mcpv1beta1.MCPServer, status metav1.ConditionStatus, reason, message string) { meta.SetStatusCondition(&mcpServer.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionSessionStorageWarning, + Type: mcpv1beta1.ConditionSessionStorageWarning, Status: status, Reason: reason, Message: message, @@ -2286,20 +2286,20 @@ func setSessionStorageCondition(mcpServer *mcpv1alpha1.MCPServer, status metav1. // validateSessionStorageForReplicas emits a Warning condition when replicas > 1 but session storage // is not configured with a Redis backend. The deployment still proceeds; this is advisory only. // Clears the condition when replicas drop back to nil or <= 1. -func (r *MCPServerReconciler) validateSessionStorageForReplicas(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) { +func (r *MCPServerReconciler) validateSessionStorageForReplicas(ctx context.Context, mcpServer *mcpv1beta1.MCPServer) { if mcpServer.Spec.Replicas != nil && *mcpServer.Spec.Replicas > 1 { - if mcpServer.Spec.SessionStorage == nil || mcpServer.Spec.SessionStorage.Provider != mcpv1alpha1.SessionStorageProviderRedis { + if mcpServer.Spec.SessionStorage == nil || mcpServer.Spec.SessionStorage.Provider != mcpv1beta1.SessionStorageProviderRedis { setSessionStorageCondition(mcpServer, metav1.ConditionTrue, - mcpv1alpha1.ConditionReasonSessionStorageMissing, + mcpv1beta1.ConditionReasonSessionStorageMissing, "replicas > 1 but sessionStorage.provider is not redis; sessions are not shared across replicas") } else { setSessionStorageCondition(mcpServer, metav1.ConditionFalse, - mcpv1alpha1.ConditionReasonSessionStorageConfigured, + mcpv1beta1.ConditionReasonSessionStorageConfigured, "Redis session storage is configured") } } else { setSessionStorageCondition(mcpServer, metav1.ConditionFalse, - mcpv1alpha1.ConditionReasonSessionStorageNotApplicable, + mcpv1beta1.ConditionReasonSessionStorageNotApplicable, "session storage warning is not active") } if err := r.Status().Update(ctx, mcpServer); err != nil { @@ -2308,9 +2308,9 @@ func (r *MCPServerReconciler) validateSessionStorageForReplicas(ctx context.Cont } // setRateLimitConfigCondition sets the RateLimitConfigValid status condition. -func setRateLimitConfigCondition(mcpServer *mcpv1alpha1.MCPServer, status metav1.ConditionStatus, reason, message string) { +func setRateLimitConfigCondition(mcpServer *mcpv1beta1.MCPServer, status metav1.ConditionStatus, reason, message string) { meta.SetStatusCondition(&mcpServer.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionRateLimitConfigValid, + Type: mcpv1beta1.ConditionRateLimitConfigValid, Status: status, Reason: reason, Message: message, @@ -2322,11 +2322,11 @@ func setRateLimitConfigCondition(mcpServer *mcpv1alpha1.MCPServer, status metav1 // Sets the RateLimitConfigValid condition. This is defense-in-depth only; CEL admission // validation is the primary gate. Reconciliation continues even when the condition is False // because per-user buckets are silently skipped when userID is empty (graceful degradation). -func (r *MCPServerReconciler) validateRateLimitConfig(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) { +func (r *MCPServerReconciler) validateRateLimitConfig(ctx context.Context, mcpServer *mcpv1beta1.MCPServer) { rl := mcpServer.Spec.RateLimiting if rl == nil { setRateLimitConfigCondition(mcpServer, metav1.ConditionTrue, - mcpv1alpha1.ConditionReasonRateLimitNotApplicable, + mcpv1beta1.ConditionReasonRateLimitNotApplicable, "rate limiting is not configured") if err := r.Status().Update(ctx, mcpServer); err != nil { log.FromContext(ctx).Error(err, "Failed to update MCPServer status after rate limit validation") @@ -2349,11 +2349,11 @@ func (r *MCPServerReconciler) validateRateLimitConfig(ctx context.Context, mcpSe if hasPerUser && !authEnabled { setRateLimitConfigCondition(mcpServer, metav1.ConditionFalse, - mcpv1alpha1.ConditionReasonRateLimitPerUserRequiresAuth, + mcpv1beta1.ConditionReasonRateLimitPerUserRequiresAuth, "perUser rate limiting requires authentication to be enabled (oidcConfigRef or externalAuthConfigRef)") } else { setRateLimitConfigCondition(mcpServer, metav1.ConditionTrue, - mcpv1alpha1.ConditionReasonRateLimitConfigValid, + mcpv1beta1.ConditionReasonRateLimitConfigValid, "rate limit configuration is valid") } if err := r.Status().Update(ctx, mcpServer); err != nil { @@ -2366,13 +2366,13 @@ func (r *MCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { // Create a handler that maps MCPExternalAuthConfig changes to MCPServer reconciliation requests externalAuthConfigHandler := handler.EnqueueRequestsFromMapFunc( func(ctx context.Context, obj client.Object) []reconcile.Request { - externalAuthConfig, ok := obj.(*mcpv1alpha1.MCPExternalAuthConfig) + externalAuthConfig, ok := obj.(*mcpv1beta1.MCPExternalAuthConfig) if !ok { return nil } // List all MCPServers in the same namespace - mcpServerList := &mcpv1alpha1.MCPServerList{} + mcpServerList := &mcpv1beta1.MCPServerList{} if err := r.List(ctx, mcpServerList, client.InNamespace(externalAuthConfig.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPServers for MCPExternalAuthConfig watch") return nil @@ -2401,12 +2401,12 @@ func (r *MCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { // Create a handler that maps MCPOIDCConfig changes to MCPServer reconciliation requests oidcConfigHandler := handler.EnqueueRequestsFromMapFunc( func(ctx context.Context, obj client.Object) []reconcile.Request { - oidcConfig, ok := obj.(*mcpv1alpha1.MCPOIDCConfig) + oidcConfig, ok := obj.(*mcpv1beta1.MCPOIDCConfig) if !ok { return nil } - mcpServerList := &mcpv1alpha1.MCPServerList{} + mcpServerList := &mcpv1beta1.MCPServerList{} if err := r.List(ctx, mcpServerList, client.InNamespace(oidcConfig.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPServers for MCPOIDCConfig watch") return nil @@ -2432,11 +2432,11 @@ func (r *MCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { telemetryConfigHandler := handler.EnqueueRequestsFromMapFunc(r.mapTelemetryConfigToServers) return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1alpha1.MCPServer{}). + For(&mcpv1beta1.MCPServer{}). Owns(&appsv1.Deployment{}). Owns(&corev1.Service{}). - Watches(&mcpv1alpha1.MCPExternalAuthConfig{}, externalAuthConfigHandler). - Watches(&mcpv1alpha1.MCPOIDCConfig{}, oidcConfigHandler). - Watches(&mcpv1alpha1.MCPTelemetryConfig{}, telemetryConfigHandler). + Watches(&mcpv1beta1.MCPExternalAuthConfig{}, externalAuthConfigHandler). + Watches(&mcpv1beta1.MCPOIDCConfig{}, oidcConfigHandler). + Watches(&mcpv1beta1.MCPTelemetryConfig{}, telemetryConfigHandler). Complete(r) } diff --git a/cmd/thv-operator/controllers/mcpserver_externalauth_runconfig_test.go b/cmd/thv-operator/controllers/mcpserver_externalauth_runconfig_test.go index 51ff64f0e9..02c137d093 100644 --- a/cmd/thv-operator/controllers/mcpserver_externalauth_runconfig_test.go +++ b/cmd/thv-operator/controllers/mcpserver_externalauth_runconfig_test.go @@ -25,7 +25,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" "github.com/stacklok/toolhive/pkg/container/kubernetes" @@ -39,8 +39,8 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + mcpServer *mcpv1beta1.MCPServer + externalAuth *mcpv1beta1.MCPExternalAuthConfig clientSecret *corev1.Secret oidcConfig *oidc.OIDCConfig // OIDC config for embedded auth server expectError bool @@ -49,12 +49,12 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }{ { name: "no external auth config reference", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", // No ExternalAuthConfigRef }, @@ -68,29 +68,29 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, { name: "valid token exchange configuration with all fields", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "oauth-secret", Key: "client-secret", }, @@ -117,29 +117,29 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, { name: "valid token exchange with minimal configuration", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "minimal-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "minimal-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "minimal-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "minimal-secret", Key: "secret-key", }, @@ -165,14 +165,14 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, { name: "external auth config not found", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "non-existent", }, }, @@ -182,24 +182,24 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, { name: "unsupported external auth type", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "unsupported-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "unsupported-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: "unsupported-type", }, }, @@ -208,38 +208,38 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, { name: "valid embedded auth server configuration", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "embedded-auth-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "embedded-auth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "okta", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://okta.example.com", ClientID: "client-id", RedirectURI: "https://auth.example.com/callback", @@ -262,25 +262,25 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, { name: "embedded auth server with nil embedded config", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "bad-embedded-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "bad-embedded-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, EmbeddedAuthServer: nil, // Missing embedded config }, }, @@ -293,31 +293,31 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, { name: "embedded auth server without OIDC config fails", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "embedded-auth-config-no-oidc", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "embedded-auth-config-no-oidc", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -329,31 +329,31 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, { name: "embedded auth server without resourceUrl fails", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "embedded-auth-config-no-resource", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "embedded-auth-config-no-resource", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -368,25 +368,25 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, { name: "token exchange spec is nil", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "nil-spec-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "nil-spec-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, TokenExchange: nil, }, }, @@ -395,29 +395,29 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, { name: "client secret not found", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "no-secret-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "no-secret-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "non-existent-secret", Key: "key", }, @@ -430,29 +430,29 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, { name: "secret missing required key", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "missing-key-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "missing-key-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "incomplete-secret", Key: "missing-key", }, @@ -474,29 +474,29 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, { name: "empty scope string", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "empty-scope-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "empty-scope-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "secret", Key: "key", }, @@ -522,26 +522,26 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, { name: "token exchange without client credentials (GCP Workforce Identity)", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "gcp-workforce-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "gcp-workforce-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://sts.googleapis.com/v1/token", Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider", Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, @@ -557,26 +557,26 @@ func TestAddExternalAuthConfigOptions(t *testing.T) { }, { name: "token exchange with empty client ID but no secret ref", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "empty-client-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "empty-client-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://sts.googleapis.com/v1/token", ClientID: "", // Empty string Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider", @@ -639,39 +639,39 @@ func TestCreateRunConfigFromMCPServer_WithExternalAuth(t *testing.T) { tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + mcpServer *mcpv1beta1.MCPServer + externalAuth *mcpv1beta1.MCPExternalAuthConfig clientSecret *corev1.Secret expectError bool validate func(*testing.T, *runner.RunConfig) }{ { name: "with external auth token exchange", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "external-auth-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test:v1", Transport: "stdio", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "oauth-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "oauth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "my-client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "oauth-creds", Key: "client-secret", }, @@ -723,16 +723,16 @@ func TestCreateRunConfigFromMCPServer_WithExternalAuth(t *testing.T) { }, { name: "external auth config not found returns error", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "broken-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test:v1", Transport: "stdio", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "non-existent", }, }, @@ -741,50 +741,50 @@ func TestCreateRunConfigFromMCPServer_WithExternalAuth(t *testing.T) { }, { name: "with external auth embedded auth server", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "embedded-auth-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test:v1", Transport: "stdio", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "embedded-auth-config", }, - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: "embedded-oidc", Audience: "http://embedded-auth-server.default.svc.cluster.local:8080", Scopes: []string{"openid", "offline_access", "mcp:tools"}, }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "embedded-auth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, - TokenLifespans: &mcpv1alpha1.TokenLifespanConfig{ + TokenLifespans: &mcpv1beta1.TokenLifespanConfig{ AccessTokenLifespan: "30m", RefreshTokenLifespan: "168h", AuthCodeLifespan: "5m", }, - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "okta", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://okta.example.com", ClientID: "my-client-id", RedirectURI: "https://auth.example.com/callback", @@ -840,14 +840,14 @@ func TestCreateRunConfigFromMCPServer_WithExternalAuth(t *testing.T) { } // Add MCPOIDCConfig if the MCPServer references one if tt.mcpServer.Spec.OIDCConfigRef != nil { - objects = append(objects, &mcpv1alpha1.MCPOIDCConfig{ + objects = append(objects, &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: tt.mcpServer.Spec.OIDCConfigRef.Name, Namespace: tt.mcpServer.Namespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://kubernetes.default.svc", }, }, @@ -882,20 +882,20 @@ func TestGenerateTokenExchangeEnvVars(t *testing.T) { tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + mcpServer *mcpv1beta1.MCPServer + externalAuth *mcpv1beta1.MCPExternalAuthConfig expectError bool errContains string validate func(*testing.T, []corev1.EnvVar) }{ { name: "no external auth config reference", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", }, }, @@ -907,29 +907,29 @@ func TestGenerateTokenExchangeEnvVars(t *testing.T) { }, { name: "valid token exchange config generates env var", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "oauth-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "oauth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "oauth-secret", Key: "client-secret", }, @@ -950,24 +950,24 @@ func TestGenerateTokenExchangeEnvVars(t *testing.T) { }, { name: "unsupported auth type returns empty env vars", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "unsupported-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "unsupported-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: "unsupported", }, }, @@ -979,25 +979,25 @@ func TestGenerateTokenExchangeEnvVars(t *testing.T) { }, { name: "nil token exchange spec returns empty env vars", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "nil-spec-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "nil-spec-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, TokenExchange: nil, }, }, @@ -1009,14 +1009,14 @@ func TestGenerateTokenExchangeEnvVars(t *testing.T) { }, { name: "external auth config not found returns error", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "non-existent", }, }, @@ -1026,26 +1026,26 @@ func TestGenerateTokenExchangeEnvVars(t *testing.T) { }, { name: "token exchange without client secret ref (GCP Workforce Identity)", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "gcp-workforce-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "gcp-workforce-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://sts.googleapis.com/v1/token", Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider", Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, @@ -1062,26 +1062,26 @@ func TestGenerateTokenExchangeEnvVars(t *testing.T) { }, { name: "token exchange with nil client secret ref returns no env vars", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "nil-secret-config", }, }, }, - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "nil-secret-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "client-id", ClientSecretRef: nil, // Explicitly nil diff --git a/cmd/thv-operator/controllers/mcpserver_externalauth_test.go b/cmd/thv-operator/controllers/mcpserver_externalauth_test.go index 00441d7c01..9fa8e0ffc3 100644 --- a/cmd/thv-operator/controllers/mcpserver_externalauth_test.go +++ b/cmd/thv-operator/controllers/mcpserver_externalauth_test.go @@ -27,7 +27,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/container/kubernetes" ) @@ -36,24 +36,24 @@ func TestMCPServerReconciler_handleExternalAuthConfig(t *testing.T) { tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig + mcpServer *mcpv1beta1.MCPServer + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig expectError bool expectHash string expectHashCleared bool }{ { name: "no external auth config reference", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", // No ExternalAuthConfigRef }, - Status: mcpv1alpha1.MCPServerStatus{}, + Status: mcpv1beta1.MCPServerStatus{}, }, expectError: false, expectHash: "", @@ -61,37 +61,37 @@ func TestMCPServerReconciler_handleExternalAuthConfig(t *testing.T) { }, { name: "external auth config reference exists", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-config", }, }, - Status: mcpv1alpha1.MCPServerStatus{}, + Status: mcpv1beta1.MCPServerStatus{}, }, - externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, Audience: "backend-service", }, }, - Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ ConfigHash: "test-hash-123", }, }, @@ -100,56 +100,56 @@ func TestMCPServerReconciler_handleExternalAuthConfig(t *testing.T) { }, { name: "external auth config not found", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "non-existent-config", }, }, - Status: mcpv1alpha1.MCPServerStatus{}, + Status: mcpv1beta1.MCPServerStatus{}, }, expectError: true, }, { name: "external auth config hash changed", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-config", }, }, - Status: mcpv1alpha1.MCPServerStatus{ + Status: mcpv1beta1.MCPServerStatus{ ExternalAuthConfigHash: "old-hash", }, }, - externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, Audience: "new-audience", // Changed config }, }, - Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ ConfigHash: "new-hash-456", }, }, @@ -158,16 +158,16 @@ func TestMCPServerReconciler_handleExternalAuthConfig(t *testing.T) { }, { name: "clear hash when reference is removed", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", // No ExternalAuthConfigRef (was removed) }, - Status: mcpv1alpha1.MCPServerStatus{ + Status: mcpv1beta1.MCPServerStatus{ ExternalAuthConfigHash: "old-hash-to-clear", }, }, @@ -177,35 +177,35 @@ func TestMCPServerReconciler_handleExternalAuthConfig(t *testing.T) { }, { name: "embedded auth server with multiple upstreams rejected", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "multi-upstream-config", }, }, - Status: mcpv1alpha1.MCPServerStatus{}, + Status: mcpv1beta1.MCPServerStatus{}, }, - externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "multi-upstream-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ - {Name: "github", Type: mcpv1alpha1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{IssuerURL: "https://github.com", ClientID: "id1"}}, - {Name: "google", Type: mcpv1alpha1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{IssuerURL: "https://accounts.google.com", ClientID: "id2"}}, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + {Name: "github", Type: mcpv1beta1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{IssuerURL: "https://github.com", ClientID: "id1"}}, + {Name: "google", Type: mcpv1beta1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{IssuerURL: "https://accounts.google.com", ClientID: "id2"}}, }, }, }, - Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ConfigHash: "multi-hash"}, + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ConfigHash: "multi-hash"}, }, expectError: true, }, @@ -219,7 +219,7 @@ func TestMCPServerReconciler_handleExternalAuthConfig(t *testing.T) { defer cancel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // Build objects for fake client @@ -231,7 +231,7 @@ func TestMCPServerReconciler_handleExternalAuthConfig(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) @@ -266,51 +266,51 @@ func TestMCPServerReconciler_handleExternalAuthConfig_SameNamespace(t *testing.T defer cancel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // External auth config in a different namespace - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "other-namespace", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, Audience: "backend-service", }, }, - Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ ConfigHash: "test-hash-123", }, } // MCPServer in different namespace - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-config", // References config in same namespace (default) }, }, - Status: mcpv1alpha1.MCPServerStatus{}, + Status: mcpv1beta1.MCPServerStatus{}, } fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(externalAuthConfig, mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) @@ -330,43 +330,43 @@ func TestMCPServerReconciler_handleExternalAuthConfig_HashUpdateTrigger(t *testi defer cancel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, Audience: "backend-service", }, }, - Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ ConfigHash: "initial-hash", }, } - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-config", }, }, - Status: mcpv1alpha1.MCPServerStatus{ + Status: mcpv1beta1.MCPServerStatus{ ExternalAuthConfigHash: "initial-hash", }, } @@ -374,7 +374,7 @@ func TestMCPServerReconciler_handleExternalAuthConfig_HashUpdateTrigger(t *testi fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(externalAuthConfig, mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}, &mcpv1alpha1.MCPExternalAuthConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}, &mcpv1beta1.MCPExternalAuthConfig{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) @@ -385,7 +385,7 @@ func TestMCPServerReconciler_handleExternalAuthConfig_HashUpdateTrigger(t *testi assert.Equal(t, "initial-hash", mcpServer.Status.ExternalAuthConfigHash) // Simulate external auth config change - need to get the object first - var updatedConfig mcpv1alpha1.MCPExternalAuthConfig + var updatedConfig mcpv1beta1.MCPExternalAuthConfig err = fakeClient.Get(ctx, client.ObjectKey{Name: "test-config", Namespace: "default"}, &updatedConfig) require.NoError(t, err) @@ -407,50 +407,50 @@ func TestMCPServerReconciler_handleExternalAuthConfig_NoHashInConfig(t *testing. defer cancel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // External auth config without hash in status - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, Audience: "backend-service", }, }, - Status: mcpv1alpha1.MCPExternalAuthConfigStatus{ + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ // ConfigHash is empty }, } - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-config", }, }, - Status: mcpv1alpha1.MCPServerStatus{}, + Status: mcpv1beta1.MCPServerStatus{}, } fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(externalAuthConfig, mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) diff --git a/cmd/thv-operator/controllers/mcpserver_groupref_test.go b/cmd/thv-operator/controllers/mcpserver_groupref_test.go index 41580f85d5..aa238e8b47 100644 --- a/cmd/thv-operator/controllers/mcpserver_groupref_test.go +++ b/cmd/thv-operator/controllers/mcpserver_groupref_test.go @@ -16,7 +16,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // TestMCPServerReconciler_ValidateGroupRef tests the validateGroupRef function @@ -25,32 +25,32 @@ func TestMCPServerReconciler_ValidateGroupRef(t *testing.T) { tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer - mcpGroups []*mcpv1alpha1.MCPGroup + mcpServer *mcpv1beta1.MCPServer + mcpGroups []*mcpv1beta1.MCPGroup expectedConditionStatus metav1.ConditionStatus expectedConditionReason string expectedConditionMsg string }{ { name: "GroupRef validated when group exists and is Ready", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, - mcpGroups: []*mcpv1alpha1.MCPGroup{ + mcpGroups: []*mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, }, }, @@ -59,94 +59,94 @@ func TestMCPServerReconciler_ValidateGroupRef(t *testing.T) { }, { name: "GroupRef not validated when group does not exist", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "non-existent-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "non-existent-group"}, }, }, - mcpGroups: []*mcpv1alpha1.MCPGroup{}, + mcpGroups: []*mcpv1beta1.MCPGroup{}, expectedConditionStatus: metav1.ConditionFalse, - expectedConditionReason: mcpv1alpha1.ConditionReasonGroupRefNotFound, + expectedConditionReason: mcpv1beta1.ConditionReasonGroupRefNotFound, }, { name: "GroupRef not validated when group is Pending", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, - mcpGroups: []*mcpv1alpha1.MCPGroup{ + mcpGroups: []*mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhasePending, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhasePending, }, }, }, expectedConditionStatus: metav1.ConditionFalse, - expectedConditionReason: mcpv1alpha1.ConditionReasonGroupRefNotReady, + expectedConditionReason: mcpv1beta1.ConditionReasonGroupRefNotReady, expectedConditionMsg: "MCPGroup 'test-group' is not ready (current phase: Pending)", }, { name: "GroupRef not validated when group is Failed", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, - mcpGroups: []*mcpv1alpha1.MCPGroup{ + mcpGroups: []*mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseFailed, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseFailed, }, }, }, expectedConditionStatus: metav1.ConditionFalse, - expectedConditionReason: mcpv1alpha1.ConditionReasonGroupRefNotReady, + expectedConditionReason: mcpv1beta1.ConditionReasonGroupRefNotReady, expectedConditionMsg: "MCPGroup 'test-group' is not ready (current phase: Failed)", }, { name: "No validation when GroupRef is empty", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", // No GroupRef }, }, - mcpGroups: []*mcpv1alpha1.MCPGroup{ + mcpGroups: []*mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, }, }, @@ -160,7 +160,7 @@ func TestMCPServerReconciler_ValidateGroupRef(t *testing.T) { ctx := context.Background() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) objs := []client.Object{} @@ -171,7 +171,7 @@ func TestMCPServerReconciler_ValidateGroupRef(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPGroup{}). + WithStatusSubresource(&mcpv1beta1.MCPGroup{}). Build() r := &MCPServerReconciler{ @@ -183,7 +183,7 @@ func TestMCPServerReconciler_ValidateGroupRef(t *testing.T) { // Check the condition if we expected one if tt.expectedConditionStatus != "" { - condition := meta.FindStatusCondition(tt.mcpServer.Status.Conditions, mcpv1alpha1.ConditionGroupRefValidated) + condition := meta.FindStatusCondition(tt.mcpServer.Status.Conditions, mcpv1beta1.ConditionGroupRefValidated) require.NotNil(t, condition, "GroupRefValidated condition should be present") assert.Equal(t, tt.expectedConditionStatus, condition.Status) if tt.expectedConditionReason != "" { @@ -194,7 +194,7 @@ func TestMCPServerReconciler_ValidateGroupRef(t *testing.T) { } } else { // No condition should be set when GroupRef is empty - condition := meta.FindStatusCondition(tt.mcpServer.Status.Conditions, mcpv1alpha1.ConditionGroupRefValidated) + condition := meta.FindStatusCondition(tt.mcpServer.Status.Conditions, mcpv1beta1.ConditionGroupRefValidated) assert.Nil(t, condition, "GroupRefValidated condition should not be present when GroupRef is empty") } }) @@ -207,57 +207,57 @@ func TestMCPServerReconciler_GroupRefValidation_Integration(t *testing.T) { tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer - mcpGroup *mcpv1alpha1.MCPGroup + mcpServer *mcpv1beta1.MCPServer + mcpGroup *mcpv1beta1.MCPGroup expectedConditionStatus metav1.ConditionStatus expectedConditionReason string }{ { name: "Server with valid GroupRef gets validated condition", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, }, expectedConditionStatus: metav1.ConditionTrue, }, { name: "Server with GroupRef to non-Ready group gets failed condition", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhasePending, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhasePending, }, }, expectedConditionStatus: metav1.ConditionFalse, - expectedConditionReason: mcpv1alpha1.ConditionReasonGroupRefNotReady, + expectedConditionReason: mcpv1beta1.ConditionReasonGroupRefNotReady, }, } @@ -267,7 +267,7 @@ func TestMCPServerReconciler_GroupRefValidation_Integration(t *testing.T) { ctx := context.Background() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) objs := []client.Object{tt.mcpServer} @@ -278,7 +278,7 @@ func TestMCPServerReconciler_GroupRefValidation_Integration(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}, &mcpv1alpha1.MCPGroup{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}, &mcpv1beta1.MCPGroup{}). Build() r := &MCPServerReconciler{ @@ -288,7 +288,7 @@ func TestMCPServerReconciler_GroupRefValidation_Integration(t *testing.T) { r.validateGroupRef(ctx, tt.mcpServer) - condition := meta.FindStatusCondition(tt.mcpServer.Status.Conditions, mcpv1alpha1.ConditionGroupRefValidated) + condition := meta.FindStatusCondition(tt.mcpServer.Status.Conditions, mcpv1beta1.ConditionGroupRefValidated) require.NotNil(t, condition, "GroupRefValidated condition should be present") assert.Equal(t, tt.expectedConditionStatus, condition.Status) if tt.expectedConditionReason != "" { @@ -304,34 +304,34 @@ func TestMCPServerReconciler_GroupRefCrossNamespace(t *testing.T) { ctx := context.Background() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "namespace-a", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "namespace-b", // Different namespace }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, } fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(mcpServer, mcpGroup). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}, &mcpv1alpha1.MCPGroup{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}, &mcpv1beta1.MCPGroup{}). Build() r := &MCPServerReconciler{ @@ -342,8 +342,8 @@ func TestMCPServerReconciler_GroupRefCrossNamespace(t *testing.T) { r.validateGroupRef(ctx, mcpServer) // Should fail to find the group because it's in a different namespace - condition := meta.FindStatusCondition(mcpServer.Status.Conditions, mcpv1alpha1.ConditionGroupRefValidated) + condition := meta.FindStatusCondition(mcpServer.Status.Conditions, mcpv1beta1.ConditionGroupRefValidated) require.NotNil(t, condition, "GroupRefValidated condition should be present") assert.Equal(t, metav1.ConditionFalse, condition.Status) - assert.Equal(t, mcpv1alpha1.ConditionReasonGroupRefNotFound, condition.Reason) + assert.Equal(t, mcpv1beta1.ConditionReasonGroupRefNotFound, condition.Reason) } diff --git a/cmd/thv-operator/controllers/mcpserver_invalid_podtemplate_reconcile_test.go b/cmd/thv-operator/controllers/mcpserver_invalid_podtemplate_reconcile_test.go index 36230535b3..a1fcad5e01 100644 --- a/cmd/thv-operator/controllers/mcpserver_invalid_podtemplate_reconcile_test.go +++ b/cmd/thv-operator/controllers/mcpserver_invalid_podtemplate_reconcile_test.go @@ -20,7 +20,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -29,19 +29,19 @@ func TestMCPServerReconciler_InvalidPodTemplateSpec(t *testing.T) { tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer expectConditionStatus metav1.ConditionStatus expectConditionReason string expectEventReason string }{ { name: "invalid_json_in_podtemplatespec", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-invalid-json", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, @@ -53,17 +53,17 @@ func TestMCPServerReconciler_InvalidPodTemplateSpec(t *testing.T) { }, }, expectConditionStatus: metav1.ConditionFalse, - expectConditionReason: mcpv1alpha1.ConditionReasonPodTemplateInvalid, + expectConditionReason: mcpv1beta1.ConditionReasonPodTemplateInvalid, expectEventReason: "InvalidPodTemplateSpec", }, { name: "valid_podtemplatespec", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-valid", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, @@ -73,17 +73,17 @@ func TestMCPServerReconciler_InvalidPodTemplateSpec(t *testing.T) { }, }, expectConditionStatus: metav1.ConditionTrue, - expectConditionReason: mcpv1alpha1.ConditionReasonPodTemplateValid, + expectConditionReason: mcpv1beta1.ConditionReasonPodTemplateValid, expectEventReason: "", // No warning event for valid spec }, { name: "nil_podtemplatespec", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-nil", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, @@ -104,7 +104,7 @@ func TestMCPServerReconciler_InvalidPodTemplateSpec(t *testing.T) { // Setup the test environment for each test to avoid race conditions s := runtime.NewScheme() require.NoError(t, scheme.AddToScheme(s)) - require.NoError(t, mcpv1alpha1.AddToScheme(s)) + require.NoError(t, mcpv1beta1.AddToScheme(s)) // Create a fake event recorder for each test eventRecorder := events.NewFakeRecorder(10) @@ -142,12 +142,12 @@ func TestMCPServerReconciler_InvalidPodTemplateSpec(t *testing.T) { require.NoError(t, err) // Check the MCPServer status conditions - var updatedMCPServer mcpv1alpha1.MCPServer + var updatedMCPServer mcpv1beta1.MCPServer err = fakeClient.Get(ctx, client.ObjectKeyFromObject(tt.mcpServer), &updatedMCPServer) require.NoError(t, err) // Find the PodTemplateValid condition - condition := meta.FindStatusCondition(updatedMCPServer.Status.Conditions, mcpv1alpha1.ConditionPodTemplateValid) + condition := meta.FindStatusCondition(updatedMCPServer.Status.Conditions, mcpv1beta1.ConditionPodTemplateValid) if tt.expectConditionStatus != "" { require.NotNil(t, condition, "PodTemplateValid condition should be set") assert.Equal(t, tt.expectConditionStatus, condition.Status) @@ -185,15 +185,15 @@ func TestDeploymentArgsWithInvalidPodTemplateSpec(t *testing.T) { ctx := t.Context() s := runtime.NewScheme() require.NoError(t, scheme.AddToScheme(s)) - require.NoError(t, mcpv1alpha1.AddToScheme(s)) + require.NoError(t, mcpv1beta1.AddToScheme(s)) // MCPServer with invalid PodTemplateSpec - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-mcp", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, diff --git a/cmd/thv-operator/controllers/mcpserver_oidcconfig_test.go b/cmd/thv-operator/controllers/mcpserver_oidcconfig_test.go index 7a67960837..b2025f8456 100644 --- a/cmd/thv-operator/controllers/mcpserver_oidcconfig_test.go +++ b/cmd/thv-operator/controllers/mcpserver_oidcconfig_test.go @@ -16,7 +16,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/container/kubernetes" ) @@ -25,13 +25,13 @@ func TestMCPServerReconciler_handleOIDCConfig(t *testing.T) { // validOIDCCondition is a helper to build a Ready=True condition slice. validOIDCCondition := []metav1.Condition{{ - Type: mcpv1alpha1.ConditionTypeOIDCConfigValid, Status: metav1.ConditionTrue, Reason: mcpv1alpha1.ConditionReasonOIDCConfigValid, + Type: mcpv1beta1.ConditionTypeOIDCConfigValid, Status: metav1.ConditionTrue, Reason: mcpv1beta1.ConditionReasonOIDCConfigValid, }} tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer - oidcConfig *mcpv1alpha1.MCPOIDCConfig + mcpServer *mcpv1beta1.MCPServer + oidcConfig *mcpv1beta1.MCPOIDCConfig expectError bool expectErrorContains string expectHash string @@ -42,44 +42,44 @@ func TestMCPServerReconciler_handleOIDCConfig(t *testing.T) { }{ { name: "no ref clears previously stored hash", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "s", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "img"}, - Status: mcpv1alpha1.MCPServerStatus{OIDCConfigHash: "old"}, + Spec: mcpv1beta1.MCPServerSpec{Image: "img"}, + Status: mcpv1beta1.MCPServerStatus{OIDCConfigHash: "old"}, }, expectHashCleared: true, }, { name: "referenced config not found sets NotFound condition", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "s", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "img", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "missing", Audience: "aud"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "missing", Audience: "aud"}, }, }, expectError: true, expectConditionStatus: conditionStatusPtr(metav1.ConditionFalse), - expectConditionReason: mcpv1alpha1.ConditionReasonOIDCConfigRefNotFound, + expectConditionReason: mcpv1beta1.ConditionReasonOIDCConfigRefNotFound, }, { name: "config with Valid=False sets NotValid condition", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "s", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "img", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "bad", Audience: "aud"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "bad", Audience: "aud"}, }, }, - oidcConfig: &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig: &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: "bad", Namespace: "default"}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{Issuer: "https://x"}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{Issuer: "https://x"}, }, - Status: mcpv1alpha1.MCPOIDCConfigStatus{ + Status: mcpv1beta1.MCPOIDCConfigStatus{ Conditions: []metav1.Condition{{ - Type: mcpv1alpha1.ConditionTypeOIDCConfigValid, Status: metav1.ConditionFalse, Reason: mcpv1alpha1.ConditionReasonOIDCConfigInvalid, + Type: mcpv1beta1.ConditionTypeOIDCConfigValid, Status: metav1.ConditionFalse, Reason: mcpv1beta1.ConditionReasonOIDCConfigInvalid, Message: "missing fields", }}, }, @@ -87,59 +87,59 @@ func TestMCPServerReconciler_handleOIDCConfig(t *testing.T) { expectError: true, expectErrorContains: "not valid", expectConditionStatus: conditionStatusPtr(metav1.ConditionFalse), - expectConditionReason: mcpv1alpha1.ConditionReasonOIDCConfigRefNotValid, + expectConditionReason: mcpv1beta1.ConditionReasonOIDCConfigRefNotValid, }, { name: "valid config sets hash, condition, and referencing server", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "s", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "img", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "ok", Audience: "aud"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "ok", Audience: "aud"}, }, }, - oidcConfig: &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig: &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: "ok", Namespace: "default"}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{Issuer: "https://x", ClientID: "c"}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{Issuer: "https://x", ClientID: "c"}, }, - Status: mcpv1alpha1.MCPOIDCConfigStatus{ + Status: mcpv1beta1.MCPOIDCConfigStatus{ ConfigHash: "hash-123", Conditions: validOIDCCondition, }, }, expectHash: "hash-123", expectConditionStatus: conditionStatusPtr(metav1.ConditionTrue), - expectConditionReason: mcpv1alpha1.ConditionReasonOIDCConfigRefValid, + expectConditionReason: mcpv1beta1.ConditionReasonOIDCConfigRefValid, expectReferencingServer: true, }, { name: "detects config hash change", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "s", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "img", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "cfg", Audience: "aud"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "cfg", Audience: "aud"}, }, - Status: mcpv1alpha1.MCPServerStatus{OIDCConfigHash: "old-hash"}, + Status: mcpv1beta1.MCPServerStatus{OIDCConfigHash: "old-hash"}, }, - oidcConfig: &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig: &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "default"}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount, - KubernetesServiceAccount: &mcpv1alpha1.KubernetesServiceAccountOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount, + KubernetesServiceAccount: &mcpv1beta1.KubernetesServiceAccountOIDCConfig{ Issuer: "https://kubernetes.default.svc", }, }, - Status: mcpv1alpha1.MCPOIDCConfigStatus{ + Status: mcpv1beta1.MCPOIDCConfigStatus{ ConfigHash: "new-hash", Conditions: validOIDCCondition, }, }, expectHash: "new-hash", expectConditionStatus: conditionStatusPtr(metav1.ConditionTrue), - expectConditionReason: mcpv1alpha1.ConditionReasonOIDCConfigRefValid, + expectConditionReason: mcpv1beta1.ConditionReasonOIDCConfigRefValid, expectReferencingServer: true, }, } @@ -151,7 +151,7 @@ func TestMCPServerReconciler_handleOIDCConfig(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) objs := []runtime.Object{tt.mcpServer} @@ -163,8 +163,8 @@ func TestMCPServerReconciler_handleOIDCConfig(t *testing.T) { WithScheme(scheme). WithRuntimeObjects(objs...). WithStatusSubresource( - &mcpv1alpha1.MCPServer{}, - &mcpv1alpha1.MCPOIDCConfig{}, + &mcpv1beta1.MCPServer{}, + &mcpv1beta1.MCPOIDCConfig{}, ). Build() @@ -191,20 +191,20 @@ func TestMCPServerReconciler_handleOIDCConfig(t *testing.T) { if tt.expectConditionStatus != nil { var found bool for _, cond := range tt.mcpServer.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionOIDCConfigRefValidated { + if cond.Type == mcpv1beta1.ConditionOIDCConfigRefValidated { found = true assert.Equal(t, string(*tt.expectConditionStatus), string(cond.Status)) assert.Equal(t, tt.expectConditionReason, cond.Reason) break } } - assert.True(t, found, "expected %s condition", mcpv1alpha1.ConditionOIDCConfigRefValidated) + assert.True(t, found, "expected %s condition", mcpv1beta1.ConditionOIDCConfigRefValidated) } if tt.expectReferencingServer && tt.oidcConfig != nil { - var updated mcpv1alpha1.MCPOIDCConfig + var updated mcpv1beta1.MCPOIDCConfig require.NoError(t, fakeClient.Get(ctx, client.ObjectKeyFromObject(tt.oidcConfig), &updated)) - expectedRef := mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: tt.mcpServer.Name} + expectedRef := mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: tt.mcpServer.Name} assert.Contains(t, updated.Status.ReferencingWorkloads, expectedRef) } }) @@ -214,39 +214,39 @@ func TestMCPServerReconciler_handleOIDCConfig(t *testing.T) { func TestMCPServerReconciler_updateOIDCConfigReferencingWorkloads(t *testing.T) { t.Parallel() - existingRef := mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "existing"} + existingRef := mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "existing"} t.Run("adds new server reference", func(t *testing.T) { t.Parallel() ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - cfg := &mcpv1alpha1.MCPOIDCConfig{ + cfg := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "default"}, - Status: mcpv1alpha1.MCPOIDCConfigStatus{ReferencingWorkloads: []mcpv1alpha1.WorkloadReference{existingRef}}, + Status: mcpv1beta1.MCPOIDCConfigStatus{ReferencingWorkloads: []mcpv1beta1.WorkloadReference{existingRef}}, } fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cfg). - WithStatusSubresource(&mcpv1alpha1.MCPOIDCConfig{}).Build() + WithStatusSubresource(&mcpv1beta1.MCPOIDCConfig{}).Build() r := newTestMCPServerReconciler(fc, scheme, kubernetes.PlatformKubernetes) require.NoError(t, r.updateOIDCConfigReferencingWorkloads(ctx, cfg, "new")) - newRef := mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "new"} - assert.ElementsMatch(t, []mcpv1alpha1.WorkloadReference{existingRef, newRef}, cfg.Status.ReferencingWorkloads) + newRef := mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "new"} + assert.ElementsMatch(t, []mcpv1beta1.WorkloadReference{existingRef, newRef}, cfg.Status.ReferencingWorkloads) }) t.Run("does not duplicate existing reference", func(t *testing.T) { t.Parallel() ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - cfg := &mcpv1alpha1.MCPOIDCConfig{ + cfg := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "default"}, - Status: mcpv1alpha1.MCPOIDCConfigStatus{ReferencingWorkloads: []mcpv1alpha1.WorkloadReference{existingRef}}, + Status: mcpv1beta1.MCPOIDCConfigStatus{ReferencingWorkloads: []mcpv1beta1.WorkloadReference{existingRef}}, } fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cfg). - WithStatusSubresource(&mcpv1alpha1.MCPOIDCConfig{}).Build() + WithStatusSubresource(&mcpv1beta1.MCPOIDCConfig{}).Build() r := newTestMCPServerReconciler(fc, scheme, kubernetes.PlatformKubernetes) require.NoError(t, r.updateOIDCConfigReferencingWorkloads(ctx, cfg, "existing")) @@ -262,45 +262,45 @@ func TestMCPServerReconciler_handleOIDCConfig_ConditionPersistedOnRecovery(t *te ctx := t.Context() validOIDCCondition := []metav1.Condition{{ - Type: mcpv1alpha1.ConditionTypeOIDCConfigValid, Status: metav1.ConditionTrue, Reason: mcpv1alpha1.ConditionReasonOIDCConfigValid, + Type: mcpv1beta1.ConditionTypeOIDCConfigValid, Status: metav1.ConditionTrue, Reason: mcpv1beta1.ConditionReasonOIDCConfigValid, }} - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "s", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "img", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "cfg", Audience: "aud"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "cfg", Audience: "aud"}, }, - Status: mcpv1alpha1.MCPServerStatus{ + Status: mcpv1beta1.MCPServerStatus{ // Hash is already current — only the condition is stale (simulating recovery). OIDCConfigHash: "same-hash", Conditions: []metav1.Condition{{ - Type: mcpv1alpha1.ConditionOIDCConfigRefValidated, + Type: mcpv1beta1.ConditionOIDCConfigRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonOIDCConfigRefNotFound, + Reason: mcpv1beta1.ConditionReasonOIDCConfigRefNotFound, }}, }, } - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "default"}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{Issuer: "https://x", ClientID: "c"}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{Issuer: "https://x", ClientID: "c"}, }, - Status: mcpv1alpha1.MCPOIDCConfigStatus{ + Status: mcpv1beta1.MCPOIDCConfigStatus{ ConfigHash: "same-hash", Conditions: validOIDCCondition, }, } scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(mcpServer, oidcConfig). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}, &mcpv1alpha1.MCPOIDCConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}, &mcpv1beta1.MCPOIDCConfig{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) @@ -308,13 +308,13 @@ func TestMCPServerReconciler_handleOIDCConfig_ConditionPersistedOnRecovery(t *te // Re-read from the fake client to verify the condition was actually persisted, // not just set in the in-memory Go struct. - var persisted mcpv1alpha1.MCPServer + var persisted mcpv1beta1.MCPServer require.NoError(t, fakeClient.Get(ctx, client.ObjectKeyFromObject(mcpServer), &persisted)) - cond := meta.FindStatusCondition(persisted.Status.Conditions, mcpv1alpha1.ConditionOIDCConfigRefValidated) + cond := meta.FindStatusCondition(persisted.Status.Conditions, mcpv1beta1.ConditionOIDCConfigRefValidated) require.NotNil(t, cond, "OIDCConfigRefValidated condition must be persisted") assert.Equal(t, metav1.ConditionTrue, cond.Status, "condition should be True after recovery") - assert.Equal(t, mcpv1alpha1.ConditionReasonOIDCConfigRefValid, cond.Reason) + assert.Equal(t, mcpv1beta1.ConditionReasonOIDCConfigRefValid, cond.Reason) assert.Equal(t, "same-hash", persisted.Status.OIDCConfigHash, "hash should remain unchanged") } @@ -323,31 +323,31 @@ func TestMCPOIDCConfigReconciler_handleDeletion_BlocksWhenReferenced(t *testing. ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) now := metav1.Now() - cfg := &mcpv1alpha1.MCPOIDCConfig{ + cfg := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "cfg", Namespace: "default", Finalizers: []string{OIDCConfigFinalizerName}, DeletionTimestamp: &now, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{Issuer: "https://x"}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{Issuer: "https://x"}, }, } - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "referencing", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "img", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "cfg", Audience: "aud"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "cfg", Audience: "aud"}, }, } fc := fake.NewClientBuilder().WithScheme(scheme). WithObjects(cfg, server). - WithStatusSubresource(&mcpv1alpha1.MCPOIDCConfig{}).Build() + WithStatusSubresource(&mcpv1beta1.MCPOIDCConfig{}).Build() r := &MCPOIDCConfigReconciler{Client: fc, Scheme: scheme} result, err := r.handleDeletion(ctx, cfg) @@ -362,29 +362,29 @@ func TestMCPOIDCConfigReconciler_handleDeletion_AllowsWhenNotReferenced(t *testi ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) now := metav1.Now() - cfg := &mcpv1alpha1.MCPOIDCConfig{ + cfg := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "cfg", Namespace: "default", Finalizers: []string{OIDCConfigFinalizerName}, DeletionTimestamp: &now, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{Issuer: "https://x"}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{Issuer: "https://x"}, }, } // Unrelated server -- does NOT reference this config - unrelated := &mcpv1alpha1.MCPServer{ + unrelated := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "other", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "img"}, + Spec: mcpv1beta1.MCPServerSpec{Image: "img"}, } fc := fake.NewClientBuilder().WithScheme(scheme). WithObjects(cfg, unrelated). - WithStatusSubresource(&mcpv1alpha1.MCPOIDCConfig{}).Build() + WithStatusSubresource(&mcpv1beta1.MCPOIDCConfig{}).Build() r := &MCPOIDCConfigReconciler{Client: fc, Scheme: scheme} result, err := r.handleDeletion(ctx, cfg) @@ -399,32 +399,32 @@ func TestMCPOIDCConfigReconciler_handleDeletion_IgnoresCrossNamespaceRef(t *test ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) now := metav1.Now() - cfg := &mcpv1alpha1.MCPOIDCConfig{ + cfg := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "cfg", Namespace: "ns-a", Finalizers: []string{OIDCConfigFinalizerName}, DeletionTimestamp: &now, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{Issuer: "https://x"}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{Issuer: "https://x"}, }, } // Server in a DIFFERENT namespace referencing same config name - crossNS := &mcpv1alpha1.MCPServer{ + crossNS := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "s", Namespace: "ns-b"}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "img", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "cfg", Audience: "aud"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "cfg", Audience: "aud"}, }, } fc := fake.NewClientBuilder().WithScheme(scheme). WithObjects(cfg, crossNS). - WithStatusSubresource(&mcpv1alpha1.MCPOIDCConfig{}).Build() + WithStatusSubresource(&mcpv1beta1.MCPOIDCConfig{}).Build() r := &MCPOIDCConfigReconciler{Client: fc, Scheme: scheme} result, err := r.handleDeletion(ctx, cfg) diff --git a/cmd/thv-operator/controllers/mcpserver_platform_test.go b/cmd/thv-operator/controllers/mcpserver_platform_test.go index 492b6c0c74..893cfab35a 100644 --- a/cmd/thv-operator/controllers/mcpserver_platform_test.go +++ b/cmd/thv-operator/controllers/mcpserver_platform_test.go @@ -13,7 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/pkg/container/kubernetes" ) @@ -92,12 +92,12 @@ func TestMCPServerReconciler_DeploymentForMCPServer_Kubernetes(t *testing.T) { t.Parallel() // Create a test MCPServer - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-mcp-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, @@ -106,7 +106,7 @@ func TestMCPServerReconciler_DeploymentForMCPServer_Kubernetes(t *testing.T) { // Create reconciler with mock platform detector for Kubernetes scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) mockDetector := &mockPlatformDetector{ platform: kubernetes.PlatformKubernetes, err: nil, @@ -164,12 +164,12 @@ func TestMCPServerReconciler_DeploymentForMCPServer_OpenShift(t *testing.T) { t.Parallel() // Create a test MCPServer - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-mcp-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, @@ -178,7 +178,7 @@ func TestMCPServerReconciler_DeploymentForMCPServer_OpenShift(t *testing.T) { // Create reconciler with mock platform detector for OpenShift scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) mockDetector := &mockPlatformDetector{ platform: kubernetes.PlatformOpenShift, err: nil, @@ -242,12 +242,12 @@ func TestMCPServerReconciler_DeploymentForMCPServer_PlatformDetectionError(t *te t.Parallel() // Create a test MCPServer - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-mcp-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, @@ -256,7 +256,7 @@ func TestMCPServerReconciler_DeploymentForMCPServer_PlatformDetectionError(t *te // Create reconciler with mock platform detector that returns error scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) mockDetector := &mockPlatformDetector{ platform: kubernetes.PlatformKubernetes, err: assert.AnError, diff --git a/cmd/thv-operator/controllers/mcpserver_pod_template_test.go b/cmd/thv-operator/controllers/mcpserver_pod_template_test.go index befca2d597..2cb48b4190 100644 --- a/cmd/thv-operator/controllers/mcpserver_pod_template_test.go +++ b/cmd/thv-operator/controllers/mcpserver_pod_template_test.go @@ -17,7 +17,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/container/kubernetes" ) @@ -69,19 +69,19 @@ func TestDeploymentForMCPServerWithPodTemplateSpec(t *testing.T) { }, } - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-mcp-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, PodTemplateSpec: podTemplateSpecToRawExtension(t, podTemplateSpec), - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - PodTemplateMetadataOverrides: &mcpv1alpha1.ResourceMetadataOverrides{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + PodTemplateMetadataOverrides: &mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "podspec-testlabel": "true", }, @@ -94,8 +94,8 @@ func TestDeploymentForMCPServerWithPodTemplateSpec(t *testing.T) { // Create a new scheme for this test to avoid race conditions s := runtime.NewScheme() _ = scheme.AddToScheme(s) - s.AddKnownTypes(mcpv1alpha1.GroupVersion, &mcpv1alpha1.MCPServer{}) - s.AddKnownTypes(mcpv1alpha1.GroupVersion, &mcpv1alpha1.MCPServerList{}) + s.AddKnownTypes(mcpv1beta1.GroupVersion, &mcpv1beta1.MCPServer{}) + s.AddKnownTypes(mcpv1beta1.GroupVersion, &mcpv1beta1.MCPServerList{}) // Create a reconciler with the scheme r := newTestMCPServerReconciler(nil, s, kubernetes.PlatformKubernetes) @@ -170,12 +170,12 @@ func TestDeploymentForMCPServerWithPodTemplateSpec(t *testing.T) { func TestDeploymentForMCPServerSecretsProviderEnv(t *testing.T) { t.Parallel() // Create a test MCPServer - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-mcp-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, @@ -185,8 +185,8 @@ func TestDeploymentForMCPServerSecretsProviderEnv(t *testing.T) { // Create a new scheme for this test to avoid race conditions s := runtime.NewScheme() _ = scheme.AddToScheme(s) - s.AddKnownTypes(mcpv1alpha1.GroupVersion, &mcpv1alpha1.MCPServer{}) - s.AddKnownTypes(mcpv1alpha1.GroupVersion, &mcpv1alpha1.MCPServerList{}) + s.AddKnownTypes(mcpv1beta1.GroupVersion, &mcpv1beta1.MCPServer{}) + s.AddKnownTypes(mcpv1beta1.GroupVersion, &mcpv1beta1.MCPServerList{}) // Create a reconciler with the scheme r := newTestMCPServerReconciler(nil, s, kubernetes.PlatformKubernetes) @@ -201,17 +201,17 @@ func TestDeploymentForMCPServerWithSecrets(t *testing.T) { t.Parallel() // Create a test MCPServer with secrets and custom service account customSA := "custom-mcp-sa" - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-mcp-server-secrets", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, ServiceAccount: &customSA, - Secrets: []mcpv1alpha1.SecretRef{ + Secrets: []mcpv1beta1.SecretRef{ { Name: "github-token", Key: "token", @@ -229,8 +229,8 @@ func TestDeploymentForMCPServerWithSecrets(t *testing.T) { // Create a new scheme for this test to avoid race conditions s := runtime.NewScheme() _ = scheme.AddToScheme(s) - s.AddKnownTypes(mcpv1alpha1.GroupVersion, &mcpv1alpha1.MCPServer{}) - s.AddKnownTypes(mcpv1alpha1.GroupVersion, &mcpv1alpha1.MCPServerList{}) + s.AddKnownTypes(mcpv1beta1.GroupVersion, &mcpv1beta1.MCPServer{}) + s.AddKnownTypes(mcpv1beta1.GroupVersion, &mcpv1beta1.MCPServerList{}) // Create a reconciler with the scheme r := newTestMCPServerReconciler(nil, s, kubernetes.PlatformKubernetes) @@ -311,12 +311,12 @@ func TestProxyRunnerSecurityContext(t *testing.T) { t.Parallel() // Create a test MCPServer - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-mcp-server-env", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, @@ -326,8 +326,8 @@ func TestProxyRunnerSecurityContext(t *testing.T) { // Create a new scheme for this test to avoid race conditions s := runtime.NewScheme() _ = scheme.AddToScheme(s) - s.AddKnownTypes(mcpv1alpha1.GroupVersion, &mcpv1alpha1.MCPServer{}) - s.AddKnownTypes(mcpv1alpha1.GroupVersion, &mcpv1alpha1.MCPServerList{}) + s.AddKnownTypes(mcpv1beta1.GroupVersion, &mcpv1beta1.MCPServer{}) + s.AddKnownTypes(mcpv1beta1.GroupVersion, &mcpv1beta1.MCPServerList{}) // Create a reconciler with the scheme r := newTestMCPServerReconciler(nil, s, kubernetes.PlatformKubernetes) @@ -358,12 +358,12 @@ func TestProxyRunnerStructuredLogsEnvVar(t *testing.T) { t.Parallel() // Create a test MCPServer - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-mcp-server-logs", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, @@ -373,8 +373,8 @@ func TestProxyRunnerStructuredLogsEnvVar(t *testing.T) { // Create a new scheme for this test to avoid race conditions s := runtime.NewScheme() _ = scheme.AddToScheme(s) - s.AddKnownTypes(mcpv1alpha1.GroupVersion, &mcpv1alpha1.MCPServer{}) - s.AddKnownTypes(mcpv1alpha1.GroupVersion, &mcpv1alpha1.MCPServerList{}) + s.AddKnownTypes(mcpv1beta1.GroupVersion, &mcpv1beta1.MCPServer{}) + s.AddKnownTypes(mcpv1beta1.GroupVersion, &mcpv1beta1.MCPServerList{}) // Create a reconciler with the scheme r := newTestMCPServerReconciler(nil, s, kubernetes.PlatformKubernetes) diff --git a/cmd/thv-operator/controllers/mcpserver_podtemplatespec_builder_test.go b/cmd/thv-operator/controllers/mcpserver_podtemplatespec_builder_test.go index e2fdada6db..c5af2e2636 100644 --- a/cmd/thv-operator/controllers/mcpserver_podtemplatespec_builder_test.go +++ b/cmd/thv-operator/controllers/mcpserver_podtemplatespec_builder_test.go @@ -12,7 +12,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/ptr" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -22,7 +22,7 @@ func TestMCPServerPodTemplateSpec_AllCombinations(t *testing.T) { name string userTemplate *runtime.RawExtension serviceAccount *string - secrets []mcpv1alpha1.SecretRef + secrets []mcpv1beta1.SecretRef expectedServiceAccount string expectedSecrets int expectedContainers int @@ -60,7 +60,7 @@ func TestMCPServerPodTemplateSpec_AllCombinations(t *testing.T) { // Secrets only cases { name: "single_secret_only", - secrets: []mcpv1alpha1.SecretRef{ + secrets: []mcpv1beta1.SecretRef{ {Name: "secret1", Key: "key1"}, }, expectedSecrets: 1, @@ -69,7 +69,7 @@ func TestMCPServerPodTemplateSpec_AllCombinations(t *testing.T) { }, { name: "multiple_secrets_only", - secrets: []mcpv1alpha1.SecretRef{ + secrets: []mcpv1beta1.SecretRef{ {Name: "secret1", Key: "key1"}, {Name: "secret2", Key: "key2", TargetEnvName: "CUSTOM_ENV"}, }, @@ -79,7 +79,7 @@ func TestMCPServerPodTemplateSpec_AllCombinations(t *testing.T) { }, { name: "empty_secrets_only", - secrets: []mcpv1alpha1.SecretRef{}, + secrets: []mcpv1beta1.SecretRef{}, expectNil: true, description: "Empty secrets slice should return nil", }, @@ -88,7 +88,7 @@ func TestMCPServerPodTemplateSpec_AllCombinations(t *testing.T) { { name: "service_account_and_single_secret", serviceAccount: ptr.To("test-sa"), - secrets: []mcpv1alpha1.SecretRef{ + secrets: []mcpv1beta1.SecretRef{ {Name: "secret1", Key: "key1"}, }, expectedServiceAccount: "test-sa", @@ -99,7 +99,7 @@ func TestMCPServerPodTemplateSpec_AllCombinations(t *testing.T) { { name: "service_account_and_multiple_secrets", serviceAccount: ptr.To("test-sa"), - secrets: []mcpv1alpha1.SecretRef{ + secrets: []mcpv1beta1.SecretRef{ {Name: "secret1", Key: "key1"}, {Name: "secret2", Key: "key2", TargetEnvName: "CUSTOM_ENV"}, {Name: "secret3", Key: "key3"}, @@ -129,7 +129,7 @@ func TestMCPServerPodTemplateSpec_AllCombinations(t *testing.T) { }, }), serviceAccount: ptr.To("override-sa"), - secrets: []mcpv1alpha1.SecretRef{ + secrets: []mcpv1beta1.SecretRef{ {Name: "secret1", Key: "key1"}, }, expectedServiceAccount: "override-sa", @@ -149,7 +149,7 @@ func TestMCPServerPodTemplateSpec_AllCombinations(t *testing.T) { }, }, }), - secrets: []mcpv1alpha1.SecretRef{ + secrets: []mcpv1beta1.SecretRef{ {Name: "secret1", Key: "key1"}, }, expectedSecrets: 1, @@ -209,22 +209,22 @@ func TestMCPServerPodTemplateSpec_SecretEnvVarNaming(t *testing.T) { t.Parallel() tests := []struct { name string - secret mcpv1alpha1.SecretRef + secret mcpv1beta1.SecretRef expectedEnv string }{ { name: "use_key_as_env_name", - secret: mcpv1alpha1.SecretRef{Name: "secret1", Key: "DATABASE_PASSWORD"}, + secret: mcpv1beta1.SecretRef{Name: "secret1", Key: "DATABASE_PASSWORD"}, expectedEnv: "DATABASE_PASSWORD", }, { name: "use_custom_target_env_name", - secret: mcpv1alpha1.SecretRef{Name: "secret1", Key: "key1", TargetEnvName: "DB_PASSWORD"}, + secret: mcpv1beta1.SecretRef{Name: "secret1", Key: "key1", TargetEnvName: "DB_PASSWORD"}, expectedEnv: "DB_PASSWORD", }, { name: "empty_target_env_name_uses_key", - secret: mcpv1alpha1.SecretRef{Name: "secret1", Key: "api-token", TargetEnvName: ""}, + secret: mcpv1beta1.SecretRef{Name: "secret1", Key: "api-token", TargetEnvName: ""}, expectedEnv: "api-token", }, } @@ -236,7 +236,7 @@ func TestMCPServerPodTemplateSpec_SecretEnvVarNaming(t *testing.T) { require.NoError(t, err, "Failed to create builder") result := builder. - WithSecrets([]mcpv1alpha1.SecretRef{tt.secret}). + WithSecrets([]mcpv1beta1.SecretRef{tt.secret}). Build() require.NotNil(t, result) @@ -258,7 +258,7 @@ func TestMCPServerPodTemplateSpec_NilInputWithSecrets(t *testing.T) { builder, err := ctrlutil.NewPodTemplateSpecBuilder(nil, mcpContainerName) require.NoError(t, err) - secrets := []mcpv1alpha1.SecretRef{ + secrets := []mcpv1beta1.SecretRef{ {Name: "secret1", Key: "key1"}, {Name: "secret2", Key: "key2", TargetEnvName: "CUSTOM_ENV"}, } diff --git a/cmd/thv-operator/controllers/mcpserver_rbac_test.go b/cmd/thv-operator/controllers/mcpserver_rbac_test.go index a19f830758..bd6e35c788 100644 --- a/cmd/thv-operator/controllers/mcpserver_rbac_test.go +++ b/cmd/thv-operator/controllers/mcpserver_rbac_test.go @@ -19,12 +19,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/container/kubernetes" ) type testContext struct { - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer client client.Client reconciler *MCPServerReconciler proxyRunnerNameForRBAC string @@ -324,13 +324,13 @@ func TestEnsureRBACResources_Idempotency(t *testing.T) { func TestEnsureRBACResources_CustomServiceAccount(t *testing.T) { t.Parallel() customSA := "custom-mcpserver-sa" - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server-custom-sa", Namespace: "default", UID: "test-uid", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, @@ -386,8 +386,8 @@ func TestEnsureRBACResources_ImagePullSecrets(t *testing.T) { tc := setupTest("test-server-pull-secrets", "default") // Set ImagePullSecrets via ResourceOverrides - tc.mcpServer.Spec.ResourceOverrides = &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ + tc.mcpServer.Spec.ResourceOverrides = &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ ImagePullSecrets: []corev1.LocalObjectReference{ {Name: "my-secret"}, }, @@ -424,13 +424,13 @@ func TestEnsureRBACResources_ImagePullSecrets(t *testing.T) { assert.Equal(t, expectedSecrets, mcpServerSA.ImagePullSecrets) } -func createTestMCPServer(name, namespace string) *mcpv1alpha1.MCPServer { - return &mcpv1alpha1.MCPServer{ +func createTestMCPServer(name, namespace string) *mcpv1beta1.MCPServer { + return &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, @@ -441,6 +441,6 @@ func createTestMCPServer(name, namespace string) *mcpv1alpha1.MCPServer { func createTestScheme() *runtime.Scheme { s := runtime.NewScheme() _ = scheme.AddToScheme(s) - _ = mcpv1alpha1.AddToScheme(s) + _ = mcpv1beta1.AddToScheme(s) return s } diff --git a/cmd/thv-operator/controllers/mcpserver_replicas_test.go b/cmd/thv-operator/controllers/mcpserver_replicas_test.go index 1035c4f513..fb215507a1 100644 --- a/cmd/thv-operator/controllers/mcpserver_replicas_test.go +++ b/cmd/thv-operator/controllers/mcpserver_replicas_test.go @@ -17,7 +17,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/container/kubernetes" ) @@ -89,12 +89,12 @@ func TestReplicaBehavior(t *testing.T) { name := "replica-test" namespace := testNamespaceDefault - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: tt.transport, ProxyPort: 8080, @@ -146,7 +146,7 @@ func TestReplicaBehavior(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer, deployment, service). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, testScheme, kubernetes.PlatformKubernetes) @@ -182,12 +182,12 @@ func TestConfigUpdatePreservesReplicas(t *testing.T) { name := "config-update-test" namespace := testNamespaceDefault - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "new-image:v2", // Changed image triggers deployment update Transport: "sse", ProxyPort: 8080, @@ -238,7 +238,7 @@ func TestConfigUpdatePreservesReplicas(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer, deployment, service). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, testScheme, kubernetes.PlatformKubernetes) @@ -268,12 +268,12 @@ func TestUpdateMCPServerStatusScaledToZero(t *testing.T) { name := "stopped-test" namespace := testNamespaceDefault - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "sse", ProxyPort: 8080, @@ -312,7 +312,7 @@ func TestUpdateMCPServerStatusScaledToZero(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer, deployment). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, testScheme, kubernetes.PlatformKubernetes) @@ -321,14 +321,14 @@ func TestUpdateMCPServerStatusScaledToZero(t *testing.T) { require.NoError(t, err) // Fetch the updated MCPServer - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err = fakeClient.Get(t.Context(), types.NamespacedName{ Name: name, Namespace: namespace, }, updatedMCPServer) require.NoError(t, err) - assert.Equal(t, mcpv1alpha1.MCPServerPhaseStopped, updatedMCPServer.Status.Phase) + assert.Equal(t, mcpv1beta1.MCPServerPhaseStopped, updatedMCPServer.Status.Phase) assert.Equal(t, "MCP server is stopped (scaled to zero)", updatedMCPServer.Status.Message) assert.Equal(t, int32(0), updatedMCPServer.Status.ReadyReplicas) } @@ -339,12 +339,12 @@ func TestUpdateMCPServerStatusReadyReplicas(t *testing.T) { name := "ready-replicas-test" namespace := testNamespaceDefault - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "sse", ProxyPort: 8080, @@ -436,7 +436,7 @@ func TestUpdateMCPServerStatusReadyReplicas(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer, deployment, runningPod1, runningPod2, pendingPod). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, testScheme, kubernetes.PlatformKubernetes) @@ -445,14 +445,14 @@ func TestUpdateMCPServerStatusReadyReplicas(t *testing.T) { require.NoError(t, err) // Fetch the updated MCPServer - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err = fakeClient.Get(t.Context(), types.NamespacedName{ Name: name, Namespace: namespace, }, updatedMCPServer) require.NoError(t, err) - assert.Equal(t, mcpv1alpha1.MCPServerPhaseReady, updatedMCPServer.Status.Phase) + assert.Equal(t, mcpv1beta1.MCPServerPhaseReady, updatedMCPServer.Status.Phase) assert.Equal(t, int32(2), updatedMCPServer.Status.ReadyReplicas, "ReadyReplicas should match the number of running pods") } @@ -463,12 +463,12 @@ func TestDefaultCreationHasNilReplicas(t *testing.T) { name := "default-creation" namespace := testNamespaceDefault - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "sse", ProxyPort: 8080, @@ -479,7 +479,7 @@ func TestDefaultCreationHasNilReplicas(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, testScheme, kubernetes.PlatformKubernetes) @@ -543,12 +543,12 @@ func TestTerminationGracePeriodSet(t *testing.T) { name := "tgp-test" namespace := testNamespaceDefault - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "sse", ProxyPort: 8080, @@ -559,7 +559,7 @@ func TestTerminationGracePeriodSet(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, testScheme, kubernetes.PlatformKubernetes) @@ -575,12 +575,12 @@ func TestSpecDrivenReplicasNil(t *testing.T) { name := "nil-replicas-test" namespace := testNamespaceDefault - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "sse", ProxyPort: 8080, @@ -592,7 +592,7 @@ func TestSpecDrivenReplicasNil(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, testScheme, kubernetes.PlatformKubernetes) @@ -608,12 +608,12 @@ func TestSpecDrivenReplicas3(t *testing.T) { namespace := testNamespaceDefault replicas := int32(3) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "sse", ProxyPort: 8080, @@ -625,7 +625,7 @@ func TestSpecDrivenReplicas3(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, testScheme, kubernetes.PlatformKubernetes) @@ -644,12 +644,12 @@ func TestStdioCapConditionSet(t *testing.T) { namespace := testNamespaceDefault replicas := int32(3) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, @@ -661,7 +661,7 @@ func TestStdioCapConditionSet(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, testScheme, kubernetes.PlatformKubernetes) @@ -673,16 +673,16 @@ func TestStdioCapConditionSet(t *testing.T) { require.NoError(t, err) // Read back the MCPServer to check conditions - updated := &mcpv1alpha1.MCPServer{} + updated := &mcpv1beta1.MCPServer{} err = fakeClient.Get(t.Context(), types.NamespacedName{Name: name, Namespace: namespace}, updated) require.NoError(t, err) var found bool for _, cond := range updated.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionStdioReplicaCapped { + if cond.Type == mcpv1beta1.ConditionStdioReplicaCapped { found = true assert.Equal(t, metav1.ConditionTrue, cond.Status) - assert.Equal(t, mcpv1alpha1.ConditionReasonStdioReplicaCapped, cond.Reason) + assert.Equal(t, mcpv1beta1.ConditionReasonStdioReplicaCapped, cond.Reason) } } assert.True(t, found, "ConditionStdioReplicaCapped condition should be set") @@ -695,12 +695,12 @@ func TestSessionStorageWarningSet(t *testing.T) { namespace := testNamespaceDefault replicas := int32(2) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "sse", ProxyPort: 8080, @@ -713,7 +713,7 @@ func TestSessionStorageWarningSet(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, testScheme, kubernetes.PlatformKubernetes) @@ -723,16 +723,16 @@ func TestSessionStorageWarningSet(t *testing.T) { }) require.NoError(t, err) - updated := &mcpv1alpha1.MCPServer{} + updated := &mcpv1beta1.MCPServer{} err = fakeClient.Get(t.Context(), types.NamespacedName{Name: name, Namespace: namespace}, updated) require.NoError(t, err) var found bool for _, cond := range updated.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionSessionStorageWarning { + if cond.Type == mcpv1beta1.ConditionSessionStorageWarning { found = true assert.Equal(t, metav1.ConditionTrue, cond.Status) - assert.Equal(t, mcpv1alpha1.ConditionReasonSessionStorageMissing, cond.Reason) + assert.Equal(t, mcpv1beta1.ConditionReasonSessionStorageMissing, cond.Reason) } } assert.True(t, found, "ConditionSessionStorageWarning condition should be set") @@ -745,18 +745,18 @@ func TestSessionStorageWarningCleared(t *testing.T) { namespace := testNamespaceDefault replicas := int32(2) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "sse", ProxyPort: 8080, Replicas: &replicas, - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379", }, }, @@ -766,7 +766,7 @@ func TestSessionStorageWarningCleared(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, testScheme, kubernetes.PlatformKubernetes) @@ -776,16 +776,16 @@ func TestSessionStorageWarningCleared(t *testing.T) { }) require.NoError(t, err) - updated := &mcpv1alpha1.MCPServer{} + updated := &mcpv1beta1.MCPServer{} err = fakeClient.Get(t.Context(), types.NamespacedName{Name: name, Namespace: namespace}, updated) require.NoError(t, err) var found bool for _, cond := range updated.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionSessionStorageWarning { + if cond.Type == mcpv1beta1.ConditionSessionStorageWarning { found = true assert.Equal(t, metav1.ConditionFalse, cond.Status) - assert.Equal(t, mcpv1alpha1.ConditionReasonSessionStorageConfigured, cond.Reason) + assert.Equal(t, mcpv1beta1.ConditionReasonSessionStorageConfigured, cond.Reason) } } assert.True(t, found, "ConditionSessionStorageWarning condition should be set to False when Redis is configured") @@ -868,12 +868,12 @@ func TestUpdateMCPServerStatusExcludesTerminatingPods(t *testing.T) { namespace := testNamespaceDefault now := metav1.NewTime(time.Now()) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "sse", ProxyPort: 8080, @@ -960,7 +960,7 @@ func TestUpdateMCPServerStatusExcludesTerminatingPods(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer, deployment, runningPod1, runningPod2, terminatingPod). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() reconciler := newTestMCPServerReconciler(fakeClient, testScheme, kubernetes.PlatformKubernetes) @@ -968,14 +968,14 @@ func TestUpdateMCPServerStatusExcludesTerminatingPods(t *testing.T) { err := reconciler.updateMCPServerStatus(t.Context(), mcpServer) require.NoError(t, err) - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err = fakeClient.Get(t.Context(), types.NamespacedName{ Name: name, Namespace: namespace, }, updatedMCPServer) require.NoError(t, err) - assert.Equal(t, mcpv1alpha1.MCPServerPhaseReady, updatedMCPServer.Status.Phase) + assert.Equal(t, mcpv1beta1.MCPServerPhaseReady, updatedMCPServer.Status.Phase) assert.Equal(t, int32(2), updatedMCPServer.Status.ReadyReplicas, "ReadyReplicas should exclude terminating pods") } @@ -985,76 +985,76 @@ func TestRateLimitConfigValidation(t *testing.T) { tests := []struct { name string - spec mcpv1alpha1.MCPServerSpec + spec mcpv1beta1.MCPServerSpec expectStatus metav1.ConditionStatus expectReason string }{ { name: "no-rate-limiting", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "sse", ProxyPort: 8080, }, expectStatus: metav1.ConditionTrue, - expectReason: mcpv1alpha1.ConditionReasonRateLimitNotApplicable, + expectReason: mcpv1beta1.ConditionReasonRateLimitNotApplicable, }, { name: "peruser-with-auth", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "sse", ProxyPort: 8080, - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379", }, - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test"}, - RateLimiting: &mcpv1alpha1.RateLimitConfig{ - PerUser: &mcpv1alpha1.RateLimitBucket{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test"}, + RateLimiting: &mcpv1beta1.RateLimitConfig{ + PerUser: &mcpv1beta1.RateLimitBucket{ MaxTokens: 100, RefillPeriod: metav1.Duration{Duration: time.Minute}, }, }, }, expectStatus: metav1.ConditionTrue, - expectReason: mcpv1alpha1.ConditionReasonRateLimitConfigValid, + expectReason: mcpv1beta1.ConditionReasonRateLimitConfigValid, }, { name: "peruser-without-auth", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "sse", ProxyPort: 8080, - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379", }, - RateLimiting: &mcpv1alpha1.RateLimitConfig{ - PerUser: &mcpv1alpha1.RateLimitBucket{ + RateLimiting: &mcpv1beta1.RateLimitConfig{ + PerUser: &mcpv1beta1.RateLimitBucket{ MaxTokens: 100, RefillPeriod: metav1.Duration{Duration: time.Minute}, }, }, }, expectStatus: metav1.ConditionFalse, - expectReason: mcpv1alpha1.ConditionReasonRateLimitPerUserRequiresAuth, + expectReason: mcpv1beta1.ConditionReasonRateLimitPerUserRequiresAuth, }, { name: "per-tool-peruser-without-auth", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "sse", ProxyPort: 8080, - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379", }, - RateLimiting: &mcpv1alpha1.RateLimitConfig{ - Tools: []mcpv1alpha1.ToolRateLimitConfig{ + RateLimiting: &mcpv1beta1.RateLimitConfig{ + Tools: []mcpv1beta1.ToolRateLimitConfig{ { Name: "search", - PerUser: &mcpv1alpha1.RateLimitBucket{ + PerUser: &mcpv1beta1.RateLimitBucket{ MaxTokens: 10, RefillPeriod: metav1.Duration{Duration: time.Minute}, }, @@ -1063,27 +1063,27 @@ func TestRateLimitConfigValidation(t *testing.T) { }, }, expectStatus: metav1.ConditionFalse, - expectReason: mcpv1alpha1.ConditionReasonRateLimitPerUserRequiresAuth, + expectReason: mcpv1beta1.ConditionReasonRateLimitPerUserRequiresAuth, }, { name: "shared-only-no-auth", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "sse", ProxyPort: 8080, - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379", }, - RateLimiting: &mcpv1alpha1.RateLimitConfig{ - Shared: &mcpv1alpha1.RateLimitBucket{ + RateLimiting: &mcpv1beta1.RateLimitConfig{ + Shared: &mcpv1beta1.RateLimitBucket{ MaxTokens: 1000, RefillPeriod: metav1.Duration{Duration: time.Minute}, }, }, }, expectStatus: metav1.ConditionTrue, - expectReason: mcpv1alpha1.ConditionReasonRateLimitConfigValid, + expectReason: mcpv1beta1.ConditionReasonRateLimitConfigValid, }, } @@ -1094,7 +1094,7 @@ func TestRateLimitConfigValidation(t *testing.T) { name := "rl-" + tt.name namespace := testNamespaceDefault - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, @@ -1106,25 +1106,25 @@ func TestRateLimitConfigValidation(t *testing.T) { clientBuilder := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}) + WithStatusSubresource(&mcpv1beta1.MCPServer{}) // Add referenced MCPOIDCConfig to fake client if spec references one if mcpServer.Spec.OIDCConfigRef != nil { - oidcCfg := &mcpv1alpha1.MCPOIDCConfig{ + oidcCfg := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServer.Spec.OIDCConfigRef.Name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://auth.example.com", }, }, } oidcCfg.Status.Conditions = []metav1.Condition{ { - Type: mcpv1alpha1.ConditionTypeValid, + Type: mcpv1beta1.ConditionTypeValid, Status: metav1.ConditionTrue, Reason: "Valid", LastTransitionTime: metav1.Now(), @@ -1132,7 +1132,7 @@ func TestRateLimitConfigValidation(t *testing.T) { } clientBuilder = clientBuilder. WithObjects(oidcCfg). - WithStatusSubresource(&mcpv1alpha1.MCPOIDCConfig{}) + WithStatusSubresource(&mcpv1beta1.MCPOIDCConfig{}) } fakeClient := clientBuilder.Build() @@ -1144,13 +1144,13 @@ func TestRateLimitConfigValidation(t *testing.T) { }) require.NoError(t, err) - updated := &mcpv1alpha1.MCPServer{} + updated := &mcpv1beta1.MCPServer{} err = fakeClient.Get(t.Context(), types.NamespacedName{Name: name, Namespace: namespace}, updated) require.NoError(t, err) var found bool for _, cond := range updated.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionRateLimitConfigValid { + if cond.Type == mcpv1beta1.ConditionRateLimitConfigValid { found = true assert.Equal(t, tt.expectStatus, cond.Status) assert.Equal(t, tt.expectReason, cond.Reason) diff --git a/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go b/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go index 9adfedfb9f..8c259f26cc 100644 --- a/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go +++ b/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go @@ -24,7 +24,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/pkg/container/kubernetes" ) @@ -34,24 +34,24 @@ func TestMCPServerDeploymentNeedsUpdate_EmbeddedAuthLegacyEnvStable(t *testing.T scheme := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(scheme)) - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "embedded-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "google", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://accounts.google.com", ClientID: "client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "upstream-secret", Key: "client-secret", }, @@ -62,15 +62,15 @@ func TestMCPServerDeploymentNeedsUpdate_EmbeddedAuthLegacyEnvStable(t *testing.T }, } - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: externalAuthConfig.Name, }, }, @@ -103,24 +103,24 @@ func TestMCPServerDeploymentNeedsUpdate_EmbeddedAuthAuthServerRefEnvStable(t *te scheme := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(scheme)) - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "embedded-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "google", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://accounts.google.com", ClientID: "client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "upstream-secret", Key: "client-secret", }, @@ -131,15 +131,15 @@ func TestMCPServerDeploymentNeedsUpdate_EmbeddedAuthAuthServerRefEnvStable(t *te }, } - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, - AuthServerRef: &mcpv1alpha1.AuthServerRef{ + AuthServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: authConfig.Name, }, @@ -164,19 +164,19 @@ func TestMCPServerDeploymentNeedsUpdate_TokenExchangeDoesNotDrift(t *testing.T) scheme := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(scheme)) - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "exchange-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "token-secret", Key: "client-secret", }, @@ -185,15 +185,15 @@ func TestMCPServerDeploymentNeedsUpdate_TokenExchangeDoesNotDrift(t *testing.T) }, } - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: authConfig.Name, }, }, @@ -216,11 +216,11 @@ func TestResourceOverrides(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer expectedDeploymentLabels map[string]string expectedDeploymentAnns map[string]string expectedServiceLabels map[string]string @@ -228,12 +228,12 @@ func TestResourceOverrides(t *testing.T) { }{ { name: "no resource overrides", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, }, @@ -257,17 +257,17 @@ func TestResourceOverrides(t *testing.T) { }, { name: "with resource overrides", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - ResourceMetadataOverrides: mcpv1alpha1.ResourceMetadataOverrides{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + ResourceMetadataOverrides: mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "custom-label": "deployment-value", "environment": "test", @@ -279,7 +279,7 @@ func TestResourceOverrides(t *testing.T) { }, }, }, - ProxyService: &mcpv1alpha1.ResourceMetadataOverrides{ + ProxyService: &mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "custom-label": "service-value", "environment": "test", @@ -322,22 +322,22 @@ func TestResourceOverrides(t *testing.T) { }, { name: "with proxy environment variables", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - ResourceMetadataOverrides: mcpv1alpha1.ResourceMetadataOverrides{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + ResourceMetadataOverrides: mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "environment": "test", }, }, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ { Name: "HTTP_PROXY", Value: "http://proxy.example.com:8080", @@ -375,17 +375,17 @@ func TestResourceOverrides(t *testing.T) { }, { name: "with debug logging via TOOLHIVE_DEBUG env var", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - Env: []mcpv1alpha1.EnvVar{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + Env: []mcpv1beta1.EnvVar{ {Name: "TOOLHIVE_DEBUG", Value: "true"}, }, }, @@ -411,17 +411,17 @@ func TestResourceOverrides(t *testing.T) { }, { name: "with both metadata overrides and proxy environment variables", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - ResourceMetadataOverrides: mcpv1alpha1.ResourceMetadataOverrides{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + ResourceMetadataOverrides: mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "environment": "production", "team": "platform", @@ -431,7 +431,7 @@ func TestResourceOverrides(t *testing.T) { "version": "v1.2.3", }, }, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ { Name: "LOG_LEVEL", Value: "debug", @@ -442,7 +442,7 @@ func TestResourceOverrides(t *testing.T) { }, }, }, - ProxyService: &mcpv1alpha1.ResourceMetadataOverrides{ + ProxyService: &mcpv1beta1.ResourceMetadataOverrides{ Annotations: map[string]string{ "service.beta.kubernetes.io/aws-load-balancer-type": "nlb", }, @@ -476,17 +476,17 @@ func TestResourceOverrides(t *testing.T) { }, { name: "with Vault Agent Injection pod template annotations", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - PodTemplateMetadataOverrides: &mcpv1alpha1.ResourceMetadataOverrides{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + PodTemplateMetadataOverrides: &mcpv1beta1.ResourceMetadataOverrides{ Annotations: map[string]string{ "vault.hashicorp.com/agent-inject": "true", "vault.hashicorp.com/role": "toolhive-mcp-workloads", @@ -642,30 +642,30 @@ func TestDeploymentNeedsUpdateProxyEnv(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) client := fake.NewClientBuilder().WithScheme(scheme).Build() r := newTestMCPServerReconciler(client, scheme, kubernetes.PlatformKubernetes) tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer existingEnvVars []corev1.EnvVar expectEnvChange bool // Focus on whether env change detection works }{ { name: "matching proxy env vars - no env change", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - Env: []mcpv1alpha1.EnvVar{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + Env: []mcpv1beta1.EnvVar{ {Name: "HTTP_PROXY", Value: "http://proxy.example.com:8080"}, {Name: "NO_PROXY", Value: "localhost,127.0.0.1"}, }, @@ -681,17 +681,17 @@ func TestDeploymentNeedsUpdateProxyEnv(t *testing.T) { }, { name: "different proxy env vars - env change detected", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - Env: []mcpv1alpha1.EnvVar{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + Env: []mcpv1beta1.EnvVar{ {Name: "HTTP_PROXY", Value: "http://new-proxy.example.com:8080"}, {Name: "NO_PROXY", Value: "localhost,127.0.0.1"}, }, @@ -707,17 +707,17 @@ func TestDeploymentNeedsUpdateProxyEnv(t *testing.T) { }, { name: "added proxy env vars - env change detected", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - Env: []mcpv1alpha1.EnvVar{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + Env: []mcpv1beta1.EnvVar{ {Name: "HTTP_PROXY", Value: "http://proxy.example.com:8080"}, {Name: "NO_PROXY", Value: "localhost,127.0.0.1"}, {Name: "CUSTOM_ENV", Value: "custom-value"}, @@ -734,17 +734,17 @@ func TestDeploymentNeedsUpdateProxyEnv(t *testing.T) { }, { name: "removed proxy env vars - env change detected", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - Env: []mcpv1alpha1.EnvVar{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + Env: []mcpv1beta1.EnvVar{ {Name: "HTTP_PROXY", Value: "http://proxy.example.com:8080"}, }, }, @@ -759,12 +759,12 @@ func TestDeploymentNeedsUpdateProxyEnv(t *testing.T) { }, { name: "no proxy env vars specified - no env change when none exist", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, }, @@ -774,12 +774,12 @@ func TestDeploymentNeedsUpdateProxyEnv(t *testing.T) { }, { name: "env vars removed entirely - env change detected", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, }, @@ -828,14 +828,14 @@ func TestMCPServerSessionAffinityNone(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, SessionAffinity: string(corev1.ServiceAffinityNone), @@ -853,12 +853,12 @@ func TestMCPServerSessionAffinityNone(t *testing.T) { func TestMCPServerServiceNeedsUpdate(t *testing.T) { t.Parallel() - baseMCPServer := &mcpv1alpha1.MCPServer{ + baseMCPServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", ProxyPort: 8080, }, @@ -882,7 +882,7 @@ func TestMCPServerServiceNeedsUpdate(t *testing.T) { tests := []struct { name string service *corev1.Service - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer needsUpdate bool }{ { @@ -904,7 +904,7 @@ func TestMCPServerServiceNeedsUpdate(t *testing.T) { { name: "session affinity spec changed to None", service: baseService.DeepCopy(), - mcpServer: func() *mcpv1alpha1.MCPServer { + mcpServer: func() *mcpv1beta1.MCPServer { m := baseMCPServer.DeepCopy() m.Spec.SessionAffinity = string(corev1.ServiceAffinityNone) return m @@ -918,7 +918,7 @@ func TestMCPServerServiceNeedsUpdate(t *testing.T) { s.Spec.SessionAffinity = corev1.ServiceAffinityNone return s }(), - mcpServer: func() *mcpv1alpha1.MCPServer { + mcpServer: func() *mcpv1beta1.MCPServer { m := baseMCPServer.DeepCopy() m.Spec.SessionAffinity = string(corev1.ServiceAffinityNone) return m diff --git a/cmd/thv-operator/controllers/mcpserver_restart_test.go b/cmd/thv-operator/controllers/mcpserver_restart_test.go index 946a5efd56..32852d45c7 100644 --- a/cmd/thv-operator/controllers/mcpserver_restart_test.go +++ b/cmd/thv-operator/controllers/mcpserver_restart_test.go @@ -19,12 +19,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/container/kubernetes" ) type restartTestContext struct { - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer client client.Client reconciler *MCPServerReconciler t *testing.T @@ -39,7 +39,7 @@ func setupRestartTest(t *testing.T) *restartTestContext { fakeClient := fake.NewClientBuilder(). WithScheme(testScheme). WithObjects(mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() return &restartTestContext{ @@ -135,7 +135,7 @@ func (tc *restartTestContext) handleRestartAnnotation() (bool, error) { } // Now fetch the updated MCPServer for the actual test - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err = tc.client.Get(context.TODO(), types.NamespacedName{ Name: tc.mcpServer.Name, Namespace: tc.mcpServer.Namespace, @@ -728,7 +728,7 @@ func (m *mockFailingClient) Update(ctx context.Context, obj client.Object, opts } if m.failOnMCPServerUpdate { // Check if the object being updated is an MCPServer - if _, isMCPServer := obj.(*mcpv1alpha1.MCPServer); isMCPServer { + if _, isMCPServer := obj.(*mcpv1beta1.MCPServer); isMCPServer { return fmt.Errorf("mock error: MCPServer Update operation failed") } } diff --git a/cmd/thv-operator/controllers/mcpserver_runconfig.go b/cmd/thv-operator/controllers/mcpserver_runconfig.go index 3be6df9a9b..25c34ef55a 100644 --- a/cmd/thv-operator/controllers/mcpserver_runconfig.go +++ b/cmd/thv-operator/controllers/mcpserver_runconfig.go @@ -15,7 +15,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/kubernetes/configmaps" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" @@ -33,7 +33,7 @@ const defaultProxyHost = "0.0.0.0" const defaultAPITimeout = 15 * time.Second // ensureRunConfigConfigMap ensures the RunConfig ConfigMap exists and is up to date -func (r *MCPServerReconciler) ensureRunConfigConfigMap(ctx context.Context, m *mcpv1alpha1.MCPServer) error { +func (r *MCPServerReconciler) ensureRunConfigConfigMap(ctx context.Context, m *mcpv1beta1.MCPServer) error { runConfig, err := r.createRunConfigFromMCPServer(m) if err != nil { return fmt.Errorf("failed to create RunConfig from MCPServer: %w", err) @@ -81,7 +81,7 @@ func (r *MCPServerReconciler) ensureRunConfigConfigMap(ctx context.Context, m *m // This creates a RunConfig for serialization to ConfigMap, not for direct execution // //nolint:gocyclo -func (r *MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1alpha1.MCPServer) (*runner.RunConfig, error) { +func (r *MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1beta1.MCPServer) (*runner.RunConfig, error) { ctx := context.Background() ctxLogger := log.FromContext(ctx) @@ -165,13 +165,13 @@ func (r *MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1alpha1.MCPSer // Add permission profile if specified if m.Spec.PermissionProfile != nil { switch m.Spec.PermissionProfile.Type { - case mcpv1alpha1.PermissionProfileTypeBuiltin: + case mcpv1beta1.PermissionProfileTypeBuiltin: options = append(options, runner.WithPermissionProfileNameOrPath( m.Spec.PermissionProfile.Name, ), ) - case mcpv1alpha1.PermissionProfileTypeConfigMap: + case mcpv1beta1.PermissionProfileTypeConfigMap: // For ConfigMap-based permission profiles, we store the path options = append(options, runner.WithPermissionProfileNameOrPath( @@ -290,9 +290,9 @@ func (r *MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1alpha1.MCPSer // populateScalingConfig sets BackendReplicas and SessionRedis on the RunConfig from the MCPServer spec. // Fields are only set when present in the spec; nil means "not configured" and is left as-is. -func populateScalingConfig(runConfig *runner.RunConfig, m *mcpv1alpha1.MCPServer) { +func populateScalingConfig(runConfig *runner.RunConfig, m *mcpv1beta1.MCPServer) { hasBackendReplicas := m.Spec.BackendReplicas != nil - hasRedis := m.Spec.SessionStorage != nil && m.Spec.SessionStorage.Provider == mcpv1alpha1.SessionStorageProviderRedis + hasRedis := m.Spec.SessionStorage != nil && m.Spec.SessionStorage.Provider == mcpv1beta1.SessionStorageProviderRedis if !hasBackendReplicas && !hasRedis { return @@ -551,7 +551,7 @@ func (*MCPServerReconciler) validateToolsFilter(config *runner.RunConfig) error } // convertEnvVarsFromMCPServer converts MCPServer environment variables to builder format -func convertEnvVarsFromMCPServer(envs []mcpv1alpha1.EnvVar) map[string]string { +func convertEnvVarsFromMCPServer(envs []mcpv1beta1.EnvVar) map[string]string { if len(envs) == 0 { return nil } @@ -563,7 +563,7 @@ func convertEnvVarsFromMCPServer(envs []mcpv1alpha1.EnvVar) map[string]string { } // convertVolumesFromMCPServer converts MCPServer volumes to builder format -func convertVolumesFromMCPServer(vols []mcpv1alpha1.Volume) []string { +func convertVolumesFromMCPServer(vols []mcpv1beta1.Volume) []string { if len(vols) == 0 { return nil } diff --git a/cmd/thv-operator/controllers/mcpserver_runconfig_test.go b/cmd/thv-operator/controllers/mcpserver_runconfig_test.go index 4199f804ae..0ffcd2a790 100644 --- a/cmd/thv-operator/controllers/mcpserver_runconfig_test.go +++ b/cmd/thv-operator/controllers/mcpserver_runconfig_test.go @@ -18,7 +18,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" "github.com/stacklok/toolhive/pkg/authz" @@ -37,17 +37,17 @@ const ( func createRunConfigTestScheme() *runtime.Scheme { testScheme := runtime.NewScheme() _ = corev1.AddToScheme(testScheme) - _ = mcpv1alpha1.AddToScheme(testScheme) + _ = mcpv1beta1.AddToScheme(testScheme) return testScheme } -func createTestMCPServerWithConfig(name, namespace, image string, envVars []mcpv1alpha1.EnvVar) *mcpv1alpha1.MCPServer { - return &mcpv1alpha1.MCPServer{ +func createTestMCPServerWithConfig(name, namespace, image string, envVars []mcpv1beta1.EnvVar) *mcpv1beta1.MCPServer { + return &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: image, Transport: stdioTransport, ProxyPort: 8080, @@ -61,17 +61,17 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { t.Parallel() tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer expected func(t *testing.T, config *runner.RunConfig) }{ { name: "basic conversion", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, @@ -87,16 +87,16 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { }, { name: "with environment variables", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "env-server", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "env-image:latest", Transport: "sse", ProxyPort: 9090, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "VAR1", Value: "value1"}, {Name: "VAR2", Value: "value2"}, }, @@ -114,16 +114,16 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { }, { name: "with volumes", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "vol-server", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "vol-image:latest", Transport: "stdio", ProxyPort: 8080, - Volumes: []mcpv1alpha1.Volume{ + Volumes: []mcpv1beta1.Volume{ {Name: "vol1", HostPath: "/host/path1", MountPath: "/mount/path1", ReadOnly: false}, {Name: "vol2", HostPath: "/host/path2", MountPath: "/mount/path2", ReadOnly: true}, }, @@ -139,16 +139,16 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { }, { name: "with secrets", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "secret-server", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "secret-image:latest", Transport: "stdio", ProxyPort: 8080, - Secrets: []mcpv1alpha1.SecretRef{ + Secrets: []mcpv1beta1.SecretRef{ {Name: "secret1", Key: "key1", TargetEnvName: "TARGET1"}, {Name: "secret2", Key: "key2"}, // No target, should use key as target }, @@ -167,12 +167,12 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { }, { name: "proxy mode specified", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "proxy-mode-server", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, @@ -190,12 +190,12 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { }, { name: "proxy mode defaults to streamable-http when not specified", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "default-proxy-mode-server", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, @@ -213,12 +213,12 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { }, { name: "SSE transport sets proxyMode to sse (ignores configured proxyMode)", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "sse-server", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: "sse", ProxyPort: 8080, @@ -240,12 +240,12 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { }, { name: "SSE transport without proxyMode sets proxyMode to sse", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "sse-server-no-proxymode", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: "sse", ProxyPort: 8080, @@ -263,12 +263,12 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { }, { name: "streamable-http transport sets proxyMode to streamable-http (ignores configured proxyMode)", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "streamable-http-server", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: "streamable-http", ProxyPort: 8080, @@ -287,12 +287,12 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { }, { name: "streamable-http transport without proxyMode sets proxyMode to streamable-http", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "streamable-http-server-no-proxymode", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: "streamable-http", ProxyPort: 8080, @@ -310,28 +310,28 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { }, { name: "comprehensive test with all fields", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "comprehensive-server", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "comprehensive:latest", Transport: "streamable-http", ProxyPort: 9090, MCPPort: 8080, ProxyMode: "streamable-http", Args: []string{"--comprehensive", "--test"}, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "ENV1", Value: "value1"}, {Name: "ENV2", Value: "value2"}, {Name: "EMPTY_VALUE", Value: ""}, }, - Volumes: []mcpv1alpha1.Volume{ + Volumes: []mcpv1beta1.Volume{ {Name: "vol1", HostPath: "/host/path1", MountPath: "/mount/path1", ReadOnly: false}, {Name: "vol2", HostPath: "/host/path2", MountPath: "/mount/path2", ReadOnly: true}, }, - Secrets: []mcpv1alpha1.SecretRef{ + Secrets: []mcpv1beta1.SecretRef{ {Name: "secret1", Key: "key1", TargetEnvName: "CUSTOM_TARGET"}, {Name: "secret2", Key: "key2"}, // Uses key as target }, @@ -363,19 +363,19 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { }, { name: "edge case: empty/nil slices", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "edge-server", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "edge:latest", Transport: "stdio", ProxyPort: 8080, - Args: []string{}, // Empty slice - Env: nil, // Nil slice - Volumes: []mcpv1alpha1.Volume{}, // Empty slice - Secrets: nil, // Nil slice + Args: []string{}, // Empty slice + Env: nil, // Nil slice + Volumes: []mcpv1beta1.Volume{}, // Empty slice + Secrets: nil, // Nil slice }, }, //nolint:thelper // We want to see the error at the specific line @@ -390,18 +390,18 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { }, { name: "with inline authorization configuration", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "authz-server", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{ `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, `permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");`, @@ -431,18 +431,18 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { }, { name: "with configmap authorization configuration", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "authz-configmap-server", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "test-authz-config", Key: ctrlutil.DefaultAuthzKey, }, @@ -475,7 +475,7 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) { var r *MCPServerReconciler if tt.mcpServer != nil && tt.mcpServer.Spec.AuthzConfig != nil && - tt.mcpServer.Spec.AuthzConfig.Type == mcpv1alpha1.AuthzConfigTypeConfigMap && + tt.mcpServer.Spec.AuthzConfig.Type == mcpv1beta1.AuthzConfigTypeConfigMap && tt.mcpServer.Spec.AuthzConfig.ConfigMap != nil { scheme := createRunConfigTestScheme() @@ -529,28 +529,28 @@ func TestDeterministicConfigMapGeneration(t *testing.T) { t.Parallel() // Create a complex MCPServer with all possible fields to ensure comprehensive testing - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "deterministic-server", Namespace: "test-namespace", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "deterministic-test:v1.2.3", Transport: "sse", ProxyPort: 9090, MCPPort: 8080, Args: []string{"--arg1", "--arg2", "--complex-flag=value"}, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "VAR_C", Value: "value_c"}, {Name: "VAR_A", Value: "value_a"}, {Name: "VAR_B", Value: "value_b"}, {Name: "EMPTY_VAR", Value: ""}, }, - Volumes: []mcpv1alpha1.Volume{ + Volumes: []mcpv1beta1.Volume{ {Name: "vol2", HostPath: "/host/path2", MountPath: "/container/path2", ReadOnly: true}, {Name: "vol1", HostPath: "/host/path1", MountPath: "/container/path1", ReadOnly: false}, }, - Secrets: []mcpv1alpha1.SecretRef{ + Secrets: []mcpv1beta1.SecretRef{ {Name: "secret2", Key: "key2", TargetEnvName: "CUSTOM_TARGET2"}, {Name: "secret1", Key: "key1"}, // Uses key as target }, @@ -664,7 +664,7 @@ func TestEnsureRunConfigConfigMap(t *testing.T) { t.Parallel() tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer existingCM *corev1.ConfigMap expectUpdate bool expectError bool @@ -759,18 +759,18 @@ func TestEnsureRunConfigConfigMap(t *testing.T) { }, { name: "configmap with inline authorization configuration", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "authz-test", Namespace: "toolhive-system", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/example/server:v1.0.0", Transport: "stdio", ProxyPort: 8080, - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{ `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, `permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");`, @@ -813,16 +813,16 @@ func TestEnsureRunConfigConfigMap(t *testing.T) { }, { name: "configmap with audit configuration enabled", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "audit-test", Namespace: "toolhive-system", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/example/server:v1.0.0", Transport: "stdio", ProxyPort: 8080, - Audit: &mcpv1alpha1.AuditConfig{ + Audit: &mcpv1beta1.AuditConfig{ Enabled: true, }, }, @@ -901,18 +901,18 @@ func TestEnsureRunConfigConfigMap(t *testing.T) { t.Parallel() testScheme := createRunConfigTestScheme() - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "authz-cm-ext", Namespace: "toolhive-system", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/example/server:v1.0.0", Transport: "stdio", ProxyPort: 8080, - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "ext-authz-config", Key: "authz.json", }, @@ -1203,7 +1203,7 @@ func TestEnsureRunConfigConfigMapCompleteFlow(t *testing.T) { } // Step 1: Create initial MCPServer and ConfigMap - mcpServer := createTestMCPServerWithConfig("flow-server", "flow-ns", "test:v1", []mcpv1alpha1.EnvVar{ + mcpServer := createTestMCPServerWithConfig("flow-server", "flow-ns", "test:v1", []mcpv1beta1.EnvVar{ {Name: "ENV1", Value: "value1"}, }) @@ -1234,7 +1234,7 @@ func TestEnsureRunConfigConfigMapCompleteFlow(t *testing.T) { // The checksum will automatically change when content changes mcpServer.Spec.Image = "test:v2" - mcpServer.Spec.Env = []mcpv1alpha1.EnvVar{ + mcpServer.Spec.Env = []mcpv1beta1.EnvVar{ {Name: "ENV1", Value: "value1"}, {Name: "ENV2", Value: "value2"}, } @@ -1283,16 +1283,16 @@ func TestMCPServerModificationScenarios(t *testing.T) { t.Parallel() tests := []struct { name string - initialServer func() *mcpv1alpha1.MCPServer - modifyServer func(*mcpv1alpha1.MCPServer) + initialServer func() *mcpv1beta1.MCPServer + modifyServer func(*mcpv1beta1.MCPServer) expectedChanges map[string]interface{} }{ { name: "Transport change", - initialServer: func() *mcpv1alpha1.MCPServer { + initialServer: func() *mcpv1beta1.MCPServer { return createTestMCPServerWithConfig("transport-test", "default", "test:v1", nil) }, - modifyServer: func(server *mcpv1alpha1.MCPServer) { + modifyServer: func(server *mcpv1beta1.MCPServer) { server.Spec.Transport = "sse" server.Spec.ProxyPort = 9090 server.Spec.MCPPort = 8080 @@ -1305,12 +1305,12 @@ func TestMCPServerModificationScenarios(t *testing.T) { }, { name: "Args modification", - initialServer: func() *mcpv1alpha1.MCPServer { + initialServer: func() *mcpv1beta1.MCPServer { server := createTestMCPServerWithConfig("args-test", "default", "test:v1", nil) server.Spec.Args = []string{"--initial", "--arg"} return server }, - modifyServer: func(server *mcpv1alpha1.MCPServer) { + modifyServer: func(server *mcpv1beta1.MCPServer) { server.Spec.Args = []string{"--modified", "--different", "--args"} }, expectedChanges: map[string]interface{}{ @@ -1319,15 +1319,15 @@ func TestMCPServerModificationScenarios(t *testing.T) { }, { name: "Volume changes", - initialServer: func() *mcpv1alpha1.MCPServer { + initialServer: func() *mcpv1beta1.MCPServer { server := createTestMCPServerWithConfig("volume-test", "default", "test:v1", nil) - server.Spec.Volumes = []mcpv1alpha1.Volume{ + server.Spec.Volumes = []mcpv1beta1.Volume{ {HostPath: "/host/path1", MountPath: "/container/path1"}, } return server }, - modifyServer: func(server *mcpv1alpha1.MCPServer) { - server.Spec.Volumes = []mcpv1alpha1.Volume{ + modifyServer: func(server *mcpv1beta1.MCPServer) { + server.Spec.Volumes = []mcpv1beta1.Volume{ {HostPath: "/host/path1", MountPath: "/container/path1", ReadOnly: true}, {HostPath: "/host/path2", MountPath: "/container/path2"}, } @@ -1338,15 +1338,15 @@ func TestMCPServerModificationScenarios(t *testing.T) { }, { name: "Secret changes", - initialServer: func() *mcpv1alpha1.MCPServer { + initialServer: func() *mcpv1beta1.MCPServer { server := createTestMCPServerWithConfig("secret-test", "default", "test:v1", nil) - server.Spec.Secrets = []mcpv1alpha1.SecretRef{ + server.Spec.Secrets = []mcpv1beta1.SecretRef{ {Name: "secret1", Key: "key1"}, } return server }, - modifyServer: func(server *mcpv1alpha1.MCPServer) { - server.Spec.Secrets = []mcpv1alpha1.SecretRef{ + modifyServer: func(server *mcpv1beta1.MCPServer) { + server.Spec.Secrets = []mcpv1beta1.SecretRef{ {Name: "secret1", Key: "key1", TargetEnvName: "CUSTOM_ENV1"}, {Name: "secret2", Key: "key2"}, } @@ -1359,12 +1359,12 @@ func TestMCPServerModificationScenarios(t *testing.T) { }, { name: "Proxy mode change", - initialServer: func() *mcpv1alpha1.MCPServer { + initialServer: func() *mcpv1beta1.MCPServer { server := createTestMCPServerWithConfig("proxy-test", "default", "test:v1", nil) server.Spec.ProxyMode = sseProxyMode return server }, - modifyServer: func(server *mcpv1alpha1.MCPServer) { + modifyServer: func(server *mcpv1beta1.MCPServer) { server.Spec.ProxyMode = streamableHTTPProxyMode }, expectedChanges: map[string]interface{}{ @@ -1446,17 +1446,17 @@ func TestEnsureRunConfigConfigMap_WithVaultInjection(t *testing.T) { // Test that EnvFileDir is properly set when Vault Agent Injection is detected testCases := []struct { name string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer expectedEnvDir string }{ { name: "vault injection in PodTemplateSpec annotations", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "vault-server", Namespace: "toolhive-system", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/example/server:v1.0.0", Transport: "stdio", ProxyPort: 8080, @@ -1478,18 +1478,18 @@ func TestEnsureRunConfigConfigMap_WithVaultInjection(t *testing.T) { }, { name: "vault injection in ResourceOverrides annotations", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "vault-override-server", Namespace: "toolhive-system", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/example/server:v1.0.0", Transport: "stdio", ProxyPort: 8080, - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - PodTemplateMetadataOverrides: &mcpv1alpha1.ResourceMetadataOverrides{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + PodTemplateMetadataOverrides: &mcpv1beta1.ResourceMetadataOverrides{ Annotations: map[string]string{ "vault.hashicorp.com/agent-inject": "true", "vault.hashicorp.com/role": "override-role", @@ -1503,12 +1503,12 @@ func TestEnsureRunConfigConfigMap_WithVaultInjection(t *testing.T) { }, { name: "no vault injection - should have empty EnvFileDir", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "no-vault-server", Namespace: "toolhive-system", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/example/server:v1.0.0", Transport: "stdio", ProxyPort: 8080, @@ -1561,12 +1561,12 @@ func TestPopulateScalingConfig(t *testing.T) { tests := []struct { name string - spec mcpv1alpha1.MCPServerSpec + spec mcpv1beta1.MCPServerSpec expected func(t *testing.T, sc *runner.ScalingConfig) }{ { name: "nil backendReplicas and nil sessionStorage — ScalingConfig stays nil", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, @@ -1578,7 +1578,7 @@ func TestPopulateScalingConfig(t *testing.T) { }, { name: "backendReplicas set — written to ScalingConfig", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, @@ -1593,7 +1593,7 @@ func TestPopulateScalingConfig(t *testing.T) { }, { name: "backendReplicas zero — written (not nil) to ScalingConfig", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, @@ -1608,7 +1608,7 @@ func TestPopulateScalingConfig(t *testing.T) { }, { name: "sessionStorage nil — SessionRedis stays nil", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, @@ -1622,11 +1622,11 @@ func TestPopulateScalingConfig(t *testing.T) { }, { name: "sessionStorage memory — SessionRedis stays nil", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ + SessionStorage: &mcpv1beta1.SessionStorageConfig{ Provider: "memory", }, }, @@ -1637,11 +1637,11 @@ func TestPopulateScalingConfig(t *testing.T) { }, { name: "sessionStorage redis — address/db/keyPrefix written to SessionRedis", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ + SessionStorage: &mcpv1beta1.SessionStorageConfig{ Provider: "redis", Address: "redis.default.svc:6379", DB: 2, @@ -1659,14 +1659,14 @@ func TestPopulateScalingConfig(t *testing.T) { }, { name: "sessionStorage redis with passwordRef — password NOT in SessionRedis", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ + SessionStorage: &mcpv1beta1.SessionStorageConfig{ Provider: "redis", Address: "redis:6379", - PasswordRef: &mcpv1alpha1.SecretKeyRef{ + PasswordRef: &mcpv1beta1.SecretKeyRef{ Name: "redis-secret", Key: "password", }, @@ -1693,7 +1693,7 @@ func TestPopulateScalingConfig(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - m := &mcpv1alpha1.MCPServer{ + m := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, Spec: tt.spec, } @@ -1717,27 +1717,27 @@ func TestCreateRunConfigFromMCPServer_RateLimiting(t *testing.T) { tests := []struct { name string - spec mcpv1alpha1.MCPServerSpec + spec mcpv1beta1.MCPServerSpec wantNil bool wantNs string }{ { name: "rateLimiting nil produces nil config", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: testImage, }, wantNil: true, }, { name: "rateLimiting set flows to RunConfig", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: testImage, - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ + SessionStorage: &mcpv1beta1.SessionStorageConfig{ Provider: "redis", Address: "redis:6379", }, - RateLimiting: &mcpv1alpha1.RateLimitConfig{ - Shared: &mcpv1alpha1.RateLimitBucket{ + RateLimiting: &mcpv1beta1.RateLimitConfig{ + Shared: &mcpv1beta1.RateLimitBucket{ MaxTokens: 10, RefillPeriod: metav1.Duration{Duration: 60_000_000_000}, // 1m }, @@ -1759,7 +1759,7 @@ func TestCreateRunConfigFromMCPServer_RateLimiting(t *testing.T) { Client: k8sClient, } - m := &mcpv1alpha1.MCPServer{ + m := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "test-ns", diff --git a/cmd/thv-operator/controllers/mcpserver_telemetry_cabundle_test.go b/cmd/thv-operator/controllers/mcpserver_telemetry_cabundle_test.go index e5507af96a..49e1302fc0 100644 --- a/cmd/thv-operator/controllers/mcpserver_telemetry_cabundle_test.go +++ b/cmd/thv-operator/controllers/mcpserver_telemetry_cabundle_test.go @@ -13,7 +13,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/container/kubernetes" ) @@ -22,7 +22,7 @@ func TestDeploymentForMCPServer_TelemetryCABundleVolume(t *testing.T) { tests := []struct { name string - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig + telemetryConfig *mcpv1beta1.MCPTelemetryConfig expectVolumeName string expectMountPath string expectConfigMap string @@ -31,17 +31,17 @@ func TestDeploymentForMCPServer_TelemetryCABundleVolume(t *testing.T) { }{ { name: "CA bundle volume and mount are present with default key", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "my-telemetry", Namespace: "default", }, - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "https://otel-collector:4318", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, - CABundleRef: &mcpv1alpha1.CABundleSource{ + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ Name: "otel-ca-bundle", @@ -58,17 +58,17 @@ func TestDeploymentForMCPServer_TelemetryCABundleVolume(t *testing.T) { }, { name: "CA bundle volume and mount use custom key", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "my-telemetry", Namespace: "default", }, - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "https://otel-collector:4318", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, - CABundleRef: &mcpv1alpha1.CABundleSource{ + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ Name: "internal-ca", @@ -86,16 +86,16 @@ func TestDeploymentForMCPServer_TelemetryCABundleVolume(t *testing.T) { }, { name: "no CA bundle when telemetry config has no caBundleRef", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "my-telemetry", Namespace: "default", }, - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "https://otel-collector:4318", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, }, }, }, @@ -110,7 +110,7 @@ func TestDeploymentForMCPServer_TelemetryCABundleVolume(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) fakeClient := fake.NewClientBuilder(). @@ -118,16 +118,16 @@ func TestDeploymentForMCPServer_TelemetryCABundleVolume(t *testing.T) { WithObjects(tt.telemetryConfig). Build() - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "my-telemetry", }, }, @@ -183,7 +183,7 @@ func TestDeploymentForMCPServer_TelemetryCABundleVolume_FetchError(t *testing.T) ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // Build a client that does NOT have the MCPTelemetryConfig object. @@ -192,16 +192,16 @@ func TestDeploymentForMCPServer_TelemetryCABundleVolume_FetchError(t *testing.T) WithScheme(scheme). Build() - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyPort: 8080, - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "missing-telemetry-config", }, }, diff --git a/cmd/thv-operator/controllers/mcpserver_telemetryconfig.go b/cmd/thv-operator/controllers/mcpserver_telemetryconfig.go index 8ad4328108..48bf2035dc 100644 --- a/cmd/thv-operator/controllers/mcpserver_telemetryconfig.go +++ b/cmd/thv-operator/controllers/mcpserver_telemetryconfig.go @@ -15,12 +15,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // handleTelemetryConfig validates and tracks the hash of the referenced MCPTelemetryConfig. // It updates the MCPServer status when the telemetry configuration changes. -func (r *MCPServerReconciler) handleTelemetryConfig(ctx context.Context, m *mcpv1alpha1.MCPServer) error { +func (r *MCPServerReconciler) handleTelemetryConfig(ctx context.Context, m *mcpv1beta1.MCPServer) error { ctxLogger := log.FromContext(ctx) if m.Spec.TelemetryConfigRef == nil { @@ -39,9 +39,9 @@ func (r *MCPServerReconciler) handleTelemetryConfig(ctx context.Context, m *mcpv if err != nil { // Transient API error (not a NotFound) meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTelemetryConfigRefValidated, + Type: mcpv1beta1.ConditionTelemetryConfigRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonTelemetryConfigRefError, + Reason: mcpv1beta1.ConditionReasonTelemetryConfigRefError, Message: err.Error(), ObservedGeneration: m.Generation, }) @@ -51,9 +51,9 @@ func (r *MCPServerReconciler) handleTelemetryConfig(ctx context.Context, m *mcpv if telemetryConfig == nil { // Resource genuinely does not exist meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTelemetryConfigRefValidated, + Type: mcpv1beta1.ConditionTelemetryConfigRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonTelemetryConfigRefNotFound, + Reason: mcpv1beta1.ConditionReasonTelemetryConfigRefNotFound, Message: fmt.Sprintf("MCPTelemetryConfig %s not found", m.Spec.TelemetryConfigRef.Name), ObservedGeneration: m.Generation, }) @@ -63,9 +63,9 @@ func (r *MCPServerReconciler) handleTelemetryConfig(ctx context.Context, m *mcpv // Validate that the MCPTelemetryConfig is valid (has Valid=True condition) if err := telemetryConfig.Validate(); err != nil { meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTelemetryConfigRefValidated, + Type: mcpv1beta1.ConditionTelemetryConfigRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonTelemetryConfigRefInvalid, + Reason: mcpv1beta1.ConditionReasonTelemetryConfigRefInvalid, Message: fmt.Sprintf("MCPTelemetryConfig %s is invalid: %v", m.Spec.TelemetryConfigRef.Name, err), ObservedGeneration: m.Generation, }) @@ -75,13 +75,13 @@ func (r *MCPServerReconciler) handleTelemetryConfig(ctx context.Context, m *mcpv // Detect whether the condition is transitioning to True (e.g. recovering from // a transient error). Without this check the status update is skipped when the // hash is unchanged, leaving a stale False condition (#4511). - prevCondition := meta.FindStatusCondition(m.Status.Conditions, mcpv1alpha1.ConditionTelemetryConfigRefValidated) + prevCondition := meta.FindStatusCondition(m.Status.Conditions, mcpv1beta1.ConditionTelemetryConfigRefValidated) needsUpdate := prevCondition == nil || prevCondition.Status != metav1.ConditionTrue meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTelemetryConfigRefValidated, + Type: mcpv1beta1.ConditionTelemetryConfigRefValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonTelemetryConfigRefValid, + Reason: mcpv1beta1.ConditionReasonTelemetryConfigRefValid, Message: fmt.Sprintf("MCPTelemetryConfig %s is valid", m.Spec.TelemetryConfigRef.Name), ObservedGeneration: m.Generation, }) @@ -112,13 +112,13 @@ func (r *MCPServerReconciler) handleTelemetryConfig(ctx context.Context, m *mcpv func getTelemetryConfigForMCPServer( ctx context.Context, c client.Client, - m *mcpv1alpha1.MCPServer, -) (*mcpv1alpha1.MCPTelemetryConfig, error) { + m *mcpv1beta1.MCPServer, +) (*mcpv1beta1.MCPTelemetryConfig, error) { if m.Spec.TelemetryConfigRef == nil { return nil, nil } - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{} + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{} err := c.Get(ctx, types.NamespacedName{ Name: m.Spec.TelemetryConfigRef.Name, Namespace: m.Namespace, @@ -138,12 +138,12 @@ func getTelemetryConfigForMCPServer( func (r *MCPServerReconciler) mapTelemetryConfigToServers( ctx context.Context, obj client.Object, ) []reconcile.Request { - telemetryConfig, ok := obj.(*mcpv1alpha1.MCPTelemetryConfig) + telemetryConfig, ok := obj.(*mcpv1beta1.MCPTelemetryConfig) if !ok { return nil } - mcpServerList := &mcpv1alpha1.MCPServerList{} + mcpServerList := &mcpv1beta1.MCPServerList{} if err := r.List(ctx, mcpServerList, client.InNamespace(telemetryConfig.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPServers for MCPTelemetryConfig watch") return nil diff --git a/cmd/thv-operator/controllers/mcpserver_telemetryconfig_test.go b/cmd/thv-operator/controllers/mcpserver_telemetryconfig_test.go index f8d2600bb6..27329efaf2 100644 --- a/cmd/thv-operator/controllers/mcpserver_telemetryconfig_test.go +++ b/cmd/thv-operator/controllers/mcpserver_telemetryconfig_test.go @@ -12,7 +12,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestGetTelemetryConfigForMCPServer(t *testing.T) { @@ -20,20 +20,20 @@ func TestGetTelemetryConfigForMCPServer(t *testing.T) { tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig + mcpServer *mcpv1beta1.MCPServer + telemetryConfig *mcpv1beta1.MCPTelemetryConfig expectNil bool expectError bool expectedConfigName string }{ { name: "nil ref returns nil without error", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ TelemetryConfigRef: nil, }, }, @@ -43,18 +43,18 @@ func TestGetTelemetryConfigForMCPServer(t *testing.T) { }, { name: "fetches the right config from the fake client", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + Spec: mcpv1beta1.MCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "my-telemetry-config", }, }, }, - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "my-telemetry-config", Namespace: "default", @@ -67,13 +67,13 @@ func TestGetTelemetryConfigForMCPServer(t *testing.T) { }, { name: "returns nil without error when not found", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + Spec: mcpv1beta1.MCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "non-existent-config", }, }, @@ -91,7 +91,7 @@ func TestGetTelemetryConfigForMCPServer(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) builder := fake.NewClientBuilder().WithScheme(scheme) if tt.telemetryConfig != nil { @@ -125,10 +125,10 @@ func TestGetTelemetryConfigForMCPServer_NamespacedLookup(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // Config exists in namespace-a but server is in namespace-b - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "shared-config", Namespace: "namespace-a", @@ -136,13 +136,13 @@ func TestGetTelemetryConfigForMCPServer_NamespacedLookup(t *testing.T) { Spec: newTelemetrySpec("https://otel-collector:4317", true, false), } - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "namespace-b", }, - Spec: mcpv1alpha1.MCPServerSpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + Spec: mcpv1beta1.MCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "shared-config", }, }, diff --git a/cmd/thv-operator/controllers/mcpserverentry_controller.go b/cmd/thv-operator/controllers/mcpserverentry_controller.go index 035497c61b..c34281470f 100644 --- a/cmd/thv-operator/controllers/mcpserverentry_controller.go +++ b/cmd/thv-operator/controllers/mcpserverentry_controller.go @@ -19,7 +19,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/validation" ) @@ -51,7 +51,7 @@ type MCPServerEntryReconciler struct { func (r *MCPServerEntryReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) - entry := &mcpv1alpha1.MCPServerEntry{} + entry := &mcpv1beta1.MCPServerEntry{} if err := r.Get(ctx, req.NamespacedName, entry); err != nil { if errors.IsNotFound(err) { ctxLogger.Info("MCPServerEntry resource not found. Ignoring since object must be deleted.") @@ -106,10 +106,10 @@ func (r *MCPServerEntryReconciler) SetupWithManager(mgr ctrl.Manager) error { // Set up field index for ExternalAuthConfigRef lookups if err := mgr.GetFieldIndexer().IndexField( context.Background(), - &mcpv1alpha1.MCPServerEntry{}, + &mcpv1beta1.MCPServerEntry{}, mcpServerEntryAuthConfigRefField, func(obj client.Object) []string { - entry := obj.(*mcpv1alpha1.MCPServerEntry) + entry := obj.(*mcpv1beta1.MCPServerEntry) if entry.Spec.ExternalAuthConfigRef == nil { return nil } @@ -123,10 +123,10 @@ func (r *MCPServerEntryReconciler) SetupWithManager(mgr ctrl.Manager) error { // Set up field index for CABundleRef ConfigMap lookups if err := mgr.GetFieldIndexer().IndexField( context.Background(), - &mcpv1alpha1.MCPServerEntry{}, + &mcpv1beta1.MCPServerEntry{}, mcpServerEntryCABundleRefField, func(obj client.Object) []string { - entry := obj.(*mcpv1alpha1.MCPServerEntry) + entry := obj.(*mcpv1beta1.MCPServerEntry) if entry.Spec.CABundleRef == nil || entry.Spec.CABundleRef.ConfigMapRef == nil { return nil } @@ -138,13 +138,13 @@ func (r *MCPServerEntryReconciler) SetupWithManager(mgr ctrl.Manager) error { } return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1alpha1.MCPServerEntry{}). + For(&mcpv1beta1.MCPServerEntry{}). Watches( - &mcpv1alpha1.MCPExternalAuthConfig{}, + &mcpv1beta1.MCPExternalAuthConfig{}, handler.EnqueueRequestsFromMapFunc(r.findEntriesForAuthConfig), ). Watches( - &mcpv1alpha1.MCPGroup{}, + &mcpv1beta1.MCPGroup{}, handler.EnqueueRequestsFromMapFunc(r.findEntriesForGroup), ). Watches( @@ -158,19 +158,19 @@ func (r *MCPServerEntryReconciler) SetupWithManager(mgr ctrl.Manager) error { // Returns (valid, error). A non-nil error means a transient failure that should be requeued. func (r *MCPServerEntryReconciler) validateGroupRef( ctx context.Context, - entry *mcpv1alpha1.MCPServerEntry, + entry *mcpv1beta1.MCPServerEntry, ) (bool, error) { ctxLogger := log.FromContext(ctx) groupName := entry.Spec.GroupRef.GetName() - group := &mcpv1alpha1.MCPGroup{} + group := &mcpv1beta1.MCPGroup{} groupKey := types.NamespacedName{Namespace: entry.Namespace, Name: groupName} if err := r.Get(ctx, groupKey, group); err != nil { if errors.IsNotFound(err) { meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServerEntryGroupRefValidated, + Type: mcpv1beta1.ConditionTypeMCPServerEntryGroupRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPServerEntryGroupRefNotFound, + Reason: mcpv1beta1.ConditionReasonMCPServerEntryGroupRefNotFound, Message: fmt.Sprintf("MCPGroup '%s' not found in namespace '%s'", groupName, entry.Namespace), ObservedGeneration: entry.Generation, }) @@ -181,11 +181,11 @@ func (r *MCPServerEntryReconciler) validateGroupRef( } // Check that the group is ready - if group.Status.Phase != mcpv1alpha1.MCPGroupPhaseReady { + if group.Status.Phase != mcpv1beta1.MCPGroupPhaseReady { meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServerEntryGroupRefValidated, + Type: mcpv1beta1.ConditionTypeMCPServerEntryGroupRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPServerEntryGroupRefNotReady, + Reason: mcpv1beta1.ConditionReasonMCPServerEntryGroupRefNotReady, Message: fmt.Sprintf("MCPGroup '%s' is not ready (current phase: %s)", groupName, group.Status.Phase), ObservedGeneration: entry.Generation, }) @@ -193,9 +193,9 @@ func (r *MCPServerEntryReconciler) validateGroupRef( } meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServerEntryGroupRefValidated, + Type: mcpv1beta1.ConditionTypeMCPServerEntryGroupRefValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonMCPServerEntryGroupRefValidated, + Reason: mcpv1beta1.ConditionReasonMCPServerEntryGroupRefValidated, Message: "Referenced MCPGroup exists and is ready", ObservedGeneration: entry.Generation, }) @@ -206,21 +206,21 @@ func (r *MCPServerEntryReconciler) validateGroupRef( // Returns (valid, error). A non-nil error means a transient failure that should be requeued. func (r *MCPServerEntryReconciler) validateExternalAuthConfigRef( ctx context.Context, - entry *mcpv1alpha1.MCPServerEntry, + entry *mcpv1beta1.MCPServerEntry, ) (bool, error) { ctxLogger := log.FromContext(ctx) if entry.Spec.ExternalAuthConfigRef == nil { meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServerEntryAuthConfigValidated, + Type: mcpv1beta1.ConditionTypeMCPServerEntryAuthConfigValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonMCPServerEntryAuthConfigNotConfigured, + Reason: mcpv1beta1.ConditionReasonMCPServerEntryAuthConfigNotConfigured, Message: "No external auth config reference configured", ObservedGeneration: entry.Generation, }) return true, nil } - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + authConfig := &mcpv1beta1.MCPExternalAuthConfig{} authKey := types.NamespacedName{ Namespace: entry.Namespace, Name: entry.Spec.ExternalAuthConfigRef.Name, @@ -229,9 +229,9 @@ func (r *MCPServerEntryReconciler) validateExternalAuthConfigRef( if err := r.Get(ctx, authKey, authConfig); err != nil { if errors.IsNotFound(err) { meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServerEntryAuthConfigValidated, + Type: mcpv1beta1.ConditionTypeMCPServerEntryAuthConfigValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPServerEntryAuthConfigNotFound, + Reason: mcpv1beta1.ConditionReasonMCPServerEntryAuthConfigNotFound, Message: "Referenced MCPExternalAuthConfig not found", ObservedGeneration: entry.Generation, }) @@ -242,9 +242,9 @@ func (r *MCPServerEntryReconciler) validateExternalAuthConfigRef( } meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServerEntryAuthConfigValidated, + Type: mcpv1beta1.ConditionTypeMCPServerEntryAuthConfigValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonMCPServerEntryAuthConfigValid, + Reason: mcpv1beta1.ConditionReasonMCPServerEntryAuthConfigValid, Message: "Referenced MCPExternalAuthConfig exists", ObservedGeneration: entry.Generation, }) @@ -255,14 +255,14 @@ func (r *MCPServerEntryReconciler) validateExternalAuthConfigRef( // Returns (valid, error). A non-nil error means a transient failure that should be requeued. func (r *MCPServerEntryReconciler) validateCABundleRef( ctx context.Context, - entry *mcpv1alpha1.MCPServerEntry, + entry *mcpv1beta1.MCPServerEntry, ) (bool, error) { ctxLogger := log.FromContext(ctx) if entry.Spec.CABundleRef == nil || entry.Spec.CABundleRef.ConfigMapRef == nil { meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServerEntryCABundleRefValidated, + Type: mcpv1beta1.ConditionTypeMCPServerEntryCABundleRefValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonMCPServerEntryCABundleRefNotConfigured, + Reason: mcpv1beta1.ConditionReasonMCPServerEntryCABundleRefNotConfigured, Message: "No CA bundle reference configured", ObservedGeneration: entry.Generation, }) @@ -278,9 +278,9 @@ func (r *MCPServerEntryReconciler) validateCABundleRef( if err := r.Get(ctx, cmKey, configMap); err != nil { if errors.IsNotFound(err) { meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServerEntryCABundleRefValidated, + Type: mcpv1beta1.ConditionTypeMCPServerEntryCABundleRefValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPServerEntryCABundleRefNotFound, + Reason: mcpv1beta1.ConditionReasonMCPServerEntryCABundleRefNotFound, Message: "Referenced CA bundle ConfigMap not found", ObservedGeneration: entry.Generation, }) @@ -291,9 +291,9 @@ func (r *MCPServerEntryReconciler) validateCABundleRef( } meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServerEntryCABundleRefValidated, + Type: mcpv1beta1.ConditionTypeMCPServerEntryCABundleRefValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonMCPServerEntryCABundleRefValid, + Reason: mcpv1beta1.ConditionReasonMCPServerEntryCABundleRefValid, Message: "Referenced CA bundle ConfigMap exists", ObservedGeneration: entry.Generation, }) @@ -303,13 +303,13 @@ func (r *MCPServerEntryReconciler) validateCABundleRef( // validateRemoteURL checks that the RemoteURL is well-formed and does not target // a blocked internal or metadata endpoint (SSRF protection). func (*MCPServerEntryReconciler) validateRemoteURL( - entry *mcpv1alpha1.MCPServerEntry, + entry *mcpv1beta1.MCPServerEntry, ) bool { if err := validation.ValidateRemoteURL(entry.Spec.RemoteURL); err != nil { meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServerEntryRemoteURLValidated, + Type: mcpv1beta1.ConditionTypeMCPServerEntryRemoteURLValidated, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPServerEntryRemoteURLInvalid, + Reason: mcpv1beta1.ConditionReasonMCPServerEntryRemoteURLInvalid, Message: err.Error(), ObservedGeneration: entry.Generation, }) @@ -317,9 +317,9 @@ func (*MCPServerEntryReconciler) validateRemoteURL( } meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServerEntryRemoteURLValidated, + Type: mcpv1beta1.ConditionTypeMCPServerEntryRemoteURLValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonMCPServerEntryRemoteURLValid, + Reason: mcpv1beta1.ConditionReasonMCPServerEntryRemoteURLValid, Message: "Remote URL is valid", ObservedGeneration: entry.Generation, }) @@ -328,26 +328,26 @@ func (*MCPServerEntryReconciler) validateRemoteURL( // updateOverallStatus sets the phase and Valid condition based on validation results. func (*MCPServerEntryReconciler) updateOverallStatus( - entry *mcpv1alpha1.MCPServerEntry, + entry *mcpv1beta1.MCPServerEntry, allValid bool, ) { if allValid { - entry.Status.Phase = mcpv1alpha1.MCPServerEntryPhaseValid + entry.Status.Phase = mcpv1beta1.MCPServerEntryPhaseValid meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServerEntryValid, + Type: mcpv1beta1.ConditionTypeMCPServerEntryValid, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonMCPServerEntryValid, + Reason: mcpv1beta1.ConditionReasonMCPServerEntryValid, Message: "All referenced resources are valid", ObservedGeneration: entry.Generation, }) return } - entry.Status.Phase = mcpv1alpha1.MCPServerEntryPhaseFailed + entry.Status.Phase = mcpv1beta1.MCPServerEntryPhaseFailed meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeMCPServerEntryValid, + Type: mcpv1beta1.ConditionTypeMCPServerEntryValid, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonMCPServerEntryInvalid, + Reason: mcpv1beta1.ConditionReasonMCPServerEntryInvalid, Message: "One or more referenced resources are missing or invalid", ObservedGeneration: entry.Generation, }) @@ -360,13 +360,13 @@ func (r *MCPServerEntryReconciler) findEntriesForAuthConfig( ) []reconcile.Request { ctxLogger := log.FromContext(ctx) - authConfig, ok := obj.(*mcpv1alpha1.MCPExternalAuthConfig) + authConfig, ok := obj.(*mcpv1beta1.MCPExternalAuthConfig) if !ok { ctxLogger.Error(nil, "Object is not an MCPExternalAuthConfig", "object", obj.GetName()) return nil } - entryList := &mcpv1alpha1.MCPServerEntryList{} + entryList := &mcpv1beta1.MCPServerEntryList{} if err := r.List(ctx, entryList, client.InNamespace(authConfig.Namespace), client.MatchingFields{mcpServerEntryAuthConfigRefField: authConfig.Name}, @@ -394,13 +394,13 @@ func (r *MCPServerEntryReconciler) findEntriesForGroup( ) []reconcile.Request { ctxLogger := log.FromContext(ctx) - group, ok := obj.(*mcpv1alpha1.MCPGroup) + group, ok := obj.(*mcpv1beta1.MCPGroup) if !ok { ctxLogger.Error(nil, "Object is not an MCPGroup", "object", obj.GetName()) return nil } - entryList := &mcpv1alpha1.MCPServerEntryList{} + entryList := &mcpv1beta1.MCPServerEntryList{} if err := r.List(ctx, entryList, client.InNamespace(group.Namespace), client.MatchingFields{"spec.groupRef": group.Name}, @@ -435,7 +435,7 @@ func (r *MCPServerEntryReconciler) findEntriesForConfigMap( return nil } - entryList := &mcpv1alpha1.MCPServerEntryList{} + entryList := &mcpv1beta1.MCPServerEntryList{} if err := r.List(ctx, entryList, client.InNamespace(cm.Namespace), client.MatchingFields{mcpServerEntryCABundleRefField: cm.Name}, diff --git a/cmd/thv-operator/controllers/mcpserverentry_controller_test.go b/cmd/thv-operator/controllers/mcpserverentry_controller_test.go index 6da50ffa3b..97b01014fd 100644 --- a/cmd/thv-operator/controllers/mcpserverentry_controller_test.go +++ b/cmd/thv-operator/controllers/mcpserverentry_controller_test.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) const ( @@ -32,7 +32,7 @@ const ( func newEntryScheme(t *testing.T) *runtime.Scheme { t.Helper() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) return scheme } @@ -43,18 +43,18 @@ func newEntryFakeClient(t *testing.T, scheme *runtime.Scheme, objs ...client.Obj return fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPServerEntry{}). + WithStatusSubresource(&mcpv1beta1.MCPServerEntry{}). Build() } // newMCPGroup creates a minimal MCPGroup with the given phase. -func newMCPGroup(phase mcpv1alpha1.MCPGroupPhase) *mcpv1alpha1.MCPGroup { - return &mcpv1alpha1.MCPGroup{ +func newMCPGroup(phase mcpv1beta1.MCPGroupPhase) *mcpv1beta1.MCPGroup { + return &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testEntryGroupRef, Namespace: testEntryNS, }, - Status: mcpv1alpha1.MCPGroupStatus{ + Status: mcpv1beta1.MCPGroupStatus{ Phase: phase, }, } @@ -63,18 +63,18 @@ func newMCPGroup(phase mcpv1alpha1.MCPGroupPhase) *mcpv1alpha1.MCPGroup { // newMCPServerEntry creates an MCPServerEntry with optional auth config and CA bundle refs. func newMCPServerEntry( groupRef string, - authConfigRef *mcpv1alpha1.ExternalAuthConfigRef, - caBundleRef *mcpv1alpha1.CABundleSource, -) *mcpv1alpha1.MCPServerEntry { - return &mcpv1alpha1.MCPServerEntry{ + authConfigRef *mcpv1beta1.ExternalAuthConfigRef, + caBundleRef *mcpv1beta1.CABundleSource, +) *mcpv1beta1.MCPServerEntry { + return &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: testEntryName, Namespace: testEntryNS, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://example.com/mcp", Transport: "sse", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupRef}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupRef}, ExternalAuthConfigRef: authConfigRef, CABundleRef: caBundleRef, }, @@ -82,14 +82,14 @@ func newMCPServerEntry( } // newMCPExternalAuthConfig creates a minimal MCPExternalAuthConfig object. -func newMCPExternalAuthConfig(name, namespace string) *mcpv1alpha1.MCPExternalAuthConfig { - return &mcpv1alpha1.MCPExternalAuthConfig{ +func newMCPExternalAuthConfig(name, namespace string) *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUnauthenticated, }, } } @@ -128,10 +128,10 @@ func TestMCPServerEntryReconciler_Reconcile(t *testing.T) { tests := []struct { name string // objects to seed the fake client (entry is always first) - entry *mcpv1alpha1.MCPServerEntry + entry *mcpv1beta1.MCPServerEntry objects []client.Object wantErr bool - wantPhase mcpv1alpha1.MCPServerEntryPhase + wantPhase mcpv1beta1.MCPServerEntryPhase conditions []struct { condType string status metav1.ConditionStatus @@ -141,8 +141,8 @@ func TestMCPServerEntryReconciler_Reconcile(t *testing.T) { { name: "happy path - all refs valid", entry: newMCPServerEntry(testEntryGroupRef, - &mcpv1alpha1.ExternalAuthConfigRef{Name: testAuthConfig}, - &mcpv1alpha1.CABundleSource{ + &mcpv1beta1.ExternalAuthConfigRef{Name: testAuthConfig}, + &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: testCAConfigMap}, Key: "ca.crt", @@ -150,50 +150,50 @@ func TestMCPServerEntryReconciler_Reconcile(t *testing.T) { }, ), objects: []client.Object{ - newMCPGroup(mcpv1alpha1.MCPGroupPhaseReady), + newMCPGroup(mcpv1beta1.MCPGroupPhaseReady), newMCPExternalAuthConfig(testAuthConfig, testEntryNS), newConfigMap(testCAConfigMap, testEntryNS), }, - wantPhase: mcpv1alpha1.MCPServerEntryPhaseValid, + wantPhase: mcpv1beta1.MCPServerEntryPhaseValid, conditions: []struct { condType string status metav1.ConditionStatus reason string }{ - {mcpv1alpha1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonMCPServerEntryGroupRefValidated}, - {mcpv1alpha1.ConditionTypeMCPServerEntryAuthConfigValidated, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonMCPServerEntryAuthConfigValid}, - {mcpv1alpha1.ConditionTypeMCPServerEntryCABundleRefValidated, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonMCPServerEntryCABundleRefValid}, - {mcpv1alpha1.ConditionTypeMCPServerEntryValid, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonMCPServerEntryValid}, + {mcpv1beta1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionTrue, mcpv1beta1.ConditionReasonMCPServerEntryGroupRefValidated}, + {mcpv1beta1.ConditionTypeMCPServerEntryAuthConfigValidated, metav1.ConditionTrue, mcpv1beta1.ConditionReasonMCPServerEntryAuthConfigValid}, + {mcpv1beta1.ConditionTypeMCPServerEntryCABundleRefValidated, metav1.ConditionTrue, mcpv1beta1.ConditionReasonMCPServerEntryCABundleRefValid}, + {mcpv1beta1.ConditionTypeMCPServerEntryValid, metav1.ConditionTrue, mcpv1beta1.ConditionReasonMCPServerEntryValid}, }, }, { name: "happy path - optional refs nil", entry: newMCPServerEntry(testEntryGroupRef, nil, nil), - objects: []client.Object{newMCPGroup(mcpv1alpha1.MCPGroupPhaseReady)}, - wantPhase: mcpv1alpha1.MCPServerEntryPhaseValid, + objects: []client.Object{newMCPGroup(mcpv1beta1.MCPGroupPhaseReady)}, + wantPhase: mcpv1beta1.MCPServerEntryPhaseValid, conditions: []struct { condType string status metav1.ConditionStatus reason string }{ - {mcpv1alpha1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonMCPServerEntryGroupRefValidated}, - {mcpv1alpha1.ConditionTypeMCPServerEntryAuthConfigValidated, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonMCPServerEntryAuthConfigNotConfigured}, - {mcpv1alpha1.ConditionTypeMCPServerEntryCABundleRefValidated, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonMCPServerEntryCABundleRefNotConfigured}, - {mcpv1alpha1.ConditionTypeMCPServerEntryValid, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonMCPServerEntryValid}, + {mcpv1beta1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionTrue, mcpv1beta1.ConditionReasonMCPServerEntryGroupRefValidated}, + {mcpv1beta1.ConditionTypeMCPServerEntryAuthConfigValidated, metav1.ConditionTrue, mcpv1beta1.ConditionReasonMCPServerEntryAuthConfigNotConfigured}, + {mcpv1beta1.ConditionTypeMCPServerEntryCABundleRefValidated, metav1.ConditionTrue, mcpv1beta1.ConditionReasonMCPServerEntryCABundleRefNotConfigured}, + {mcpv1beta1.ConditionTypeMCPServerEntryValid, metav1.ConditionTrue, mcpv1beta1.ConditionReasonMCPServerEntryValid}, }, }, { name: "group ref not found", entry: newMCPServerEntry("nonexistent-group", nil, nil), objects: []client.Object{}, - wantPhase: mcpv1alpha1.MCPServerEntryPhaseFailed, + wantPhase: mcpv1beta1.MCPServerEntryPhaseFailed, conditions: []struct { condType string status metav1.ConditionStatus reason string }{ - {mcpv1alpha1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonMCPServerEntryGroupRefNotFound}, - {mcpv1alpha1.ConditionTypeMCPServerEntryValid, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonMCPServerEntryInvalid}, + {mcpv1beta1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPServerEntryGroupRefNotFound}, + {mcpv1beta1.ConditionTypeMCPServerEntryValid, metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPServerEntryInvalid}, }, }, { @@ -201,109 +201,109 @@ func TestMCPServerEntryReconciler_Reconcile(t *testing.T) { entry: newMCPServerEntry(testEntryGroupRef, nil, nil), // MCPGroup exists but has empty phase (not Ready) objects: []client.Object{newMCPGroup("")}, - wantPhase: mcpv1alpha1.MCPServerEntryPhaseFailed, + wantPhase: mcpv1beta1.MCPServerEntryPhaseFailed, conditions: []struct { condType string status metav1.ConditionStatus reason string }{ - {mcpv1alpha1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonMCPServerEntryGroupRefNotReady}, - {mcpv1alpha1.ConditionTypeMCPServerEntryValid, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonMCPServerEntryInvalid}, + {mcpv1beta1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPServerEntryGroupRefNotReady}, + {mcpv1beta1.ConditionTypeMCPServerEntryValid, metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPServerEntryInvalid}, }, }, { name: "auth config ref not found", entry: newMCPServerEntry(testEntryGroupRef, - &mcpv1alpha1.ExternalAuthConfigRef{Name: "nonexistent-auth"}, + &mcpv1beta1.ExternalAuthConfigRef{Name: "nonexistent-auth"}, nil, ), - objects: []client.Object{newMCPGroup(mcpv1alpha1.MCPGroupPhaseReady)}, - wantPhase: mcpv1alpha1.MCPServerEntryPhaseFailed, + objects: []client.Object{newMCPGroup(mcpv1beta1.MCPGroupPhaseReady)}, + wantPhase: mcpv1beta1.MCPServerEntryPhaseFailed, conditions: []struct { condType string status metav1.ConditionStatus reason string }{ - {mcpv1alpha1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonMCPServerEntryGroupRefValidated}, - {mcpv1alpha1.ConditionTypeMCPServerEntryAuthConfigValidated, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonMCPServerEntryAuthConfigNotFound}, - {mcpv1alpha1.ConditionTypeMCPServerEntryValid, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonMCPServerEntryInvalid}, + {mcpv1beta1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionTrue, mcpv1beta1.ConditionReasonMCPServerEntryGroupRefValidated}, + {mcpv1beta1.ConditionTypeMCPServerEntryAuthConfigValidated, metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPServerEntryAuthConfigNotFound}, + {mcpv1beta1.ConditionTypeMCPServerEntryValid, metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPServerEntryInvalid}, }, }, { name: "CA bundle ref not found", entry: newMCPServerEntry(testEntryGroupRef, nil, - &mcpv1alpha1.CABundleSource{ + &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "nonexistent-cm"}, Key: "ca.crt", }, }, ), - objects: []client.Object{newMCPGroup(mcpv1alpha1.MCPGroupPhaseReady)}, - wantPhase: mcpv1alpha1.MCPServerEntryPhaseFailed, + objects: []client.Object{newMCPGroup(mcpv1beta1.MCPGroupPhaseReady)}, + wantPhase: mcpv1beta1.MCPServerEntryPhaseFailed, conditions: []struct { condType string status metav1.ConditionStatus reason string }{ - {mcpv1alpha1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonMCPServerEntryGroupRefValidated}, - {mcpv1alpha1.ConditionTypeMCPServerEntryCABundleRefValidated, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonMCPServerEntryCABundleRefNotFound}, - {mcpv1alpha1.ConditionTypeMCPServerEntryValid, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonMCPServerEntryInvalid}, + {mcpv1beta1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionTrue, mcpv1beta1.ConditionReasonMCPServerEntryGroupRefValidated}, + {mcpv1beta1.ConditionTypeMCPServerEntryCABundleRefValidated, metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPServerEntryCABundleRefNotFound}, + {mcpv1beta1.ConditionTypeMCPServerEntryValid, metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPServerEntryInvalid}, }, }, { name: "SSRF - loopback IP rejected", - entry: func() *mcpv1alpha1.MCPServerEntry { + entry: func() *mcpv1beta1.MCPServerEntry { e := newMCPServerEntry(testEntryGroupRef, nil, nil) e.Spec.RemoteURL = "http://127.0.0.1:8080/" return e }(), - objects: []client.Object{newMCPGroup(mcpv1alpha1.MCPGroupPhaseReady)}, - wantPhase: mcpv1alpha1.MCPServerEntryPhaseFailed, + objects: []client.Object{newMCPGroup(mcpv1beta1.MCPGroupPhaseReady)}, + wantPhase: mcpv1beta1.MCPServerEntryPhaseFailed, conditions: []struct { condType string status metav1.ConditionStatus reason string }{ - {mcpv1alpha1.ConditionTypeMCPServerEntryRemoteURLValidated, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonMCPServerEntryRemoteURLInvalid}, - {mcpv1alpha1.ConditionTypeMCPServerEntryValid, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonMCPServerEntryInvalid}, + {mcpv1beta1.ConditionTypeMCPServerEntryRemoteURLValidated, metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPServerEntryRemoteURLInvalid}, + {mcpv1beta1.ConditionTypeMCPServerEntryValid, metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPServerEntryInvalid}, }, }, { name: "SSRF - metadata endpoint rejected", - entry: func() *mcpv1alpha1.MCPServerEntry { + entry: func() *mcpv1beta1.MCPServerEntry { e := newMCPServerEntry(testEntryGroupRef, nil, nil) e.Spec.RemoteURL = "http://169.254.169.254/latest/meta-data/" return e }(), - objects: []client.Object{newMCPGroup(mcpv1alpha1.MCPGroupPhaseReady)}, - wantPhase: mcpv1alpha1.MCPServerEntryPhaseFailed, + objects: []client.Object{newMCPGroup(mcpv1beta1.MCPGroupPhaseReady)}, + wantPhase: mcpv1beta1.MCPServerEntryPhaseFailed, conditions: []struct { condType string status metav1.ConditionStatus reason string }{ - {mcpv1alpha1.ConditionTypeMCPServerEntryRemoteURLValidated, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonMCPServerEntryRemoteURLInvalid}, - {mcpv1alpha1.ConditionTypeMCPServerEntryValid, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonMCPServerEntryInvalid}, + {mcpv1beta1.ConditionTypeMCPServerEntryRemoteURLValidated, metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPServerEntryRemoteURLInvalid}, + {mcpv1beta1.ConditionTypeMCPServerEntryValid, metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPServerEntryInvalid}, }, }, { name: "SSRF - kubernetes.default.svc rejected", - entry: func() *mcpv1alpha1.MCPServerEntry { + entry: func() *mcpv1beta1.MCPServerEntry { e := newMCPServerEntry(testEntryGroupRef, nil, nil) e.Spec.RemoteURL = "http://kubernetes.default.svc/" return e }(), - objects: []client.Object{newMCPGroup(mcpv1alpha1.MCPGroupPhaseReady)}, - wantPhase: mcpv1alpha1.MCPServerEntryPhaseFailed, + objects: []client.Object{newMCPGroup(mcpv1beta1.MCPGroupPhaseReady)}, + wantPhase: mcpv1beta1.MCPServerEntryPhaseFailed, conditions: []struct { condType string status metav1.ConditionStatus reason string }{ - {mcpv1alpha1.ConditionTypeMCPServerEntryRemoteURLValidated, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonMCPServerEntryRemoteURLInvalid}, - {mcpv1alpha1.ConditionTypeMCPServerEntryValid, metav1.ConditionFalse, mcpv1alpha1.ConditionReasonMCPServerEntryInvalid}, + {mcpv1beta1.ConditionTypeMCPServerEntryRemoteURLValidated, metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPServerEntryRemoteURLInvalid}, + {mcpv1beta1.ConditionTypeMCPServerEntryValid, metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPServerEntryInvalid}, }, }, { @@ -315,19 +315,19 @@ func TestMCPServerEntryReconciler_Reconcile(t *testing.T) { name: "CA bundle ref with nil configMapRef treated as not configured", entry: newMCPServerEntry(testEntryGroupRef, nil, - &mcpv1alpha1.CABundleSource{ConfigMapRef: nil}, + &mcpv1beta1.CABundleSource{ConfigMapRef: nil}, ), - objects: []client.Object{newMCPGroup(mcpv1alpha1.MCPGroupPhaseReady)}, - wantPhase: mcpv1alpha1.MCPServerEntryPhaseValid, + objects: []client.Object{newMCPGroup(mcpv1beta1.MCPGroupPhaseReady)}, + wantPhase: mcpv1beta1.MCPServerEntryPhaseValid, conditions: []struct { condType string status metav1.ConditionStatus reason string }{ - {mcpv1alpha1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonMCPServerEntryGroupRefValidated}, - {mcpv1alpha1.ConditionTypeMCPServerEntryAuthConfigValidated, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonMCPServerEntryAuthConfigNotConfigured}, - {mcpv1alpha1.ConditionTypeMCPServerEntryCABundleRefValidated, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonMCPServerEntryCABundleRefNotConfigured}, - {mcpv1alpha1.ConditionTypeMCPServerEntryValid, metav1.ConditionTrue, mcpv1alpha1.ConditionReasonMCPServerEntryValid}, + {mcpv1beta1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionTrue, mcpv1beta1.ConditionReasonMCPServerEntryGroupRefValidated}, + {mcpv1beta1.ConditionTypeMCPServerEntryAuthConfigValidated, metav1.ConditionTrue, mcpv1beta1.ConditionReasonMCPServerEntryAuthConfigNotConfigured}, + {mcpv1beta1.ConditionTypeMCPServerEntryCABundleRefValidated, metav1.ConditionTrue, mcpv1beta1.ConditionReasonMCPServerEntryCABundleRefNotConfigured}, + {mcpv1beta1.ConditionTypeMCPServerEntryValid, metav1.ConditionTrue, mcpv1beta1.ConditionReasonMCPServerEntryValid}, }, }, } @@ -379,7 +379,7 @@ func TestMCPServerEntryReconciler_Reconcile(t *testing.T) { assert.Zero(t, result.RequeueAfter, "Should not requeue on success") // Fetch the updated entry from the fake client - var updatedEntry mcpv1alpha1.MCPServerEntry + var updatedEntry mcpv1beta1.MCPServerEntry err = fakeClient.Get(ctx, req.NamespacedName, &updatedEntry) require.NoError(t, err) @@ -400,41 +400,41 @@ func TestMCPGroupReconciler_MCPServerEntryIntegration(t *testing.T) { ctx := t.Context() scheme := newEntryScheme(t) - group := &mcpv1alpha1.MCPGroup{ + group := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testEntryGroupRef, Namespace: testEntryNS, }, } - entry1 := &mcpv1alpha1.MCPServerEntry{ + entry1 := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{Name: "entry1", Namespace: testEntryNS}, - Spec: mcpv1alpha1.MCPServerEntrySpec{RemoteURL: "https://a.example.com", Transport: "sse", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testEntryGroupRef}}, + Spec: mcpv1beta1.MCPServerEntrySpec{RemoteURL: "https://a.example.com", Transport: "sse", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testEntryGroupRef}}, } - entry2 := &mcpv1alpha1.MCPServerEntry{ + entry2 := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{Name: "entry2", Namespace: testEntryNS}, - Spec: mcpv1alpha1.MCPServerEntrySpec{RemoteURL: "https://b.example.com", Transport: "sse", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testEntryGroupRef}}, + Spec: mcpv1beta1.MCPServerEntrySpec{RemoteURL: "https://b.example.com", Transport: "sse", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testEntryGroupRef}}, } fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(group, entry1, entry2). - WithStatusSubresource(&mcpv1alpha1.MCPGroup{}, &mcpv1alpha1.MCPServerEntry{}). - WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - s := obj.(*mcpv1alpha1.MCPServer) + WithStatusSubresource(&mcpv1beta1.MCPGroup{}, &mcpv1beta1.MCPServerEntry{}). + WithIndex(&mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + s := obj.(*mcpv1beta1.MCPServer) if s.Spec.GroupRef.GetName() == "" { return nil } return []string{s.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - p := obj.(*mcpv1alpha1.MCPRemoteProxy) + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + p := obj.(*mcpv1beta1.MCPRemoteProxy) if p.Spec.GroupRef.GetName() == "" { return nil } return []string{p.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - e := obj.(*mcpv1alpha1.MCPServerEntry) + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + e := obj.(*mcpv1beta1.MCPServerEntry) if e.Spec.GroupRef.GetName() == "" { return nil } @@ -461,11 +461,11 @@ func TestMCPGroupReconciler_MCPServerEntryIntegration(t *testing.T) { require.NoError(t, err) assert.Zero(t, result.RequeueAfter, "Should not requeue") - var updatedGroup mcpv1alpha1.MCPGroup + var updatedGroup mcpv1beta1.MCPGroup err = fakeClient.Get(ctx, req.NamespacedName, &updatedGroup) require.NoError(t, err) - assert.Equal(t, mcpv1alpha1.MCPGroupPhaseReady, updatedGroup.Status.Phase) + assert.Equal(t, mcpv1beta1.MCPGroupPhaseReady, updatedGroup.Status.Phase) assert.Equal(t, int32(2), updatedGroup.Status.EntryCount) assert.ElementsMatch(t, []string{"entry1", "entry2"}, updatedGroup.Status.Entries) } @@ -478,38 +478,38 @@ func TestMCPGroupReconciler_EntryDeletionHandler(t *testing.T) { ctx := t.Context() scheme := newEntryScheme(t) - entry1 := &mcpv1alpha1.MCPServerEntry{ + entry1 := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{Name: "entry1", Namespace: testEntryNS}, - Spec: mcpv1alpha1.MCPServerEntrySpec{RemoteURL: "https://a.example.com", Transport: "sse", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testEntryGroupRef}}, + Spec: mcpv1beta1.MCPServerEntrySpec{RemoteURL: "https://a.example.com", Transport: "sse", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testEntryGroupRef}}, } - entry2 := &mcpv1alpha1.MCPServerEntry{ + entry2 := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{Name: "entry2", Namespace: testEntryNS}, - Spec: mcpv1alpha1.MCPServerEntrySpec{RemoteURL: "https://b.example.com", Transport: "sse", GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testEntryGroupRef}}, + Spec: mcpv1beta1.MCPServerEntrySpec{RemoteURL: "https://b.example.com", Transport: "sse", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testEntryGroupRef}}, } fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(entry1, entry2). - WithStatusSubresource(&mcpv1alpha1.MCPServerEntry{}). + WithStatusSubresource(&mcpv1beta1.MCPServerEntry{}). Build() r := &MCPGroupReconciler{Client: fakeClient} // Build the slice of entries as the controller would receive them - entries := []mcpv1alpha1.MCPServerEntry{*entry1, *entry2} + entries := []mcpv1beta1.MCPServerEntry{*entry1, *entry2} r.updateReferencingEntriesOnDeletion(ctx, entries, testEntryGroupRef) // Verify both entries have the GroupRefValidated condition set to False for _, entryName := range []string{"entry1", "entry2"} { - var updated mcpv1alpha1.MCPServerEntry + var updated mcpv1beta1.MCPServerEntry err := fakeClient.Get(ctx, types.NamespacedName{Name: entryName, Namespace: testEntryNS}, &updated) require.NoError(t, err, "should be able to fetch entry %s", entryName) assertCondition(t, updated.Status.Conditions, - mcpv1alpha1.ConditionTypeMCPServerEntryGroupRefValidated, + mcpv1beta1.ConditionTypeMCPServerEntryGroupRefValidated, metav1.ConditionFalse, - mcpv1alpha1.ConditionReasonMCPServerEntryGroupRefNotFound, + mcpv1beta1.ConditionReasonMCPServerEntryGroupRefNotFound, ) } } diff --git a/cmd/thv-operator/controllers/mcptelemetryconfig_controller.go b/cmd/thv-operator/controllers/mcptelemetryconfig_controller.go index 976c9c8bc8..72d6e6e81b 100644 --- a/cmd/thv-operator/controllers/mcptelemetryconfig_controller.go +++ b/cmd/thv-operator/controllers/mcptelemetryconfig_controller.go @@ -20,7 +20,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -53,7 +53,7 @@ func (r *MCPTelemetryConfigReconciler) Reconcile(ctx context.Context, req ctrl.R logger := log.FromContext(ctx) // Fetch the MCPTelemetryConfig instance - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{} + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{} err := r.Get(ctx, req.NamespacedName, telemetryConfig) if err != nil { if errors.IsNotFound(err) { @@ -83,7 +83,7 @@ func (r *MCPTelemetryConfigReconciler) Reconcile(ctx context.Context, req ctrl.R if err := telemetryConfig.Validate(); err != nil { logger.Error(err, "MCPTelemetryConfig spec validation failed") meta.SetStatusCondition(&telemetryConfig.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeValid, + Type: mcpv1beta1.ConditionTypeValid, Status: metav1.ConditionFalse, Reason: "ValidationFailed", Message: err.Error(), @@ -97,7 +97,7 @@ func (r *MCPTelemetryConfigReconciler) Reconcile(ctx context.Context, req ctrl.R // Validation succeeded - set Valid=True condition conditionChanged := meta.SetStatusCondition(&telemetryConfig.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeValid, + Type: mcpv1beta1.ConditionTypeValid, Status: metav1.ConditionTrue, Reason: "ValidationSucceeded", Message: "Spec validation passed", @@ -149,7 +149,7 @@ func (r *MCPTelemetryConfigReconciler) SetupWithManager(mgr ctrl.Manager) error // config needs to reconcile and clean up the stale entry). mcpServerHandler := handler.EnqueueRequestsFromMapFunc( func(ctx context.Context, obj client.Object) []reconcile.Request { - server, ok := obj.(*mcpv1alpha1.MCPServer) + server, ok := obj.(*mcpv1beta1.MCPServer) if !ok { return nil } @@ -169,7 +169,7 @@ func (r *MCPTelemetryConfigReconciler) SetupWithManager(mgr ctrl.Manager) error // Also enqueue any MCPTelemetryConfig that still lists this server in // ReferencingWorkloads — handles ref-removal and server-deletion cases. - telemetryConfigList := &mcpv1alpha1.MCPTelemetryConfigList{} + telemetryConfigList := &mcpv1beta1.MCPTelemetryConfigList{} if err := r.List(ctx, telemetryConfigList, client.InNamespace(server.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPTelemetryConfigs for MCPServer watch") return requests @@ -180,7 +180,7 @@ func (r *MCPTelemetryConfigReconciler) SetupWithManager(mgr ctrl.Manager) error continue } for _, ref := range cfg.Status.ReferencingWorkloads { - if ref.Kind == mcpv1alpha1.WorkloadKindMCPServer && ref.Name == server.Name { + if ref.Kind == mcpv1beta1.WorkloadKindMCPServer && ref.Name == server.Name { requests = append(requests, reconcile.Request{NamespacedName: nn}) break } @@ -192,14 +192,14 @@ func (r *MCPTelemetryConfigReconciler) SetupWithManager(mgr ctrl.Manager) error ) return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1alpha1.MCPTelemetryConfig{}). - Watches(&mcpv1alpha1.MCPServer{}, mcpServerHandler). + For(&mcpv1beta1.MCPTelemetryConfig{}). + Watches(&mcpv1beta1.MCPServer{}, mcpServerHandler). Watches( - &mcpv1alpha1.MCPRemoteProxy{}, + &mcpv1beta1.MCPRemoteProxy{}, handler.EnqueueRequestsFromMapFunc(r.mapMCPRemoteProxyToTelemetryConfig), ). Watches( - &mcpv1alpha1.VirtualMCPServer{}, + &mcpv1beta1.VirtualMCPServer{}, handler.EnqueueRequestsFromMapFunc(r.mapVirtualMCPServerToTelemetryConfig), ). Complete(r) @@ -211,7 +211,7 @@ func (r *MCPTelemetryConfigReconciler) SetupWithManager(mgr ctrl.Manager) error func (r *MCPTelemetryConfigReconciler) mapMCPRemoteProxyToTelemetryConfig( ctx context.Context, obj client.Object, ) []reconcile.Request { - proxy, ok := obj.(*mcpv1alpha1.MCPRemoteProxy) + proxy, ok := obj.(*mcpv1beta1.MCPRemoteProxy) if !ok { return nil } @@ -230,7 +230,7 @@ func (r *MCPTelemetryConfigReconciler) mapMCPRemoteProxyToTelemetryConfig( // Also enqueue any MCPTelemetryConfig that still lists this proxy in // ReferencingWorkloads — handles ref-removal and proxy-deletion cases. - telemetryConfigList := &mcpv1alpha1.MCPTelemetryConfigList{} + telemetryConfigList := &mcpv1beta1.MCPTelemetryConfigList{} if err := r.List(ctx, telemetryConfigList, client.InNamespace(proxy.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPTelemetryConfigs for MCPRemoteProxy watch") return requests @@ -241,7 +241,7 @@ func (r *MCPTelemetryConfigReconciler) mapMCPRemoteProxyToTelemetryConfig( continue } for _, ref := range cfg.Status.ReferencingWorkloads { - if ref.Kind == mcpv1alpha1.WorkloadKindMCPRemoteProxy && ref.Name == proxy.Name { + if ref.Kind == mcpv1beta1.WorkloadKindMCPRemoteProxy && ref.Name == proxy.Name { requests = append(requests, reconcile.Request{NamespacedName: nn}) break } @@ -257,7 +257,7 @@ func (r *MCPTelemetryConfigReconciler) mapMCPRemoteProxyToTelemetryConfig( func (r *MCPTelemetryConfigReconciler) mapVirtualMCPServerToTelemetryConfig( ctx context.Context, obj client.Object, ) []reconcile.Request { - vmcp, ok := obj.(*mcpv1alpha1.VirtualMCPServer) + vmcp, ok := obj.(*mcpv1beta1.VirtualMCPServer) if !ok { return nil } @@ -276,7 +276,7 @@ func (r *MCPTelemetryConfigReconciler) mapVirtualMCPServerToTelemetryConfig( // Also enqueue any MCPTelemetryConfig that still lists this VirtualMCPServer in // ReferencingWorkloads — handles ref-removal and server-deletion cases. - telemetryConfigList := &mcpv1alpha1.MCPTelemetryConfigList{} + telemetryConfigList := &mcpv1beta1.MCPTelemetryConfigList{} if err := r.List(ctx, telemetryConfigList, client.InNamespace(vmcp.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPTelemetryConfigs for VirtualMCPServer watch") return requests @@ -287,7 +287,7 @@ func (r *MCPTelemetryConfigReconciler) mapVirtualMCPServerToTelemetryConfig( continue } for _, ref := range cfg.Status.ReferencingWorkloads { - if ref.Kind == mcpv1alpha1.WorkloadKindVirtualMCPServer && ref.Name == vmcp.Name { + if ref.Kind == mcpv1beta1.WorkloadKindVirtualMCPServer && ref.Name == vmcp.Name { requests = append(requests, reconcile.Request{NamespacedName: nn}) break } @@ -298,7 +298,7 @@ func (r *MCPTelemetryConfigReconciler) mapVirtualMCPServerToTelemetryConfig( } // calculateConfigHash calculates a hash of the MCPTelemetryConfig spec using Kubernetes utilities -func (*MCPTelemetryConfigReconciler) calculateConfigHash(spec mcpv1alpha1.MCPTelemetryConfigSpec) string { +func (*MCPTelemetryConfigReconciler) calculateConfigHash(spec mcpv1beta1.MCPTelemetryConfigSpec) string { return ctrlutil.CalculateConfigHash(spec) } @@ -306,7 +306,7 @@ func (*MCPTelemetryConfigReconciler) calculateConfigHash(spec mcpv1alpha1.MCPTel // Blocks deletion while MCPServer resources reference this config (deletion protection). func (r *MCPTelemetryConfigReconciler) handleDeletion( ctx context.Context, - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig, + telemetryConfig *mcpv1beta1.MCPTelemetryConfig, ) (ctrl.Result, error) { logger := log.FromContext(ctx) @@ -329,7 +329,7 @@ func (r *MCPTelemetryConfigReconciler) handleDeletion( msg := fmt.Sprintf("cannot delete: still referenced by MCPServer(s): %v", names) logger.Info(msg, "telemetryConfig", telemetryConfig.Name) meta.SetStatusCondition(&telemetryConfig.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeDeletionBlocked, + Type: mcpv1beta1.ConditionTypeDeletionBlocked, Status: metav1.ConditionTrue, Reason: "ReferencedByWorkloads", Message: msg, @@ -355,10 +355,10 @@ func (r *MCPTelemetryConfigReconciler) handleDeletion( // that reference this MCPTelemetryConfig via TelemetryConfigRef. func (r *MCPTelemetryConfigReconciler) findReferencingWorkloads( ctx context.Context, - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig, -) ([]mcpv1alpha1.WorkloadReference, error) { + telemetryConfig *mcpv1beta1.MCPTelemetryConfig, +) ([]mcpv1beta1.WorkloadReference, error) { serverRefs, err := ctrlutil.FindWorkloadRefsFromMCPServers(ctx, r.Client, telemetryConfig.Namespace, telemetryConfig.Name, - func(server *mcpv1alpha1.MCPServer) *string { + func(server *mcpv1beta1.MCPServer) *string { if server.Spec.TelemetryConfigRef != nil { return &server.Spec.TelemetryConfigRef.Name } @@ -369,7 +369,7 @@ func (r *MCPTelemetryConfigReconciler) findReferencingWorkloads( } proxies, err := ctrlutil.FindReferencingMCPRemoteProxies(ctx, r.Client, telemetryConfig.Namespace, telemetryConfig.Name, - func(proxy *mcpv1alpha1.MCPRemoteProxy) *string { + func(proxy *mcpv1beta1.MCPRemoteProxy) *string { if proxy.Spec.TelemetryConfigRef != nil { return &proxy.Spec.TelemetryConfigRef.Name } @@ -380,19 +380,19 @@ func (r *MCPTelemetryConfigReconciler) findReferencingWorkloads( } // Check VirtualMCPServers - vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + vmcpList := &mcpv1beta1.VirtualMCPServerList{} if err := r.List(ctx, vmcpList, client.InNamespace(telemetryConfig.Namespace)); err != nil { return nil, fmt.Errorf("failed to list VirtualMCPServers: %w", err) } - refs := make([]mcpv1alpha1.WorkloadReference, 0, len(serverRefs)+len(proxies)+len(vmcpList.Items)) + refs := make([]mcpv1beta1.WorkloadReference, 0, len(serverRefs)+len(proxies)+len(vmcpList.Items)) refs = append(refs, serverRefs...) for _, proxy := range proxies { - refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPRemoteProxy, Name: proxy.Name}) + refs = append(refs, mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindMCPRemoteProxy, Name: proxy.Name}) } for _, vmcp := range vmcpList.Items { if vmcp.Spec.TelemetryConfigRef != nil && vmcp.Spec.TelemetryConfigRef.Name == telemetryConfig.Name { - refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindVirtualMCPServer, Name: vmcp.Name}) + refs = append(refs, mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindVirtualMCPServer, Name: vmcp.Name}) } } ctrlutil.SortWorkloadRefs(refs) diff --git a/cmd/thv-operator/controllers/mcptelemetryconfig_controller_test.go b/cmd/thv-operator/controllers/mcptelemetryconfig_controller_test.go index 2fb5f1cdc9..68164733b8 100644 --- a/cmd/thv-operator/controllers/mcptelemetryconfig_controller_test.go +++ b/cmd/thv-operator/controllers/mcptelemetryconfig_controller_test.go @@ -16,7 +16,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestMCPTelemetryConfigReconciler_calculateConfigHash(t *testing.T) { @@ -24,7 +24,7 @@ func TestMCPTelemetryConfigReconciler_calculateConfigHash(t *testing.T) { tests := []struct { name string - spec mcpv1alpha1.MCPTelemetryConfigSpec + spec mcpv1beta1.MCPTelemetryConfigSpec }{ { name: "basic telemetry spec", @@ -32,7 +32,7 @@ func TestMCPTelemetryConfigReconciler_calculateConfigHash(t *testing.T) { }, { name: "telemetry spec with headers", - spec: func() mcpv1alpha1.MCPTelemetryConfigSpec { + spec: func() mcpv1beta1.MCPTelemetryConfigSpec { s := newTelemetrySpec("https://otel-collector:4317", true, true) s.OpenTelemetry.Headers = map[string]string{"X-Team": "platform"} return s @@ -73,7 +73,7 @@ func TestMCPTelemetryConfigReconciler_ReconcileNotFound(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // Empty client — no objects exist fakeClient := fake.NewClientBuilder(). @@ -103,10 +103,10 @@ func TestMCPTelemetryConfigReconciler_SteadyStateNoOp(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", @@ -118,7 +118,7 @@ func TestMCPTelemetryConfigReconciler_SteadyStateNoOp(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(telemetryConfig). - WithStatusSubresource(&mcpv1alpha1.MCPTelemetryConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPTelemetryConfig{}). Build() r := &MCPTelemetryConfigReconciler{ @@ -142,7 +142,7 @@ func TestMCPTelemetryConfigReconciler_SteadyStateNoOp(t *testing.T) { _, err = r.Reconcile(ctx, req) require.NoError(t, err) - var afterInitial mcpv1alpha1.MCPTelemetryConfig + var afterInitial mcpv1beta1.MCPTelemetryConfig err = fakeClient.Get(ctx, req.NamespacedName, &afterInitial) require.NoError(t, err) initialHash := afterInitial.Status.ConfigHash @@ -153,7 +153,7 @@ func TestMCPTelemetryConfigReconciler_SteadyStateNoOp(t *testing.T) { require.NoError(t, err) assert.Equal(t, time.Duration(0), result.RequeueAfter) - var afterSteady mcpv1alpha1.MCPTelemetryConfig + var afterSteady mcpv1beta1.MCPTelemetryConfig err = fakeClient.Get(ctx, req.NamespacedName, &afterSteady) require.NoError(t, err) assert.Equal(t, initialHash, afterSteady.Status.ConfigHash, "Hash should not change") @@ -166,22 +166,22 @@ func TestMCPTelemetryConfigReconciler_ValidationRecovery(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // Start with invalid config: empty sensitive header name - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "recovery-config", Namespace: "default", Finalizers: []string{TelemetryConfigFinalizerName}, Generation: 1, }, - Spec: func() mcpv1alpha1.MCPTelemetryConfigSpec { + Spec: func() mcpv1beta1.MCPTelemetryConfigSpec { s := newTelemetrySpec("https://otel-collector:4317", true, false) - s.OpenTelemetry.SensitiveHeaders = []mcpv1alpha1.SensitiveHeader{{ + s.OpenTelemetry.SensitiveHeaders = []mcpv1beta1.SensitiveHeader{{ Name: "", - SecretKeyRef: mcpv1alpha1.SecretKeyRef{Name: "s", Key: "k"}, + SecretKeyRef: mcpv1beta1.SecretKeyRef{Name: "s", Key: "k"}, }} return s }(), @@ -190,7 +190,7 @@ func TestMCPTelemetryConfigReconciler_ValidationRecovery(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(telemetryConfig). - WithStatusSubresource(&mcpv1alpha1.MCPTelemetryConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPTelemetryConfig{}). Build() r := &MCPTelemetryConfigReconciler{ @@ -209,7 +209,7 @@ func TestMCPTelemetryConfigReconciler_ValidationRecovery(t *testing.T) { _, err := r.Reconcile(ctx, req) require.NoError(t, err) - var invalidConfig mcpv1alpha1.MCPTelemetryConfig + var invalidConfig mcpv1beta1.MCPTelemetryConfig err = fakeClient.Get(ctx, req.NamespacedName, &invalidConfig) require.NoError(t, err) @@ -233,7 +233,7 @@ func TestMCPTelemetryConfigReconciler_ValidationRecovery(t *testing.T) { _, err = r.Reconcile(ctx, req) require.NoError(t, err) - var recoveredConfig mcpv1alpha1.MCPTelemetryConfig + var recoveredConfig mcpv1beta1.MCPTelemetryConfig err = fakeClient.Get(ctx, req.NamespacedName, &recoveredConfig) require.NoError(t, err) @@ -254,12 +254,12 @@ func TestMCPTelemetryConfigReconciler_handleDeletion(t *testing.T) { tests := []struct { name string - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig + telemetryConfig *mcpv1beta1.MCPTelemetryConfig expectFinalizerRemoved bool }{ { name: "delete config removes finalizer", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", @@ -281,7 +281,7 @@ func TestMCPTelemetryConfigReconciler_handleDeletion(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). @@ -312,10 +312,10 @@ func TestMCPTelemetryConfigReconciler_ConfigChangeTriggersHashUpdate(t *testing. ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", @@ -327,7 +327,7 @@ func TestMCPTelemetryConfigReconciler_ConfigChangeTriggersHashUpdate(t *testing. fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(telemetryConfig). - WithStatusSubresource(&mcpv1alpha1.MCPTelemetryConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPTelemetryConfig{}). Build() r := &MCPTelemetryConfigReconciler{ @@ -353,7 +353,7 @@ func TestMCPTelemetryConfigReconciler_ConfigChangeTriggersHashUpdate(t *testing. assert.Equal(t, time.Duration(0), result.RequeueAfter) // Get updated config and check hash was set - var updatedConfig mcpv1alpha1.MCPTelemetryConfig + var updatedConfig mcpv1beta1.MCPTelemetryConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) assert.NotEmpty(t, updatedConfig.Status.ConfigHash, "Config hash should be set") @@ -370,7 +370,7 @@ func TestMCPTelemetryConfigReconciler_ConfigChangeTriggersHashUpdate(t *testing. require.NoError(t, err) // Get final config and verify hash changed - var finalConfig mcpv1alpha1.MCPTelemetryConfig + var finalConfig mcpv1beta1.MCPTelemetryConfig err = fakeClient.Get(ctx, req.NamespacedName, &finalConfig) require.NoError(t, err) assert.NotEmpty(t, finalConfig.Status.ConfigHash, "Config hash should still be set") @@ -384,22 +384,22 @@ func TestMCPTelemetryConfigReconciler_ValidationFailureSetsCondition(t *testing. ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // Invalid config: empty sensitive header name - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "invalid-config", Namespace: "default", Finalizers: []string{TelemetryConfigFinalizerName}, Generation: 1, }, - Spec: func() mcpv1alpha1.MCPTelemetryConfigSpec { + Spec: func() mcpv1beta1.MCPTelemetryConfigSpec { s := newTelemetrySpec("https://otel-collector:4317", true, false) - s.OpenTelemetry.SensitiveHeaders = []mcpv1alpha1.SensitiveHeader{{ + s.OpenTelemetry.SensitiveHeaders = []mcpv1beta1.SensitiveHeader{{ Name: "", - SecretKeyRef: mcpv1alpha1.SecretKeyRef{Name: "s", Key: "k"}, + SecretKeyRef: mcpv1beta1.SecretKeyRef{Name: "s", Key: "k"}, }} return s }(), @@ -408,7 +408,7 @@ func TestMCPTelemetryConfigReconciler_ValidationFailureSetsCondition(t *testing. fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(telemetryConfig). - WithStatusSubresource(&mcpv1alpha1.MCPTelemetryConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPTelemetryConfig{}). Build() r := &MCPTelemetryConfigReconciler{ @@ -428,7 +428,7 @@ func TestMCPTelemetryConfigReconciler_ValidationFailureSetsCondition(t *testing. require.NoError(t, err) // Check that the Valid condition is set to False - var updatedConfig mcpv1alpha1.MCPTelemetryConfig + var updatedConfig mcpv1beta1.MCPTelemetryConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) @@ -449,25 +449,25 @@ func TestMCPTelemetryConfig_Validate(t *testing.T) { tests := []struct { name string - config *mcpv1alpha1.MCPTelemetryConfig + config *mcpv1beta1.MCPTelemetryConfig expectError bool }{ { name: "valid basic config", - config: &mcpv1alpha1.MCPTelemetryConfig{ + config: &mcpv1beta1.MCPTelemetryConfig{ Spec: newTelemetrySpec("https://otel-collector:4317", false, true), }, expectError: false, }, { name: "valid config with sensitive headers", - config: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: func() mcpv1alpha1.MCPTelemetryConfigSpec { + config: &mcpv1beta1.MCPTelemetryConfig{ + Spec: func() mcpv1beta1.MCPTelemetryConfigSpec { s := newTelemetrySpec("https://otel-collector:4317", true, false) - s.OpenTelemetry.SensitiveHeaders = []mcpv1alpha1.SensitiveHeader{ + s.OpenTelemetry.SensitiveHeaders = []mcpv1beta1.SensitiveHeader{ { Name: "Authorization", - SecretKeyRef: mcpv1alpha1.SecretKeyRef{ + SecretKeyRef: mcpv1beta1.SecretKeyRef{ Name: "otel-secret", Key: "auth-token", }, @@ -480,14 +480,14 @@ func TestMCPTelemetryConfig_Validate(t *testing.T) { }, { name: "invalid overlapping headers", - config: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: func() mcpv1alpha1.MCPTelemetryConfigSpec { + config: &mcpv1beta1.MCPTelemetryConfig{ + Spec: func() mcpv1beta1.MCPTelemetryConfigSpec { s := newTelemetrySpec("https://otel-collector:4317", true, false) s.OpenTelemetry.Headers = map[string]string{"Authorization": "Bearer token"} - s.OpenTelemetry.SensitiveHeaders = []mcpv1alpha1.SensitiveHeader{ + s.OpenTelemetry.SensitiveHeaders = []mcpv1beta1.SensitiveHeader{ { Name: "Authorization", - SecretKeyRef: mcpv1alpha1.SecretKeyRef{ + SecretKeyRef: mcpv1beta1.SecretKeyRef{ Name: "otel-secret", Key: "auth-token", }, @@ -500,9 +500,9 @@ func TestMCPTelemetryConfig_Validate(t *testing.T) { }, { name: "invalid endpoint without tracing or metrics", - config: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + config: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "otel-collector:4317", // No Tracing or Metrics configured @@ -513,13 +513,13 @@ func TestMCPTelemetryConfig_Validate(t *testing.T) { }, { name: "invalid empty secret ref name", - config: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: func() mcpv1alpha1.MCPTelemetryConfigSpec { + config: &mcpv1beta1.MCPTelemetryConfig{ + Spec: func() mcpv1beta1.MCPTelemetryConfigSpec { s := newTelemetrySpec("https://otel-collector:4317", true, false) - s.OpenTelemetry.SensitiveHeaders = []mcpv1alpha1.SensitiveHeader{ + s.OpenTelemetry.SensitiveHeaders = []mcpv1beta1.SensitiveHeader{ { Name: "Authorization", - SecretKeyRef: mcpv1alpha1.SecretKeyRef{ + SecretKeyRef: mcpv1beta1.SecretKeyRef{ Name: "", Key: "auth-token", }, @@ -553,7 +553,7 @@ func TestMCPTelemetryConfigReconciler_ConditionOnlyUpdate(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) spec := newTelemetrySpec("https://otel-collector:4317", true, true) @@ -563,7 +563,7 @@ func TestMCPTelemetryConfigReconciler_ConditionOnlyUpdate(t *testing.T) { precomputedHash := r.calculateConfigHash(spec) // Resource already has finalizer and correct hash, but no Valid condition - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "condition-only-config", Namespace: "default", @@ -571,7 +571,7 @@ func TestMCPTelemetryConfigReconciler_ConditionOnlyUpdate(t *testing.T) { Generation: 1, }, Spec: spec, - Status: mcpv1alpha1.MCPTelemetryConfigStatus{ + Status: mcpv1beta1.MCPTelemetryConfigStatus{ ConfigHash: precomputedHash, ObservedGeneration: 1, // No conditions set — this is the key setup @@ -581,7 +581,7 @@ func TestMCPTelemetryConfigReconciler_ConditionOnlyUpdate(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(telemetryConfig). - WithStatusSubresource(&mcpv1alpha1.MCPTelemetryConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPTelemetryConfig{}). Build() r.Client = fakeClient @@ -598,7 +598,7 @@ func TestMCPTelemetryConfigReconciler_ConditionOnlyUpdate(t *testing.T) { _, err := r.Reconcile(ctx, req) require.NoError(t, err) - var updated mcpv1alpha1.MCPTelemetryConfig + var updated mcpv1beta1.MCPTelemetryConfig err = fakeClient.Get(ctx, req.NamespacedName, &updated) require.NoError(t, err) @@ -623,10 +623,10 @@ func TestMCPTelemetryConfigReconciler_ReferenceTracking(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "shared-config", Namespace: "default", @@ -636,36 +636,36 @@ func TestMCPTelemetryConfigReconciler_ReferenceTracking(t *testing.T) { } // Two MCPServers reference this config, one does not - server1 := &mcpv1alpha1.MCPServer{ + server1 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-a", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "shared-config", }, }, } - server2 := &mcpv1alpha1.MCPServer{ + server2 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-b", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "shared-config", }, }, } - server3 := &mcpv1alpha1.MCPServer{ + server3 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-c", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", // No TelemetryConfigRef }, @@ -674,7 +674,7 @@ func TestMCPTelemetryConfigReconciler_ReferenceTracking(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(telemetryConfig, server1, server2, server3). - WithStatusSubresource(&mcpv1alpha1.MCPTelemetryConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPTelemetryConfig{}). Build() r := &MCPTelemetryConfigReconciler{ @@ -698,12 +698,12 @@ func TestMCPTelemetryConfigReconciler_ReferenceTracking(t *testing.T) { _, err = r.Reconcile(ctx, req) require.NoError(t, err) - var updated mcpv1alpha1.MCPTelemetryConfig + var updated mcpv1beta1.MCPTelemetryConfig err = fakeClient.Get(ctx, req.NamespacedName, &updated) require.NoError(t, err) // ReferencingWorkloads should list server-a and server-b (sorted), but not server-c - assert.Equal(t, []mcpv1alpha1.WorkloadReference{ + assert.Equal(t, []mcpv1beta1.WorkloadReference{ {Kind: "MCPServer", Name: "server-a"}, {Kind: "MCPServer", Name: "server-b"}, }, updated.Status.ReferencingWorkloads) @@ -715,11 +715,11 @@ func TestMCPTelemetryConfigReconciler_handleDeletion_BlocksWhenReferenced(t *tes ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) now := metav1.Now() - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "referenced-config", Namespace: "default", @@ -731,14 +731,14 @@ func TestMCPTelemetryConfigReconciler_handleDeletion_BlocksWhenReferenced(t *tes } // MCPServer that references this config - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "referencing-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "referenced-config", }, }, @@ -747,7 +747,7 @@ func TestMCPTelemetryConfigReconciler_handleDeletion_BlocksWhenReferenced(t *tes fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(telemetryConfig, server). - WithStatusSubresource(&mcpv1alpha1.MCPTelemetryConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPTelemetryConfig{}). Build() r := &MCPTelemetryConfigReconciler{ @@ -770,11 +770,11 @@ func TestMCPTelemetryConfigReconciler_handleDeletion_AllowsWhenNotReferenced(t * ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) now := metav1.Now() - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "unreferenced-config", Namespace: "default", @@ -786,12 +786,12 @@ func TestMCPTelemetryConfigReconciler_handleDeletion_AllowsWhenNotReferenced(t * } // MCPServer exists but does NOT reference this config - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "unrelated-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", // No TelemetryConfigRef }, @@ -821,14 +821,14 @@ func TestMCPTelemetryConfigReconciler_handleDeletion_NoFinalizerIsNoOp(t *testin ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // Object with DeletionTimestamp but no finalizers. // We don't add it to the fake client (which rejects such objects) // because handleDeletion only reads from the object itself for the // no-finalizer fast path. now := metav1.Now() - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "no-finalizer-config", Namespace: "default", @@ -853,13 +853,13 @@ func TestMCPTelemetryConfigReconciler_handleDeletion_NoFinalizerIsNoOp(t *testin } // newTelemetrySpec creates a basic MCPTelemetryConfigSpec for testing. -func newTelemetrySpec(endpoint string, tracing, metrics bool) mcpv1alpha1.MCPTelemetryConfigSpec { - return mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ +func newTelemetrySpec(endpoint string, tracing, metrics bool) mcpv1beta1.MCPTelemetryConfigSpec { + return mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: endpoint, - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: tracing}, - Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{Enabled: metrics}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: tracing}, + Metrics: &mcpv1beta1.OpenTelemetryMetricsConfig{Enabled: metrics}, }, } } diff --git a/cmd/thv-operator/controllers/toolconfig_controller.go b/cmd/thv-operator/controllers/toolconfig_controller.go index 3d13bbf602..0d0db16750 100644 --- a/cmd/thv-operator/controllers/toolconfig_controller.go +++ b/cmd/thv-operator/controllers/toolconfig_controller.go @@ -20,7 +20,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -49,7 +49,7 @@ func (r *ToolConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) logger := log.FromContext(ctx) // Fetch the MCPToolConfig instance - toolConfig := &mcpv1alpha1.MCPToolConfig{} + toolConfig := &mcpv1beta1.MCPToolConfig{} err := r.Get(ctx, req.NamespacedName, toolConfig) if err != nil { if errors.IsNotFound(err) { @@ -81,9 +81,9 @@ func (r *ToolConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) // Validation succeeded - set Valid=True condition conditionChanged := meta.SetStatusCondition(&toolConfig.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionToolConfigValid, + Type: mcpv1beta1.ConditionToolConfigValid, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonToolConfigValidationSucceeded, + Reason: mcpv1beta1.ConditionReasonToolConfigValidationSucceeded, Message: "Spec validation passed", ObservedGeneration: toolConfig.Generation, }) @@ -120,7 +120,7 @@ func (r *ToolConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) // handleConfigHashChange handles the logic when the config hash changes func (r *ToolConfigReconciler) handleConfigHashChange( ctx context.Context, - toolConfig *mcpv1alpha1.MCPToolConfig, + toolConfig *mcpv1beta1.MCPToolConfig, configHash string, ) (ctrl.Result, error) { logger := log.FromContext(ctx) @@ -141,9 +141,9 @@ func (r *ToolConfigReconciler) handleConfigHashChange( toolConfig.Status.ObservedGeneration = toolConfig.Generation // Update the status with the list of referencing workloads - refs := make([]mcpv1alpha1.WorkloadReference, 0, len(referencingServers)) + refs := make([]mcpv1beta1.WorkloadReference, 0, len(referencingServers)) for _, server := range referencingServers { - refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPServer, Name: server.Name}) + refs = append(refs, mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindMCPServer, Name: server.Name}) } ctrlutil.SortWorkloadRefs(refs) toolConfig.Status.ReferencingWorkloads = refs @@ -173,12 +173,12 @@ func (r *ToolConfigReconciler) handleConfigHashChange( } // calculateConfigHash calculates a hash of the MCPToolConfig spec using Kubernetes utilities -func (*ToolConfigReconciler) calculateConfigHash(spec mcpv1alpha1.MCPToolConfigSpec) string { +func (*ToolConfigReconciler) calculateConfigHash(spec mcpv1beta1.MCPToolConfigSpec) string { return ctrlutil.CalculateConfigHash(spec) } // handleDeletion handles the deletion of a MCPToolConfig -func (r *ToolConfigReconciler) handleDeletion(ctx context.Context, toolConfig *mcpv1alpha1.MCPToolConfig) (ctrl.Result, error) { +func (r *ToolConfigReconciler) handleDeletion(ctx context.Context, toolConfig *mcpv1beta1.MCPToolConfig) (ctrl.Result, error) { logger := log.FromContext(ctx) if controllerutil.ContainsFinalizer(toolConfig, ToolConfigFinalizerName) { @@ -195,7 +195,7 @@ func (r *ToolConfigReconciler) handleDeletion(ctx context.Context, toolConfig *m "referencingWorkloads", referencingWorkloads) meta.SetStatusCondition(&toolConfig.Status.Conditions, metav1.Condition{ - Type: mcpv1alpha1.ConditionTypeDeletionBlocked, + Type: mcpv1beta1.ConditionTypeDeletionBlocked, Status: metav1.ConditionTrue, Reason: "ReferencedByWorkloads", Message: fmt.Sprintf("Cannot delete: referenced by workloads: %v", referencingWorkloads), @@ -226,10 +226,10 @@ func (r *ToolConfigReconciler) handleDeletion(ctx context.Context, toolConfig *m // that reference this MCPToolConfig via their ToolConfigRef field. func (r *ToolConfigReconciler) findReferencingWorkloads( ctx context.Context, - toolConfig *mcpv1alpha1.MCPToolConfig, -) ([]mcpv1alpha1.WorkloadReference, error) { + toolConfig *mcpv1beta1.MCPToolConfig, +) ([]mcpv1beta1.WorkloadReference, error) { return ctrlutil.FindWorkloadRefsFromMCPServers(ctx, r.Client, toolConfig.Namespace, toolConfig.Name, - func(server *mcpv1alpha1.MCPServer) *string { + func(server *mcpv1beta1.MCPServer) *string { if server.Spec.ToolConfigRef != nil { return &server.Spec.ToolConfigRef.Name } @@ -241,10 +241,10 @@ func (r *ToolConfigReconciler) findReferencingWorkloads( // Returns the full MCPServer objects, used by handleConfigHashChange to update server annotations. func (r *ToolConfigReconciler) findReferencingMCPServers( ctx context.Context, - toolConfig *mcpv1alpha1.MCPToolConfig, -) ([]mcpv1alpha1.MCPServer, error) { + toolConfig *mcpv1beta1.MCPToolConfig, +) ([]mcpv1beta1.MCPServer, error) { return ctrlutil.FindReferencingMCPServers(ctx, r.Client, toolConfig.Namespace, toolConfig.Name, - func(server *mcpv1alpha1.MCPServer) *string { + func(server *mcpv1beta1.MCPServer) *string { if server.Spec.ToolConfigRef != nil { return &server.Spec.ToolConfigRef.Name } @@ -262,7 +262,7 @@ func (r *ToolConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { // config needs to reconcile and clean up the stale entry). toolConfigHandler := handler.EnqueueRequestsFromMapFunc( func(ctx context.Context, obj client.Object) []reconcile.Request { - server, ok := obj.(*mcpv1alpha1.MCPServer) + server, ok := obj.(*mcpv1beta1.MCPServer) if !ok { return nil } @@ -282,7 +282,7 @@ func (r *ToolConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { // Also enqueue any MCPToolConfig that still lists this server in // ReferencingWorkloads — handles ref-removal and server-deletion cases. - toolConfigList := &mcpv1alpha1.MCPToolConfigList{} + toolConfigList := &mcpv1beta1.MCPToolConfigList{} if err := r.List(ctx, toolConfigList, client.InNamespace(server.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPToolConfigs for MCPServer watch") return requests @@ -293,7 +293,7 @@ func (r *ToolConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { continue } for _, ref := range cfg.Status.ReferencingWorkloads { - if ref.Kind == mcpv1alpha1.WorkloadKindMCPServer && ref.Name == server.Name { + if ref.Kind == mcpv1beta1.WorkloadKindMCPServer && ref.Name == server.Name { requests = append(requests, reconcile.Request{NamespacedName: nn}) break } @@ -305,7 +305,7 @@ func (r *ToolConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { ) return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1alpha1.MCPToolConfig{}). - Watches(&mcpv1alpha1.MCPServer{}, toolConfigHandler). + For(&mcpv1beta1.MCPToolConfig{}). + Watches(&mcpv1beta1.MCPServer{}, toolConfigHandler). Complete(r) } diff --git a/cmd/thv-operator/controllers/toolconfig_controller_edge_cases_test.go b/cmd/thv-operator/controllers/toolconfig_controller_edge_cases_test.go index 7242bac4f2..f989c912d2 100644 --- a/cmd/thv-operator/controllers/toolconfig_controller_edge_cases_test.go +++ b/cmd/thv-operator/controllers/toolconfig_controller_edge_cases_test.go @@ -18,7 +18,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestToolConfigReconciler_EdgeCases(t *testing.T) { @@ -29,7 +29,7 @@ func TestToolConfigReconciler_EdgeCases(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). @@ -58,16 +58,16 @@ func TestToolConfigReconciler_EdgeCases(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1", "tool2"}, - ToolsOverride: map[string]mcpv1alpha1.ToolOverride{ + ToolsOverride: map[string]mcpv1beta1.ToolOverride{ "tool1": { Name: "renamed-tool1", Description: "Custom description", @@ -76,14 +76,14 @@ func TestToolConfigReconciler_EdgeCases(t *testing.T) { }, } - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "test-config", }, }, @@ -92,7 +92,7 @@ func TestToolConfigReconciler_EdgeCases(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(toolConfig, mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPToolConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPToolConfig{}). Build() r := &ToolConfigReconciler{ @@ -118,12 +118,12 @@ func TestToolConfigReconciler_EdgeCases(t *testing.T) { assert.Equal(t, time.Duration(0), result.RequeueAfter) // Verify status was updated - var updatedConfig mcpv1alpha1.MCPToolConfig + var updatedConfig mcpv1beta1.MCPToolConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) assert.NotEmpty(t, updatedConfig.Status.ConfigHash) assert.Contains(t, updatedConfig.Status.ReferencingWorkloads, - mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "test-server"}) + mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "test-server"}) }) t.Run("reconcile with changed spec", func(t *testing.T) { @@ -131,18 +131,18 @@ func TestToolConfigReconciler_EdgeCases(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", Finalizers: []string{ToolConfigFinalizerName}, }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1"}, }, - Status: mcpv1alpha1.MCPToolConfigStatus{ + Status: mcpv1beta1.MCPToolConfigStatus{ ConfigHash: "oldhash", }, } @@ -150,7 +150,7 @@ func TestToolConfigReconciler_EdgeCases(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(toolConfig). - WithStatusSubresource(&mcpv1alpha1.MCPToolConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPToolConfig{}). Build() r := &ToolConfigReconciler{ @@ -177,7 +177,7 @@ func TestToolConfigReconciler_EdgeCases(t *testing.T) { assert.Equal(t, time.Duration(0), result.RequeueAfter) // Verify hash was updated - var updatedConfig mcpv1alpha1.MCPToolConfig + var updatedConfig mcpv1beta1.MCPToolConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) assert.NotEqual(t, "oldhash", updatedConfig.Status.ConfigHash) @@ -193,15 +193,15 @@ func TestToolConfigReconciler_ErrorScenarios(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", Finalizers: []string{ToolConfigFinalizerName}, }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1"}, }, } @@ -211,7 +211,7 @@ func TestToolConfigReconciler_ErrorScenarios(t *testing.T) { Client: fake.NewClientBuilder(). WithScheme(scheme). WithObjects(toolConfig). - WithStatusSubresource(&mcpv1alpha1.MCPToolConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPToolConfig{}). Build(), listError: errors.New("list error"), } @@ -256,16 +256,16 @@ func TestToolConfigReconciler_ComplexScenarios(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "shared-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1", "tool2", "tool3"}, - ToolsOverride: map[string]mcpv1alpha1.ToolOverride{ + ToolsOverride: map[string]mcpv1beta1.ToolOverride{ "tool1": { Name: "custom-tool1", Description: "Customized tool 1", @@ -275,15 +275,15 @@ func TestToolConfigReconciler_ComplexScenarios(t *testing.T) { } // Create multiple MCPServers referencing the same MCPToolConfig - mcpServers := []*mcpv1alpha1.MCPServer{ + mcpServers := []*mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "shared-config", }, }, @@ -293,9 +293,9 @@ func TestToolConfigReconciler_ComplexScenarios(t *testing.T) { Name: "server2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "shared-config", }, }, @@ -305,9 +305,9 @@ func TestToolConfigReconciler_ComplexScenarios(t *testing.T) { Name: "server3", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "shared-config", }, }, @@ -322,7 +322,7 @@ func TestToolConfigReconciler_ComplexScenarios(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPToolConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPToolConfig{}). Build() r := &ToolConfigReconciler{ @@ -348,16 +348,16 @@ func TestToolConfigReconciler_ComplexScenarios(t *testing.T) { assert.Equal(t, time.Duration(0), result.RequeueAfter) // Verify all servers are listed in status - var updatedConfig mcpv1alpha1.MCPToolConfig + var updatedConfig mcpv1beta1.MCPToolConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) assert.Len(t, updatedConfig.Status.ReferencingWorkloads, 3) assert.Contains(t, updatedConfig.Status.ReferencingWorkloads, - mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server1"}) + mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server1"}) assert.Contains(t, updatedConfig.Status.ReferencingWorkloads, - mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server2"}) + mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server2"}) assert.Contains(t, updatedConfig.Status.ReferencingWorkloads, - mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server3"}) + mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server3"}) }) t.Run("empty MCPToolConfig spec", func(t *testing.T) { @@ -365,15 +365,15 @@ func TestToolConfigReconciler_ComplexScenarios(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // MCPToolConfig with completely empty spec - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "empty-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ // Empty spec - no filters, no overrides }, } @@ -381,7 +381,7 @@ func TestToolConfigReconciler_ComplexScenarios(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(toolConfig). - WithStatusSubresource(&mcpv1alpha1.MCPToolConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPToolConfig{}). Build() r := &ToolConfigReconciler{ @@ -407,7 +407,7 @@ func TestToolConfigReconciler_ComplexScenarios(t *testing.T) { assert.Equal(t, time.Duration(0), result.RequeueAfter) // Verify hash was generated even for empty spec - var updatedConfig mcpv1alpha1.MCPToolConfig + var updatedConfig mcpv1beta1.MCPToolConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) assert.NotEmpty(t, updatedConfig.Status.ConfigHash) diff --git a/cmd/thv-operator/controllers/toolconfig_controller_test.go b/cmd/thv-operator/controllers/toolconfig_controller_test.go index 46f77518cc..82c34bb77b 100644 --- a/cmd/thv-operator/controllers/toolconfig_controller_test.go +++ b/cmd/thv-operator/controllers/toolconfig_controller_test.go @@ -18,7 +18,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestToolConfigReconciler_calculateConfigHash(t *testing.T) { @@ -26,22 +26,22 @@ func TestToolConfigReconciler_calculateConfigHash(t *testing.T) { tests := []struct { name string - spec mcpv1alpha1.MCPToolConfigSpec + spec mcpv1beta1.MCPToolConfigSpec }{ { name: "empty spec", - spec: mcpv1alpha1.MCPToolConfigSpec{}, + spec: mcpv1beta1.MCPToolConfigSpec{}, }, { name: "with tools filter", - spec: mcpv1alpha1.MCPToolConfigSpec{ + spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1", "tool2", "tool3"}, }, }, { name: "with tools override", - spec: mcpv1alpha1.MCPToolConfigSpec{ - ToolsOverride: map[string]mcpv1alpha1.ToolOverride{ + spec: mcpv1beta1.MCPToolConfigSpec{ + ToolsOverride: map[string]mcpv1beta1.ToolOverride{ "tool1": { Name: "renamed-tool1", Description: "Custom description", @@ -51,9 +51,9 @@ func TestToolConfigReconciler_calculateConfigHash(t *testing.T) { }, { name: "with both filter and override", - spec: mcpv1alpha1.MCPToolConfigSpec{ + spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1", "tool2"}, - ToolsOverride: map[string]mcpv1alpha1.ToolOverride{ + ToolsOverride: map[string]mcpv1beta1.ToolOverride{ "tool1": { Name: "renamed-tool1", Description: "Custom description", @@ -86,10 +86,10 @@ func TestToolConfigReconciler_calculateConfigHash(t *testing.T) { t.Run("different specs produce different hashes", func(t *testing.T) { t.Parallel() r := &ToolConfigReconciler{} - spec1 := mcpv1alpha1.MCPToolConfigSpec{ + spec1 := mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1"}, } - spec2 := mcpv1alpha1.MCPToolConfigSpec{ + spec2 := mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool2"}, } @@ -105,19 +105,19 @@ func TestToolConfigReconciler_Reconcile(t *testing.T) { tests := []struct { name string - toolConfig *mcpv1alpha1.MCPToolConfig - existingMCPServer *mcpv1alpha1.MCPServer + toolConfig *mcpv1beta1.MCPToolConfig + existingMCPServer *mcpv1beta1.MCPServer expectFinalizer bool expectHash bool }{ { name: "new toolconfig without references", - toolConfig: &mcpv1alpha1.MCPToolConfig{ + toolConfig: &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1", "tool2"}, }, }, @@ -126,14 +126,14 @@ func TestToolConfigReconciler_Reconcile(t *testing.T) { }, { name: "toolconfig with referencing mcpserver", - toolConfig: &mcpv1alpha1.MCPToolConfig{ + toolConfig: &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1"}, - ToolsOverride: map[string]mcpv1alpha1.ToolOverride{ + ToolsOverride: map[string]mcpv1beta1.ToolOverride{ "tool1": { Name: "renamed-tool", Description: "Custom description", @@ -141,14 +141,14 @@ func TestToolConfigReconciler_Reconcile(t *testing.T) { }, }, }, - existingMCPServer: &mcpv1alpha1.MCPServer{ + existingMCPServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "test-config", }, }, @@ -165,7 +165,7 @@ func TestToolConfigReconciler_Reconcile(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) // Create fake client with objects @@ -176,7 +176,7 @@ func TestToolConfigReconciler_Reconcile(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.MCPToolConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPToolConfig{}). Build() r := &ToolConfigReconciler{ @@ -205,7 +205,7 @@ func TestToolConfigReconciler_Reconcile(t *testing.T) { } // Check the updated MCPToolConfig - var updatedConfig mcpv1alpha1.MCPToolConfig + var updatedConfig mcpv1beta1.MCPToolConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) @@ -224,15 +224,15 @@ func TestToolConfigReconciler_Reconcile(t *testing.T) { // Check referencing workloads in status if tt.existingMCPServer != nil { assert.Contains(t, updatedConfig.Status.ReferencingWorkloads, - mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: tt.existingMCPServer.Name}, + mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: tt.existingMCPServer.Name}, "Status should contain referencing MCPServer as WorkloadReference") } // Check Valid condition is set after successful reconciliation - cond := k8smeta.FindStatusCondition(updatedConfig.Status.Conditions, mcpv1alpha1.ConditionToolConfigValid) + cond := k8smeta.FindStatusCondition(updatedConfig.Status.Conditions, mcpv1beta1.ConditionToolConfigValid) require.NotNil(t, cond, "Valid condition must be set after successful reconciliation") assert.Equal(t, metav1.ConditionTrue, cond.Status, "Valid condition should be True") - assert.Equal(t, mcpv1alpha1.ConditionReasonToolConfigValidationSucceeded, cond.Reason) + assert.Equal(t, mcpv1beta1.ConditionReasonToolConfigValidationSucceeded, cond.Reason) assert.Equal(t, "Spec validation passed", cond.Message) }) } @@ -242,50 +242,50 @@ func TestToolConfigReconciler_findReferencingWorkloads(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1"}, }, } - mcpServer1 := &mcpv1alpha1.MCPServer{ + mcpServer1 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "test-config", }, }, } - mcpServer2 := &mcpv1alpha1.MCPServer{ + mcpServer2 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "test-config", }, }, } - mcpServer3 := &mcpv1alpha1.MCPServer{ + mcpServer3 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server3", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", // No ToolConfigRef }, @@ -306,9 +306,9 @@ func TestToolConfigReconciler_findReferencingWorkloads(t *testing.T) { require.NoError(t, err) assert.Len(t, refs, 2, "Should find 2 referencing workloads") - assert.Contains(t, refs, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server1"}) - assert.Contains(t, refs, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server2"}) - assert.NotContains(t, refs, mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server3"}) + assert.Contains(t, refs, mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server1"}) + assert.Contains(t, refs, mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server2"}) + assert.NotContains(t, refs, mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server3"}) } func TestToolConfigReconciler_ReferencingWorkloadsUpdatedWithoutHashChange(t *testing.T) { @@ -317,15 +317,15 @@ func TestToolConfigReconciler_ReferencingWorkloadsUpdatedWithoutHashChange(t *te ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1"}, }, } @@ -333,7 +333,7 @@ func TestToolConfigReconciler_ReferencingWorkloadsUpdatedWithoutHashChange(t *te fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(toolConfig). - WithStatusSubresource(&mcpv1alpha1.MCPToolConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPToolConfig{}). Build() r := &ToolConfigReconciler{ @@ -357,26 +357,26 @@ func TestToolConfigReconciler_ReferencingWorkloadsUpdatedWithoutHashChange(t *te _, err = r.Reconcile(ctx, req) require.NoError(t, err) - var updatedConfig mcpv1alpha1.MCPToolConfig + var updatedConfig mcpv1beta1.MCPToolConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) assert.NotEmpty(t, updatedConfig.Status.ConfigHash) assert.Empty(t, updatedConfig.Status.ReferencingWorkloads, "No servers should be referencing yet") // Verify Valid condition is set after initial reconciliation - cond := k8smeta.FindStatusCondition(updatedConfig.Status.Conditions, mcpv1alpha1.ConditionToolConfigValid) + cond := k8smeta.FindStatusCondition(updatedConfig.Status.Conditions, mcpv1beta1.ConditionToolConfigValid) require.NotNil(t, cond, "Valid condition must be set after reconciliation") assert.Equal(t, metav1.ConditionTrue, cond.Status) // Add an MCPServer that references this config (without changing the config spec) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "new-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "test-config", }, }, @@ -390,7 +390,7 @@ func TestToolConfigReconciler_ReferencingWorkloadsUpdatedWithoutHashChange(t *te err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) assert.Contains(t, updatedConfig.Status.ReferencingWorkloads, - mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "new-server"}, + mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "new-server"}, "ReferencingWorkloads should be updated even without hash change") } @@ -400,27 +400,27 @@ func TestToolConfigReconciler_ReferencingWorkloadsRemovedOnServerDeletion(t *tes ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1"}, }, } - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-to-delete", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "test-config", }, }, @@ -429,7 +429,7 @@ func TestToolConfigReconciler_ReferencingWorkloadsRemovedOnServerDeletion(t *tes fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(toolConfig, mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPToolConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPToolConfig{}). Build() r := &ToolConfigReconciler{ @@ -453,11 +453,11 @@ func TestToolConfigReconciler_ReferencingWorkloadsRemovedOnServerDeletion(t *tes _, err = r.Reconcile(ctx, req) require.NoError(t, err) - var updatedConfig mcpv1alpha1.MCPToolConfig + var updatedConfig mcpv1beta1.MCPToolConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) assert.Contains(t, updatedConfig.Status.ReferencingWorkloads, - mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: "server-to-delete"}) + mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: "server-to-delete"}) // Delete the MCPServer require.NoError(t, fakeClient.Delete(ctx, mcpServer)) @@ -478,16 +478,16 @@ func TestToolConfigReconciler_ValidConditionObservedGeneration(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", Generation: 1, }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1"}, }, } @@ -495,7 +495,7 @@ func TestToolConfigReconciler_ValidConditionObservedGeneration(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(toolConfig). - WithStatusSubresource(&mcpv1alpha1.MCPToolConfig{}). + WithStatusSubresource(&mcpv1beta1.MCPToolConfig{}). Build() r := &ToolConfigReconciler{ @@ -519,15 +519,15 @@ func TestToolConfigReconciler_ValidConditionObservedGeneration(t *testing.T) { _, err = r.Reconcile(ctx, req) require.NoError(t, err) - var updatedConfig mcpv1alpha1.MCPToolConfig + var updatedConfig mcpv1beta1.MCPToolConfig err = fakeClient.Get(ctx, req.NamespacedName, &updatedConfig) require.NoError(t, err) // Verify Valid condition exists with correct fields - cond := k8smeta.FindStatusCondition(updatedConfig.Status.Conditions, mcpv1alpha1.ConditionToolConfigValid) + cond := k8smeta.FindStatusCondition(updatedConfig.Status.Conditions, mcpv1beta1.ConditionToolConfigValid) require.NotNil(t, cond, "Valid condition must be set") assert.Equal(t, metav1.ConditionTrue, cond.Status) - assert.Equal(t, mcpv1alpha1.ConditionReasonToolConfigValidationSucceeded, cond.Reason) + assert.Equal(t, mcpv1beta1.ConditionReasonToolConfigValidationSucceeded, cond.Reason) assert.Equal(t, "Spec validation passed", cond.Message) assert.Equal(t, updatedConfig.Generation, cond.ObservedGeneration, "ObservedGeneration should match the object's Generation") @@ -542,12 +542,12 @@ func TestToolConfigReconciler_ValidConditionObservedGeneration(t *testing.T) { _, err = r.Reconcile(ctx, req) require.NoError(t, err) - var finalConfig mcpv1alpha1.MCPToolConfig + var finalConfig mcpv1beta1.MCPToolConfig err = fakeClient.Get(ctx, req.NamespacedName, &finalConfig) require.NoError(t, err) // Verify ObservedGeneration tracks the updated generation - cond = k8smeta.FindStatusCondition(finalConfig.Status.Conditions, mcpv1alpha1.ConditionToolConfigValid) + cond = k8smeta.FindStatusCondition(finalConfig.Status.Conditions, mcpv1beta1.ConditionToolConfigValid) require.NotNil(t, cond, "Valid condition must still be set after spec change") assert.Equal(t, metav1.ConditionTrue, cond.Status) assert.Equal(t, int64(2), cond.ObservedGeneration, diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index 911bb4b811..3baf42eed9 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -32,7 +32,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/kubernetes/rbac" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" @@ -134,7 +134,7 @@ func (r *VirtualMCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Req ctxLogger := log.FromContext(ctx) // Fetch the VirtualMCPServer instance - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} err := r.Get(ctx, req.NamespacedName, vmcp) if err != nil { if errors.IsNotFound(err) { @@ -205,7 +205,7 @@ func (r *VirtualMCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Req // - BackendsDiscovered condition // Fetch the latest version before updating status to ensure we use the current Generation - latestVMCP := &mcpv1alpha1.VirtualMCPServer{} + latestVMCP := &mcpv1beta1.VirtualMCPServer{} if err := r.Get(ctx, types.NamespacedName{ Name: vmcp.Name, Namespace: vmcp.Namespace, @@ -239,7 +239,7 @@ func (r *VirtualMCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Req // Returns an error if validation fails, which signals the caller to stop reconciliation. func (r *VirtualMCPServerReconciler) validateSpec( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, ) error { ctxLogger := log.FromContext(ctx) @@ -247,7 +247,7 @@ func (r *VirtualMCPServerReconciler) validateSpec( if err := vmcp.Validate(); err != nil { ctxLogger.Error(err, "VirtualMCPServer spec validation failed") statusManager.SetObservedGeneration(vmcp.Generation) - statusManager.SetCondition(mcpv1alpha1.ConditionTypeValid, "ValidationFailed", err.Error(), metav1.ConditionFalse) + statusManager.SetCondition(mcpv1beta1.ConditionTypeValid, "ValidationFailed", err.Error(), metav1.ConditionFalse) if applyErr := r.applyStatusUpdates(ctx, vmcp, statusManager); applyErr != nil { ctxLogger.Error(applyErr, "Failed to apply status updates after validation error") } @@ -256,7 +256,7 @@ func (r *VirtualMCPServerReconciler) validateSpec( // Validation succeeded - set Valid=True condition statusManager.SetObservedGeneration(vmcp.Generation) - statusManager.SetCondition(mcpv1alpha1.ConditionTypeValid, "ValidationSucceeded", "Spec validation passed", metav1.ConditionTrue) + statusManager.SetCondition(mcpv1beta1.ConditionTypeValid, "ValidationSucceeded", "Spec validation passed", metav1.ConditionTrue) return nil } @@ -265,13 +265,13 @@ func (r *VirtualMCPServerReconciler) validateSpec( // This implements the StatusCollector pattern to reduce API calls and prevent update conflicts. func (r *VirtualMCPServerReconciler) applyStatusUpdates( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, ) error { ctxLogger := log.FromContext(ctx) // Fetch the latest version to avoid conflicts - latest := &mcpv1alpha1.VirtualMCPServer{} + latest := &mcpv1beta1.VirtualMCPServer{} if err := r.Get(ctx, types.NamespacedName{ Name: vmcp.Name, Namespace: vmcp.Namespace, @@ -306,7 +306,7 @@ func (r *VirtualMCPServerReconciler) applyStatusUpdates( // Returns (false, error) for transient errors that should trigger requeue. func (r *VirtualMCPServerReconciler) runValidations( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, ) (bool, error) { ctxLogger := log.FromContext(ctx) @@ -362,7 +362,7 @@ func (r *VirtualMCPServerReconciler) runValidations( } } else { // Remove stale condition if AuthServerConfig was previously set then removed. - statusManager.RemoveConditionsWithPrefix(mcpv1alpha1.ConditionTypeAuthServerConfigValidated, []string{}) + statusManager.RemoveConditionsWithPrefix(mcpv1beta1.ConditionTypeAuthServerConfigValidated, []string{}) } // Advisory: warn when replicas > 1 but session storage is not Redis-backed. @@ -375,29 +375,29 @@ func (r *VirtualMCPServerReconciler) runValidations( // replicas > 1 but session storage is not configured with a Redis backend. // Reconciliation continues regardless; this is advisory only. func (*VirtualMCPServerReconciler) validateSessionStorageForReplicas( - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, ) { if vmcp.Spec.Replicas != nil && *vmcp.Spec.Replicas > 1 { - if vmcp.Spec.SessionStorage == nil || vmcp.Spec.SessionStorage.Provider != mcpv1alpha1.SessionStorageProviderRedis { + if vmcp.Spec.SessionStorage == nil || vmcp.Spec.SessionStorage.Provider != mcpv1beta1.SessionStorageProviderRedis { statusManager.SetCondition( - mcpv1alpha1.ConditionSessionStorageWarning, - mcpv1alpha1.ConditionReasonSessionStorageMissing, + mcpv1beta1.ConditionSessionStorageWarning, + mcpv1beta1.ConditionReasonSessionStorageMissing, "replicas > 1 but sessionStorage.provider is not redis; sessions are not shared across replicas", metav1.ConditionTrue, ) } else { statusManager.SetCondition( - mcpv1alpha1.ConditionSessionStorageWarning, - mcpv1alpha1.ConditionReasonSessionStorageConfigured, + mcpv1beta1.ConditionSessionStorageWarning, + mcpv1beta1.ConditionReasonSessionStorageConfigured, "Redis session storage is configured", metav1.ConditionFalse, ) } } else { statusManager.SetCondition( - mcpv1alpha1.ConditionSessionStorageWarning, - mcpv1alpha1.ConditionReasonSessionStorageNotApplicable, + mcpv1beta1.ConditionSessionStorageWarning, + mcpv1beta1.ConditionReasonSessionStorageNotApplicable, "session storage warning is not active", metav1.ConditionFalse, ) @@ -408,17 +408,17 @@ func (*VirtualMCPServerReconciler) validateSessionStorageForReplicas( // AuthServerConfigValidated condition. Returns an error when validation fails // (caller should NOT requeue — user must fix the spec). func (*VirtualMCPServerReconciler) validateAuthServerConfig( - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, ) error { cfg := vmcp.Spec.AuthServerConfig if cfg.Issuer == "" { message := "spec.authServerConfig.issuer is required" - statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) statusManager.SetMessage(message) statusManager.SetAuthServerConfigValidatedCondition( - mcpv1alpha1.ConditionReasonAuthServerConfigInvalid, + mcpv1beta1.ConditionReasonAuthServerConfigInvalid, message, metav1.ConditionFalse, ) @@ -428,10 +428,10 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( if len(cfg.UpstreamProviders) == 0 { message := "spec.authServerConfig.upstreamProviders is required" - statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) statusManager.SetMessage(message) statusManager.SetAuthServerConfigValidatedCondition( - mcpv1alpha1.ConditionReasonAuthServerConfigInvalid, + mcpv1beta1.ConditionReasonAuthServerConfigInvalid, message, metav1.ConditionFalse, ) @@ -443,12 +443,12 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( for i := range cfg.UpstreamProviders { prefix := fmt.Sprintf("spec.authServerConfig.upstreamProviders[%d]", i) params := cfg.UpstreamProviders[i].AdditionalAuthorizationParams() - if err := mcpv1alpha1.ValidateAdditionalAuthorizationParams(prefix, params); err != nil { + if err := mcpv1beta1.ValidateAdditionalAuthorizationParams(prefix, params); err != nil { message := err.Error() - statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) statusManager.SetMessage(message) statusManager.SetAuthServerConfigValidatedCondition( - mcpv1alpha1.ConditionReasonAuthServerConfigInvalid, + mcpv1beta1.ConditionReasonAuthServerConfigInvalid, message, metav1.ConditionFalse, ) @@ -459,7 +459,7 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( // AuthServerConfig is valid statusManager.SetAuthServerConfigValidatedCondition( - mcpv1alpha1.ConditionReasonAuthServerConfigValid, + mcpv1beta1.ConditionReasonAuthServerConfigValid, "AuthServerConfig is valid", metav1.ConditionTrue, ) @@ -473,7 +473,7 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig( // Otherwise it returns the original error unchanged for normal requeue handling. func (r *VirtualMCPServerReconciler) handleSpecValidationError( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, err error, ) error { @@ -492,13 +492,13 @@ func (r *VirtualMCPServerReconciler) handleSpecValidationError( // validateGroupRef validates that the referenced MCPGroup exists and is ready func (r *VirtualMCPServerReconciler) validateGroupRef( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, ) error { ctxLogger := log.FromContext(ctx) // Validate GroupRef exists - mcpGroup := &mcpv1alpha1.MCPGroup{} + mcpGroup := &mcpv1beta1.MCPGroup{} err := r.Get(ctx, types.NamespacedName{ Name: vmcp.ResolveGroupName(), Namespace: vmcp.Namespace, @@ -506,10 +506,10 @@ func (r *VirtualMCPServerReconciler) validateGroupRef( if errors.IsNotFound(err) { message := fmt.Sprintf("Referenced MCPGroup %s not found", vmcp.ResolveGroupName()) - statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) statusManager.SetMessage(message) statusManager.SetGroupRefValidatedCondition( - mcpv1alpha1.ConditionReasonVirtualMCPServerGroupRefNotFound, + mcpv1beta1.ConditionReasonVirtualMCPServerGroupRefNotFound, message, metav1.ConditionFalse, ) @@ -521,13 +521,13 @@ func (r *VirtualMCPServerReconciler) validateGroupRef( } // Check if MCPGroup is ready - if mcpGroup.Status.Phase != mcpv1alpha1.MCPGroupPhaseReady { + if mcpGroup.Status.Phase != mcpv1beta1.MCPGroupPhaseReady { message := fmt.Sprintf("Referenced MCPGroup %s is not ready (phase: %s)", vmcp.ResolveGroupName(), mcpGroup.Status.Phase) - statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhasePending) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhasePending) statusManager.SetMessage(message) statusManager.SetGroupRefValidatedCondition( - mcpv1alpha1.ConditionReasonVirtualMCPServerGroupRefNotReady, + mcpv1beta1.ConditionReasonVirtualMCPServerGroupRefNotReady, message, metav1.ConditionFalse, ) @@ -538,7 +538,7 @@ func (r *VirtualMCPServerReconciler) validateGroupRef( // GroupRef is valid and ready statusManager.SetGroupRefValidatedCondition( - mcpv1alpha1.ConditionReasonVirtualMCPServerGroupRefValid, + mcpv1beta1.ConditionReasonVirtualMCPServerGroupRefValid, fmt.Sprintf("MCPGroup %s is valid and ready", vmcp.ResolveGroupName()), metav1.ConditionTrue, ) @@ -550,7 +550,7 @@ func (r *VirtualMCPServerReconciler) validateGroupRef( // validateCompositeToolRefs validates that all referenced VirtualMCPCompositeToolDefinition resources exist func (r *VirtualMCPServerReconciler) validateCompositeToolRefs( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, ) error { ctxLogger := log.FromContext(ctx) @@ -560,7 +560,7 @@ func (r *VirtualMCPServerReconciler) validateCompositeToolRefs( // Set condition to indicate validation passed (no refs to validate) statusManager.SetObservedGeneration(vmcp.Generation) statusManager.SetCompositeToolRefsValidatedCondition( - mcpv1alpha1.ConditionReasonCompositeToolRefsValid, + mcpv1beta1.ConditionReasonCompositeToolRefsValid, "No composite tool references to validate", metav1.ConditionTrue, ) @@ -570,7 +570,7 @@ func (r *VirtualMCPServerReconciler) validateCompositeToolRefs( // Validate each referenced composite tool definition exists for i := range vmcp.Spec.Config.CompositeToolRefs { ref := &vmcp.Spec.Config.CompositeToolRefs[i] - compositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{} + compositeToolDef := &mcpv1beta1.VirtualMCPCompositeToolDefinition{} err := r.Get(ctx, types.NamespacedName{ Name: ref.Name, Namespace: vmcp.Namespace, @@ -579,10 +579,10 @@ func (r *VirtualMCPServerReconciler) validateCompositeToolRefs( if errors.IsNotFound(err) { message := fmt.Sprintf("Referenced VirtualMCPCompositeToolDefinition %s not found", ref.Name) statusManager.SetObservedGeneration(vmcp.Generation) - statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) statusManager.SetMessage(message) statusManager.SetCompositeToolRefsValidatedCondition( - mcpv1alpha1.ConditionReasonCompositeToolRefNotFound, + mcpv1beta1.ConditionReasonCompositeToolRefNotFound, message, metav1.ConditionFalse, ) @@ -593,16 +593,16 @@ func (r *VirtualMCPServerReconciler) validateCompositeToolRefs( } // Check that the composite tool definition is validated and valid - if compositeToolDef.Status.ValidationStatus == mcpv1alpha1.ValidationStatusInvalid { + if compositeToolDef.Status.ValidationStatus == mcpv1beta1.ValidationStatusInvalid { message := fmt.Sprintf("Referenced VirtualMCPCompositeToolDefinition %s is invalid", ref.Name) if len(compositeToolDef.Status.ValidationErrors) > 0 { message = fmt.Sprintf("%s: %s", message, strings.Join(compositeToolDef.Status.ValidationErrors, "; ")) } statusManager.SetObservedGeneration(vmcp.Generation) - statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) statusManager.SetMessage(message) statusManager.SetCompositeToolRefsValidatedCondition( - mcpv1alpha1.ConditionReasonCompositeToolRefInvalid, + mcpv1beta1.ConditionReasonCompositeToolRefInvalid, message, metav1.ConditionFalse, ) @@ -611,7 +611,7 @@ func (r *VirtualMCPServerReconciler) validateCompositeToolRefs( // If ValidationStatus is Unknown, we still allow it (validation might be in progress) // but log a warning - if compositeToolDef.Status.ValidationStatus == mcpv1alpha1.ValidationStatusUnknown { + if compositeToolDef.Status.ValidationStatus == mcpv1beta1.ValidationStatusUnknown { ctxLogger.V(1).Info("Referenced composite tool definition validation status is Unknown, proceeding", "name", ref.Name, "namespace", vmcp.Namespace) } @@ -620,7 +620,7 @@ func (r *VirtualMCPServerReconciler) validateCompositeToolRefs( // All composite tool refs are valid statusManager.SetObservedGeneration(vmcp.Generation) statusManager.SetCompositeToolRefsValidatedCondition( - mcpv1alpha1.ConditionReasonCompositeToolRefsValid, + mcpv1beta1.ConditionReasonCompositeToolRefsValid, fmt.Sprintf("All %d composite tool references are valid", len(vmcp.Spec.Config.CompositeToolRefs)), metav1.ConditionTrue, ) @@ -633,7 +633,7 @@ func (r *VirtualMCPServerReconciler) validateCompositeToolRefs( // The caller is responsible for applying status updates via applyStatusUpdates(). func (r *VirtualMCPServerReconciler) validateAndUpdatePodTemplateStatus( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, ) bool { ctxLogger := log.FromContext(ctx) @@ -653,11 +653,11 @@ func (r *VirtualMCPServerReconciler) validateAndUpdatePodTemplateStatus( } // Use StatusManager to collect status changes - statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) statusManager.SetMessage(fmt.Sprintf("Invalid PodTemplateSpec: %v", err)) statusManager.SetCondition( - mcpv1alpha1.ConditionTypeVirtualMCPServerPodTemplateSpecValid, - mcpv1alpha1.ConditionReasonVirtualMCPServerPodTemplateSpecInvalid, + mcpv1beta1.ConditionTypeVirtualMCPServerPodTemplateSpecValid, + mcpv1beta1.ConditionReasonVirtualMCPServerPodTemplateSpecInvalid, fmt.Sprintf("Failed to parse PodTemplateSpec: %v. Deployment blocked until fixed.", err), metav1.ConditionFalse, ) @@ -669,8 +669,8 @@ func (r *VirtualMCPServerReconciler) validateAndUpdatePodTemplateStatus( // Use StatusManager to collect status changes for valid PodTemplateSpec statusManager.SetCondition( - mcpv1alpha1.ConditionTypeVirtualMCPServerPodTemplateSpecValid, - mcpv1alpha1.ConditionReasonVirtualMCPServerPodTemplateSpecValid, + mcpv1beta1.ConditionTypeVirtualMCPServerPodTemplateSpecValid, + mcpv1beta1.ConditionReasonVirtualMCPServerPodTemplateSpecValid, "PodTemplateSpec is valid", metav1.ConditionTrue, ) @@ -686,8 +686,8 @@ func (r *VirtualMCPServerReconciler) validateAndUpdatePodTemplateStatus( // (e.g., waiting for EmbeddingServer readiness), and an error for failures. func (r *VirtualMCPServerReconciler) ensureAllResources( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, - telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, + vmcp *mcpv1beta1.VirtualMCPServer, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, statusManager virtualmcpserverstatus.StatusManager, ) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) @@ -707,10 +707,10 @@ func (r *VirtualMCPServerReconciler) ensureAllResources( } // EmbeddingServer is configured but not yet ready — requeue if esURL == nil && vmcp.Spec.EmbeddingServerRef != nil { - statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhasePending) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhasePending) statusManager.SetMessage("Waiting for EmbeddingServer to become ready") statusManager.SetEmbeddingServerReadyCondition( - mcpv1alpha1.ConditionReasonEmbeddingServerNotReady, + mcpv1beta1.ConditionReasonEmbeddingServerNotReady, "EmbeddingServer is not yet ready", metav1.ConditionFalse, ) @@ -720,7 +720,7 @@ func (r *VirtualMCPServerReconciler) ensureAllResources( // If an embedding server is configured and ready, set the condition if esURL != nil { statusManager.SetEmbeddingServerReadyCondition( - mcpv1alpha1.ConditionReasonEmbeddingServerReady, + mcpv1beta1.ConditionReasonEmbeddingServerReady, "EmbeddingServer is ready", metav1.ConditionTrue, ) @@ -784,14 +784,14 @@ func (r *VirtualMCPServerReconciler) ensureAllResources( // ensureAuthSecretsValid validates secret references and sets the AuthConfigured condition. func (r *VirtualMCPServerReconciler) ensureAuthSecretsValid( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, ) error { if err := r.validateSecretReferences(ctx, vmcp); err != nil { ctxLogger := log.FromContext(ctx) ctxLogger.Error(err, "Secret validation failed") statusManager.SetAuthConfiguredCondition( - mcpv1alpha1.ConditionReasonAuthInvalid, + mcpv1beta1.ConditionReasonAuthInvalid, fmt.Sprintf("Authentication configuration is invalid: %v", err), metav1.ConditionFalse, ) @@ -804,7 +804,7 @@ func (r *VirtualMCPServerReconciler) ensureAuthSecretsValid( } statusManager.SetAuthConfiguredCondition( - mcpv1alpha1.ConditionReasonAuthValid, + mcpv1beta1.ConditionReasonAuthValid, "Authentication configuration is valid", metav1.ConditionTrue, ) @@ -823,7 +823,7 @@ func (r *VirtualMCPServerReconciler) ensureAuthSecretsValid( // automatically during operator upgrades. func (r *VirtualMCPServerReconciler) ensureRBACResources( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) error { // If a service account is specified, we don't need to create one if vmcp.Spec.ServiceAccount != nil { @@ -863,7 +863,7 @@ func (r *VirtualMCPServerReconciler) ensureRBACResources( // and contains a single key: hmac-secret with a 32-byte base64-encoded random value. func (r *VirtualMCPServerReconciler) ensureHMACSecret( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) error { ctxLogger := log.FromContext(ctx) @@ -948,7 +948,7 @@ func (r *VirtualMCPServerReconciler) ensureHMACSecret( // secrets that could weaken session token signing or cause pod startup failures. func (*VirtualMCPServerReconciler) validateHMACSecret( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, secret *corev1.Secret, ) error { ctxLogger := log.FromContext(ctx) @@ -1014,7 +1014,7 @@ func (*VirtualMCPServerReconciler) validateHMACSecret( // and uses the same annotation constant for consistency. func (r *VirtualMCPServerReconciler) getVmcpConfigChecksum( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) (string, error) { if vmcp == nil { return "", fmt.Errorf("vmcp cannot be nil") @@ -1053,8 +1053,8 @@ func (r *VirtualMCPServerReconciler) getVmcpConfigChecksum( //nolint:unparam // ctrl.Result needed for ConfigMap not found case (RequeueAfter) func (r *VirtualMCPServerReconciler) ensureDeployment( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, - telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, + vmcp *mcpv1beta1.VirtualMCPServer, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) @@ -1156,7 +1156,7 @@ func (r *VirtualMCPServerReconciler) ensureDeployment( //nolint:unparam // ctrl.Result kept for consistency with ensureDeployment signature func (r *VirtualMCPServerReconciler) ensureService( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) @@ -1227,7 +1227,7 @@ func (r *VirtualMCPServerReconciler) ensureService( // ensureServiceURL ensures the service URL is set in the status func (*VirtualMCPServerReconciler) ensureServiceURL( - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, ) { if vmcp.Status.URL == "" { @@ -1240,9 +1240,9 @@ func (*VirtualMCPServerReconciler) ensureServiceURL( func (r *VirtualMCPServerReconciler) deploymentNeedsUpdate( ctx context.Context, deployment *appsv1.Deployment, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, vmcpConfigChecksum string, - telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) bool { if deployment == nil || vmcp == nil { @@ -1284,8 +1284,8 @@ func (r *VirtualMCPServerReconciler) deploymentNeedsUpdate( func (r *VirtualMCPServerReconciler) containerNeedsUpdate( ctx context.Context, deployment *appsv1.Deployment, - vmcp *mcpv1alpha1.VirtualMCPServer, - telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, + vmcp *mcpv1beta1.VirtualMCPServer, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) bool { if deployment == nil || vmcp == nil || len(deployment.Spec.Template.Spec.Containers) == 0 { @@ -1329,7 +1329,7 @@ func (r *VirtualMCPServerReconciler) containerNeedsUpdate( // deploymentMetadataNeedsUpdate checks if deployment-level metadata has changed func (*VirtualMCPServerReconciler) deploymentMetadataNeedsUpdate( deployment *appsv1.Deployment, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) bool { if deployment == nil || vmcp == nil { return true @@ -1362,7 +1362,7 @@ func (*VirtualMCPServerReconciler) deploymentMetadataNeedsUpdate( // podTemplateMetadataNeedsUpdate checks if pod template metadata has changed func (r *VirtualMCPServerReconciler) podTemplateMetadataNeedsUpdate( deployment *appsv1.Deployment, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, vmcpConfigChecksum string, ) bool { if deployment == nil || vmcp == nil { @@ -1391,7 +1391,7 @@ func (r *VirtualMCPServerReconciler) podTemplateMetadataNeedsUpdate( func (*VirtualMCPServerReconciler) podTemplateSpecNeedsUpdate( ctx context.Context, deployment *appsv1.Deployment, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, _ []workloads.TypedWorkload, ) bool { if deployment == nil || vmcp == nil { @@ -1420,7 +1420,7 @@ func (*VirtualMCPServerReconciler) podTemplateSpecNeedsUpdate( // serviceNeedsUpdate checks if the service needs to be updated func (*VirtualMCPServerReconciler) serviceNeedsUpdate( service *corev1.Service, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) bool { if service == nil || vmcp == nil { return true @@ -1507,7 +1507,7 @@ func (*VirtualMCPServerReconciler) serviceNeedsUpdate( // statusDecision encapsulates the status update decision to reduce branching and repetition type statusDecision struct { - phase mcpv1alpha1.VirtualMCPServerPhase + phase mcpv1beta1.VirtualMCPServerPhase message string reason string conditionMsg string @@ -1518,16 +1518,16 @@ type statusDecision struct { // Unauthenticated backends are routable — they are reachable but require per-request // user auth (e.g., upstream OAuth). Health probes lack user tokens, but real requests // with valid OAuth tokens will be served. -func countBackendHealth(ctx context.Context, backends []mcpv1alpha1.DiscoveredBackend) (routable, unhealthy int) { +func countBackendHealth(ctx context.Context, backends []mcpv1beta1.DiscoveredBackend) (routable, unhealthy int) { ctxLogger := log.FromContext(ctx) for _, backend := range backends { switch backend.Status { - case mcpv1alpha1.BackendStatusReady, mcpv1alpha1.BackendStatusUnauthenticated: + case mcpv1beta1.BackendStatusReady, mcpv1beta1.BackendStatusUnauthenticated: routable++ - case mcpv1alpha1.BackendStatusUnavailable, - mcpv1alpha1.BackendStatusDegraded, - mcpv1alpha1.BackendStatusUnknown: + case mcpv1beta1.BackendStatusUnavailable, + mcpv1beta1.BackendStatusDegraded, + mcpv1beta1.BackendStatusUnknown: unhealthy++ default: ctxLogger.V(1).Info("Unexpected backend status, treating as unhealthy", @@ -1541,7 +1541,7 @@ func countBackendHealth(ctx context.Context, backends []mcpv1alpha1.DiscoveredBa // determineStatusFromBackends evaluates backend health to determine status func (*VirtualMCPServerReconciler) determineStatusFromBackends( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) statusDecision { ctxLogger := log.FromContext(ctx) @@ -1551,7 +1551,7 @@ func (*VirtualMCPServerReconciler) determineStatusFromBackends( // All backends unhealthy if routable == 0 && unhealthy > 0 { return statusDecision{ - phase: mcpv1alpha1.VirtualMCPServerPhaseDegraded, + phase: mcpv1beta1.VirtualMCPServerPhaseDegraded, message: fmt.Sprintf("Virtual MCP server is running but all %d backends are unhealthy", unhealthy), reason: "BackendsUnavailable", conditionMsg: "All backends are unhealthy", @@ -1562,7 +1562,7 @@ func (*VirtualMCPServerReconciler) determineStatusFromBackends( // Some backends unhealthy if unhealthy > 0 { return statusDecision{ - phase: mcpv1alpha1.VirtualMCPServerPhaseDegraded, + phase: mcpv1beta1.VirtualMCPServerPhaseDegraded, message: fmt.Sprintf("Virtual MCP server is running with %d/%d backends available", routable, total), reason: "BackendsDegraded", conditionMsg: "Some backends are unhealthy", @@ -1573,7 +1573,7 @@ func (*VirtualMCPServerReconciler) determineStatusFromBackends( // All backends routable if routable > 0 { return statusDecision{ - phase: mcpv1alpha1.VirtualMCPServerPhaseReady, + phase: mcpv1beta1.VirtualMCPServerPhaseReady, message: "Virtual MCP server is running", reason: "DeploymentReady", conditionMsg: "Deployment is ready", @@ -1585,7 +1585,7 @@ func (*VirtualMCPServerReconciler) determineStatusFromBackends( ctxLogger.V(1).Info("No backends were counted, treating as degraded", "discoveredBackendsCount", len(vmcp.Status.DiscoveredBackends)) return statusDecision{ - phase: mcpv1alpha1.VirtualMCPServerPhaseDegraded, + phase: mcpv1beta1.VirtualMCPServerPhaseDegraded, message: "Virtual MCP server is running but backend status cannot be determined", reason: "BackendsUnknown", conditionMsg: "Backend status unknown", @@ -1599,14 +1599,14 @@ func (*VirtualMCPServerReconciler) determineStatusFromBackends( // the underlying pods are actually ready to serve traffic. func (r *VirtualMCPServerReconciler) determineStatusFromPods( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ready, pending, failed int, ) statusDecision { // Handle non-ready states first (early returns reduce nesting) if ready == 0 { if failed > 0 { return statusDecision{ - phase: mcpv1alpha1.VirtualMCPServerPhaseFailed, + phase: mcpv1beta1.VirtualMCPServerPhaseFailed, message: "Virtual MCP server failed to start", reason: "DeploymentFailed", conditionMsg: "Deployment failed", @@ -1619,7 +1619,7 @@ func (r *VirtualMCPServerReconciler) determineStatusFromPods( msg = "No pods found for Virtual MCP server" } return statusDecision{ - phase: mcpv1alpha1.VirtualMCPServerPhasePending, + phase: mcpv1beta1.VirtualMCPServerPhasePending, message: msg, reason: "DeploymentNotReady", conditionMsg: "Deployment is not yet ready", @@ -1631,7 +1631,7 @@ func (r *VirtualMCPServerReconciler) determineStatusFromPods( if len(vmcp.Status.DiscoveredBackends) == 0 { // No backends discovered yet - pods ready is sufficient for Ready return statusDecision{ - phase: mcpv1alpha1.VirtualMCPServerPhaseReady, + phase: mcpv1beta1.VirtualMCPServerPhaseReady, message: "Virtual MCP server is running", reason: "DeploymentReady", conditionMsg: "Deployment is ready", @@ -1645,7 +1645,7 @@ func (r *VirtualMCPServerReconciler) determineStatusFromPods( func (r *VirtualMCPServerReconciler) updateVirtualMCPServerStatus( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, ) error { // List the pods for this VirtualMCPServer's deployment @@ -1720,7 +1720,7 @@ func vmcpServiceAccountName(vmcpName string) string { // outgoingAuthSource returns the outgoing auth source mode with default fallback. // Returns OutgoingAuthSourceDiscovered if not specified. -func outgoingAuthSource(vmcp *mcpv1alpha1.VirtualMCPServer) string { +func outgoingAuthSource(vmcp *mcpv1beta1.VirtualMCPServer) string { if vmcp.Spec.OutgoingAuth != nil && vmcp.Spec.OutgoingAuth.Source != "" { return vmcp.Spec.OutgoingAuth.Source } @@ -1730,7 +1730,7 @@ func outgoingAuthSource(vmcp *mcpv1alpha1.VirtualMCPServer) string { // serviceAccountNameForVmcp returns the service account name for a VirtualMCPServer. // - User-provided service account: Returns the user-specified service account name // - All other modes: Returns the dedicated service account name (for status reporting) -func (*VirtualMCPServerReconciler) serviceAccountNameForVmcp(vmcp *mcpv1alpha1.VirtualMCPServer) string { +func (*VirtualMCPServerReconciler) serviceAccountNameForVmcp(vmcp *mcpv1beta1.VirtualMCPServer) string { // If a service account is specified, use it if vmcp.Spec.ServiceAccount != nil { return *vmcp.Spec.ServiceAccount @@ -1776,7 +1776,7 @@ func createVmcpServiceURL(vmcpName, namespace string, port int32) string { // For ConfigMap mode (inline), secrets are referenced as environment variables that will be // mounted in the deployment. Each ExternalAuthConfig gets a unique env var name to avoid conflicts. func (*VirtualMCPServerReconciler) convertExternalAuthConfigToStrategy( - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig, + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig, ) (*authtypes.BackendAuthStrategy, error) { // Use the converter registry to convert to typed strategy registry := converters.DefaultRegistry() @@ -1811,10 +1811,10 @@ func (*VirtualMCPServerReconciler) convertExternalAuthConfigToStrategy( func (r *VirtualMCPServerReconciler) convertBackendAuthConfigToVMCP( ctx context.Context, namespace string, - crdConfig *mcpv1alpha1.BackendAuthConfig, + crdConfig *mcpv1beta1.BackendAuthConfig, ) (*authtypes.BackendAuthStrategy, error) { // For type="discovered", return a minimal strategy (will be populated by discovery) - if crdConfig.Type == mcpv1alpha1.BackendAuthTypeDiscovered { + if crdConfig.Type == mcpv1beta1.BackendAuthTypeDiscovered { return &authtypes.BackendAuthStrategy{ Type: crdConfig.Type, }, nil @@ -1843,12 +1843,12 @@ func (r *VirtualMCPServerReconciler) convertBackendAuthConfigToVMCP( func (r *VirtualMCPServerReconciler) listMCPServersAsMap( ctx context.Context, namespace string, -) (map[string]*mcpv1alpha1.MCPServer, error) { - mcpServerList := &mcpv1alpha1.MCPServerList{} +) (map[string]*mcpv1beta1.MCPServer, error) { + mcpServerList := &mcpv1beta1.MCPServerList{} if err := r.List(ctx, mcpServerList, client.InNamespace(namespace)); err != nil { return nil, err } - mcpServerMap := make(map[string]*mcpv1alpha1.MCPServer, len(mcpServerList.Items)) + mcpServerMap := make(map[string]*mcpv1beta1.MCPServer, len(mcpServerList.Items)) for i := range mcpServerList.Items { mcpServerMap[mcpServerList.Items[i].Name] = &mcpServerList.Items[i] } @@ -1859,12 +1859,12 @@ func (r *VirtualMCPServerReconciler) listMCPServersAsMap( func (r *VirtualMCPServerReconciler) listMCPRemoteProxiesAsMap( ctx context.Context, namespace string, -) (map[string]*mcpv1alpha1.MCPRemoteProxy, error) { - mcpRemoteProxyList := &mcpv1alpha1.MCPRemoteProxyList{} +) (map[string]*mcpv1beta1.MCPRemoteProxy, error) { + mcpRemoteProxyList := &mcpv1beta1.MCPRemoteProxyList{} if err := r.List(ctx, mcpRemoteProxyList, client.InNamespace(namespace)); err != nil { return nil, err } - mcpRemoteProxyMap := make(map[string]*mcpv1alpha1.MCPRemoteProxy, len(mcpRemoteProxyList.Items)) + mcpRemoteProxyMap := make(map[string]*mcpv1beta1.MCPRemoteProxy, len(mcpRemoteProxyList.Items)) for i := range mcpRemoteProxyList.Items { mcpRemoteProxyMap[mcpRemoteProxyList.Items[i].Name] = &mcpRemoteProxyList.Items[i] } @@ -1875,12 +1875,12 @@ func (r *VirtualMCPServerReconciler) listMCPRemoteProxiesAsMap( func (r *VirtualMCPServerReconciler) listMCPServerEntriesAsMap( ctx context.Context, namespace string, -) (map[string]*mcpv1alpha1.MCPServerEntry, error) { - mcpServerEntryList := &mcpv1alpha1.MCPServerEntryList{} +) (map[string]*mcpv1beta1.MCPServerEntry, error) { + mcpServerEntryList := &mcpv1beta1.MCPServerEntryList{} if err := r.List(ctx, mcpServerEntryList, client.InNamespace(namespace)); err != nil { return nil, err } - mcpServerEntryMap := make(map[string]*mcpv1alpha1.MCPServerEntry, len(mcpServerEntryList.Items)) + mcpServerEntryMap := make(map[string]*mcpv1beta1.MCPServerEntry, len(mcpServerEntryList.Items)) for i := range mcpServerEntryList.Items { mcpServerEntryMap[mcpServerEntryList.Items[i].Name] = &mcpServerEntryList.Items[i] } @@ -1892,7 +1892,7 @@ func (r *VirtualMCPServerReconciler) listMCPServerEntriesAsMap( // The controller should continue in degraded mode even if some auth configs fail. func (r *VirtualMCPServerReconciler) discoverExternalAuthConfigs( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, typedWorkloads []workloads.TypedWorkload, outgoing *vmcpconfig.OutgoingAuthConfig, ) ([]string, []AuthConfigError) { @@ -1974,9 +1974,9 @@ func (r *VirtualMCPServerReconciler) discoverExternalAuthConfigs( // getExternalAuthConfigNameFromWorkload extracts the ExternalAuthConfigRef name from a workload. func (*VirtualMCPServerReconciler) getExternalAuthConfigNameFromWorkload( workloadInfo workloads.TypedWorkload, - mcpServerMap map[string]*mcpv1alpha1.MCPServer, - mcpRemoteProxyMap map[string]*mcpv1alpha1.MCPRemoteProxy, - mcpServerEntryMap map[string]*mcpv1alpha1.MCPServerEntry, + mcpServerMap map[string]*mcpv1beta1.MCPServer, + mcpRemoteProxyMap map[string]*mcpv1beta1.MCPRemoteProxy, + mcpServerEntryMap map[string]*mcpv1beta1.MCPServerEntry, ) string { switch workloadInfo.Type { case workloads.WorkloadTypeMCPServer: @@ -2018,7 +2018,7 @@ func (*VirtualMCPServerReconciler) getExternalAuthConfigNameFromWorkload( // This allows the system to continue operating in degraded mode with partial auth configuration. func (r *VirtualMCPServerReconciler) buildOutgoingAuthConfig( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, typedWorkloads []workloads.TypedWorkload, ) (*vmcpconfig.OutgoingAuthConfig, []string, []AuthConfigError) { // Determine source - default to "discovered" if not specified @@ -2084,7 +2084,7 @@ func (r *VirtualMCPServerReconciler) buildOutgoingAuthConfig( // SubjectProviderName set, or no embedded auth server is configured. func injectSubjectProviderIfNeeded( strategy *authtypes.BackendAuthStrategy, - embeddedCfg *mcpv1alpha1.EmbeddedAuthServerConfig, + embeddedCfg *mcpv1beta1.EmbeddedAuthServerConfig, ) *authtypes.BackendAuthStrategy { if strategy == nil || strategy.Type != authtypes.StrategyTypeTokenExchange || @@ -2156,7 +2156,7 @@ func convertBackendsToStaticBackends( // ensuring consistent retry behavior (fixed-interval requeue instead of exponential backoff). func (r *VirtualMCPServerReconciler) validateEmbeddingServerRef( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, ) error { ctxLogger := log.FromContext(ctx) @@ -2166,7 +2166,7 @@ func (r *VirtualMCPServerReconciler) validateEmbeddingServerRef( } refName := vmcp.Spec.EmbeddingServerRef.Name - es := &mcpv1alpha1.EmbeddingServer{} + es := &mcpv1beta1.EmbeddingServer{} err := r.Get(ctx, types.NamespacedName{ Name: refName, Namespace: vmcp.Namespace, @@ -2174,10 +2174,10 @@ func (r *VirtualMCPServerReconciler) validateEmbeddingServerRef( if errors.IsNotFound(err) { message := fmt.Sprintf("Referenced EmbeddingServer %s not found", refName) - statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) statusManager.SetMessage(message) statusManager.SetEmbeddingServerReadyCondition( - mcpv1alpha1.ConditionReasonEmbeddingServerNotFound, + mcpv1beta1.ConditionReasonEmbeddingServerNotFound, message, metav1.ConditionFalse, ) @@ -2203,12 +2203,12 @@ func (r *VirtualMCPServerReconciler) mapEmbeddingServerToVirtualMCPServer( ctx context.Context, obj client.Object, ) []reconcile.Request { - es, ok := obj.(*mcpv1alpha1.EmbeddingServer) + es, ok := obj.(*mcpv1beta1.EmbeddingServer) if !ok { return nil } - vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + vmcpList := &mcpv1beta1.VirtualMCPServerList{} if err := r.List(ctx, vmcpList, client.InNamespace(es.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list VirtualMCPServers for EmbeddingServer watch") return nil @@ -2233,36 +2233,36 @@ func (r *VirtualMCPServerReconciler) mapEmbeddingServerToVirtualMCPServer( // SetupWithManager sets up the controller with the Manager func (r *VirtualMCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1alpha1.VirtualMCPServer{}). + For(&mcpv1beta1.VirtualMCPServer{}). Owns(&appsv1.Deployment{}). Owns(&corev1.Service{}). Owns(&corev1.ConfigMap{}). - Watches(&mcpv1alpha1.MCPGroup{}, handler.EnqueueRequestsFromMapFunc(r.mapMCPGroupToVirtualMCPServer)). - Watches(&mcpv1alpha1.MCPServer{}, handler.EnqueueRequestsFromMapFunc(r.mapMCPServerToVirtualMCPServer)). - Watches(&mcpv1alpha1.MCPRemoteProxy{}, handler.EnqueueRequestsFromMapFunc(r.mapMCPRemoteProxyToVirtualMCPServer)). - Watches(&mcpv1alpha1.MCPServerEntry{}, handler.EnqueueRequestsFromMapFunc(r.mapMCPServerEntryToVirtualMCPServer)). - Watches(&mcpv1alpha1.MCPExternalAuthConfig{}, handler.EnqueueRequestsFromMapFunc(r.mapExternalAuthConfigToVirtualMCPServer)). - Watches(&mcpv1alpha1.MCPToolConfig{}, handler.EnqueueRequestsFromMapFunc(r.mapToolConfigToVirtualMCPServer)). + Watches(&mcpv1beta1.MCPGroup{}, handler.EnqueueRequestsFromMapFunc(r.mapMCPGroupToVirtualMCPServer)). + Watches(&mcpv1beta1.MCPServer{}, handler.EnqueueRequestsFromMapFunc(r.mapMCPServerToVirtualMCPServer)). + Watches(&mcpv1beta1.MCPRemoteProxy{}, handler.EnqueueRequestsFromMapFunc(r.mapMCPRemoteProxyToVirtualMCPServer)). + Watches(&mcpv1beta1.MCPServerEntry{}, handler.EnqueueRequestsFromMapFunc(r.mapMCPServerEntryToVirtualMCPServer)). + Watches(&mcpv1beta1.MCPExternalAuthConfig{}, handler.EnqueueRequestsFromMapFunc(r.mapExternalAuthConfigToVirtualMCPServer)). + Watches(&mcpv1beta1.MCPToolConfig{}, handler.EnqueueRequestsFromMapFunc(r.mapToolConfigToVirtualMCPServer)). Watches( - &mcpv1alpha1.VirtualMCPCompositeToolDefinition{}, + &mcpv1beta1.VirtualMCPCompositeToolDefinition{}, handler.EnqueueRequestsFromMapFunc(r.mapCompositeToolDefinitionToVirtualMCPServer), ). // Watch referenced EmbeddingServers so that readiness/status changes // trigger VirtualMCPServer reconciliation. Watches( - &mcpv1alpha1.EmbeddingServer{}, + &mcpv1beta1.EmbeddingServer{}, handler.EnqueueRequestsFromMapFunc(r.mapEmbeddingServerToVirtualMCPServer), ). // Watch referenced MCPOIDCConfigs so that validity/hash changes // trigger VirtualMCPServer reconciliation. Watches( - &mcpv1alpha1.MCPOIDCConfig{}, + &mcpv1beta1.MCPOIDCConfig{}, handler.EnqueueRequestsFromMapFunc(r.mapOIDCConfigToVirtualMCPServer), ). // Watch referenced MCPTelemetryConfigs so that validity/hash changes // trigger VirtualMCPServer reconciliation. Watches( - &mcpv1alpha1.MCPTelemetryConfig{}, + &mcpv1beta1.MCPTelemetryConfig{}, handler.EnqueueRequestsFromMapFunc(r.mapTelemetryConfigToVirtualMCPServer), ). Complete(r) @@ -2270,12 +2270,12 @@ func (r *VirtualMCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { // mapMCPGroupToVirtualMCPServer maps MCPGroup changes to VirtualMCPServer reconciliation requests func (r *VirtualMCPServerReconciler) mapMCPGroupToVirtualMCPServer(ctx context.Context, obj client.Object) []reconcile.Request { - mcpGroup, ok := obj.(*mcpv1alpha1.MCPGroup) + mcpGroup, ok := obj.(*mcpv1beta1.MCPGroup) if !ok { return nil } - vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + vmcpList := &mcpv1beta1.VirtualMCPServerList{} if err := r.List(ctx, vmcpList, client.InNamespace(mcpGroup.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list VirtualMCPServers for MCPGroup watch") return nil @@ -2307,7 +2307,7 @@ func (r *VirtualMCPServerReconciler) mapMCPGroupToVirtualMCPServer(ctx context.C // // This significantly reduces unnecessary reconciliations in large clusters with many VirtualMCPServers. func (r *VirtualMCPServerReconciler) mapMCPServerToVirtualMCPServer(ctx context.Context, obj client.Object) []reconcile.Request { - mcpServer, ok := obj.(*mcpv1alpha1.MCPServer) + mcpServer, ok := obj.(*mcpv1beta1.MCPServer) if !ok { return nil } @@ -2316,7 +2316,7 @@ func (r *VirtualMCPServerReconciler) mapMCPServerToVirtualMCPServer(ctx context. // Step 1: Find all MCPGroups that include this MCPServer // MCPGroups track their member servers in Status.Servers (populated by MCPGroup controller) - mcpGroupList := &mcpv1alpha1.MCPGroupList{} + mcpGroupList := &mcpv1beta1.MCPGroupList{} if err := r.List(ctx, mcpGroupList, client.InNamespace(mcpServer.Namespace)); err != nil { ctxLogger.Error(err, "Failed to list MCPGroups for MCPServer watch") return nil @@ -2345,7 +2345,7 @@ func (r *VirtualMCPServerReconciler) mapMCPServerToVirtualMCPServer(ctx context. } // Step 2: Find VirtualMCPServers that reference the affected MCPGroups - vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + vmcpList := &mcpv1beta1.VirtualMCPServerList{} if err := r.List(ctx, vmcpList, client.InNamespace(mcpServer.Namespace)); err != nil { ctxLogger.Error(err, "Failed to list VirtualMCPServers for MCPServer watch") return nil @@ -2388,7 +2388,7 @@ func (r *VirtualMCPServerReconciler) mapMCPRemoteProxyToVirtualMCPServer( ctx context.Context, obj client.Object, ) []reconcile.Request { - mcpRemoteProxy, ok := obj.(*mcpv1alpha1.MCPRemoteProxy) + mcpRemoteProxy, ok := obj.(*mcpv1beta1.MCPRemoteProxy) if !ok { return nil } @@ -2397,7 +2397,7 @@ func (r *VirtualMCPServerReconciler) mapMCPRemoteProxyToVirtualMCPServer( // Step 1: Find all MCPGroups that include this MCPRemoteProxy // MCPGroups track their member remote proxies in Status.RemoteProxies (populated by MCPGroup controller) - mcpGroupList := &mcpv1alpha1.MCPGroupList{} + mcpGroupList := &mcpv1beta1.MCPGroupList{} if err := r.List(ctx, mcpGroupList, client.InNamespace(mcpRemoteProxy.Namespace)); err != nil { ctxLogger.Error(err, "Failed to list MCPGroups for MCPRemoteProxy watch") return nil @@ -2426,7 +2426,7 @@ func (r *VirtualMCPServerReconciler) mapMCPRemoteProxyToVirtualMCPServer( } // Step 2: Find VirtualMCPServers that reference the affected MCPGroups - vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + vmcpList := &mcpv1beta1.VirtualMCPServerList{} if err := r.List(ctx, vmcpList, client.InNamespace(mcpRemoteProxy.Namespace)); err != nil { ctxLogger.Error(err, "Failed to list VirtualMCPServers for MCPRemoteProxy watch") return nil @@ -2469,7 +2469,7 @@ func (r *VirtualMCPServerReconciler) mapMCPServerEntryToVirtualMCPServer( ctx context.Context, obj client.Object, ) []reconcile.Request { - mcpServerEntry, ok := obj.(*mcpv1alpha1.MCPServerEntry) + mcpServerEntry, ok := obj.(*mcpv1beta1.MCPServerEntry) if !ok { return nil } @@ -2477,7 +2477,7 @@ func (r *VirtualMCPServerReconciler) mapMCPServerEntryToVirtualMCPServer( ctxLogger := log.FromContext(ctx) // Step 1: Find all MCPGroups that include this MCPServerEntry - mcpGroupList := &mcpv1alpha1.MCPGroupList{} + mcpGroupList := &mcpv1beta1.MCPGroupList{} if err := r.List(ctx, mcpGroupList, client.InNamespace(mcpServerEntry.Namespace)); err != nil { ctxLogger.Error(err, "Failed to list MCPGroups for MCPServerEntry watch") return nil @@ -2503,7 +2503,7 @@ func (r *VirtualMCPServerReconciler) mapMCPServerEntryToVirtualMCPServer( } // Step 2: Find VirtualMCPServers that reference the affected MCPGroups - vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + vmcpList := &mcpv1beta1.VirtualMCPServerList{} if err := r.List(ctx, vmcpList, client.InNamespace(mcpServerEntry.Namespace)); err != nil { ctxLogger.Error(err, "Failed to list VirtualMCPServers for MCPServerEntry watch") return nil @@ -2538,12 +2538,12 @@ func (r *VirtualMCPServerReconciler) mapExternalAuthConfigToVirtualMCPServer( ctx context.Context, obj client.Object, ) []reconcile.Request { - externalAuthConfig, ok := obj.(*mcpv1alpha1.MCPExternalAuthConfig) + externalAuthConfig, ok := obj.(*mcpv1beta1.MCPExternalAuthConfig) if !ok { return nil } - vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + vmcpList := &mcpv1beta1.VirtualMCPServerList{} if err := r.List(ctx, vmcpList, client.InNamespace(externalAuthConfig.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list VirtualMCPServers for MCPExternalAuthConfig watch") return nil @@ -2568,12 +2568,12 @@ func (r *VirtualMCPServerReconciler) mapExternalAuthConfigToVirtualMCPServer( // mapToolConfigToVirtualMCPServer maps MCPToolConfig changes to VirtualMCPServer reconciliation requests func (r *VirtualMCPServerReconciler) mapToolConfigToVirtualMCPServer(ctx context.Context, obj client.Object) []reconcile.Request { - toolConfig, ok := obj.(*mcpv1alpha1.MCPToolConfig) + toolConfig, ok := obj.(*mcpv1beta1.MCPToolConfig) if !ok { return nil } - vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + vmcpList := &mcpv1beta1.VirtualMCPServerList{} if err := r.List(ctx, vmcpList, client.InNamespace(toolConfig.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list VirtualMCPServers for MCPToolConfig watch") return nil @@ -2595,7 +2595,7 @@ func (r *VirtualMCPServerReconciler) mapToolConfigToVirtualMCPServer(ctx context } // vmcpReferencesToolConfig checks if a VirtualMCPServer references the given MCPToolConfig -func (*VirtualMCPServerReconciler) vmcpReferencesToolConfig(vmcp *mcpv1alpha1.VirtualMCPServer, toolConfigName string) bool { +func (*VirtualMCPServerReconciler) vmcpReferencesToolConfig(vmcp *mcpv1beta1.VirtualMCPServer, toolConfigName string) bool { if vmcp.Spec.Config.Aggregation == nil || len(vmcp.Spec.Config.Aggregation.Tools) == 0 { return false } @@ -2614,7 +2614,7 @@ func (*VirtualMCPServerReconciler) vmcpReferencesToolConfig(vmcp *mcpv1alpha1.Vi // (via MCPServers in the group). func (r *VirtualMCPServerReconciler) vmcpReferencesExternalAuthConfig( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, authConfigName string, ) bool { // Note: AuthServerConfig is inline (not a ref), so it doesn't reference @@ -2655,13 +2655,13 @@ func (r *VirtualMCPServerReconciler) vmcpReferencesExternalAuthConfig( // in the VirtualMCPServer's group reference the given MCPExternalAuthConfig func (r *VirtualMCPServerReconciler) mcpGroupBackendsReferenceExternalAuthConfig( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, authConfigName string, ) bool { ctxLogger := log.FromContext(ctx) // Get the MCPGroup to verify it exists - mcpGroup := &mcpv1alpha1.MCPGroup{} + mcpGroup := &mcpv1beta1.MCPGroup{} err := r.Get(ctx, types.NamespacedName{ Name: vmcp.ResolveGroupName(), Namespace: vmcp.Namespace, @@ -2681,7 +2681,7 @@ func (r *VirtualMCPServerReconciler) mcpGroupBackendsReferenceExternalAuthConfig } // List all MCPServers in the group using field selector (same as MCPGroup controller) - mcpServerList := &mcpv1alpha1.MCPServerList{} + mcpServerList := &mcpv1beta1.MCPServerList{} err = r.List(ctx, mcpServerList, listOpts...) if err != nil { ctxLogger.Error(err, "Failed to list MCPServers for ExternalAuthConfig reference check", @@ -2698,7 +2698,7 @@ func (r *VirtualMCPServerReconciler) mcpGroupBackendsReferenceExternalAuthConfig } // List all MCPRemoteProxies in the group - mcpRemoteProxyList := &mcpv1alpha1.MCPRemoteProxyList{} + mcpRemoteProxyList := &mcpv1beta1.MCPRemoteProxyList{} err = r.List(ctx, mcpRemoteProxyList, listOpts...) if err != nil { ctxLogger.Error(err, "Failed to list MCPRemoteProxies for ExternalAuthConfig reference check", @@ -2723,12 +2723,12 @@ func (r *VirtualMCPServerReconciler) mapCompositeToolDefinitionToVirtualMCPServe ctx context.Context, obj client.Object, ) []reconcile.Request { - compositeToolDef, ok := obj.(*mcpv1alpha1.VirtualMCPCompositeToolDefinition) + compositeToolDef, ok := obj.(*mcpv1beta1.VirtualMCPCompositeToolDefinition) if !ok { return nil } - vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + vmcpList := &mcpv1beta1.VirtualMCPServerList{} if err := r.List(ctx, vmcpList, client.InNamespace(compositeToolDef.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list VirtualMCPServers for VirtualMCPCompositeToolDefinition watch") return nil @@ -2751,7 +2751,7 @@ func (r *VirtualMCPServerReconciler) mapCompositeToolDefinitionToVirtualMCPServe // vmcpReferencesCompositeToolDefinition checks if a VirtualMCPServer references the given VirtualMCPCompositeToolDefinition func (*VirtualMCPServerReconciler) vmcpReferencesCompositeToolDefinition( - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, compositeToolDefName string, ) bool { if len(vmcp.Spec.Config.CompositeToolRefs) == 0 { @@ -2920,9 +2920,9 @@ func generateHMACSecret() (string, error) { // to downstream functions without redundant API calls. func (r *VirtualMCPServerReconciler) handleConfigRefs( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, -) (*mcpv1alpha1.MCPTelemetryConfig, error) { +) (*mcpv1beta1.MCPTelemetryConfig, error) { if err := r.handleOIDCConfig(ctx, vmcp, statusManager); err != nil { return nil, err } @@ -2934,7 +2934,7 @@ func (r *VirtualMCPServerReconciler) handleConfigRefs( // the OIDC configuration changes. func (r *VirtualMCPServerReconciler) handleOIDCConfig( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, ) error { ctxLogger := log.FromContext(ctx) @@ -2953,8 +2953,8 @@ func (r *VirtualMCPServerReconciler) handleOIDCConfig( oidcConfig, err := ctrlutil.GetOIDCConfigForServer(ctx, r.Client, vmcp.Namespace, ref) if err != nil { statusManager.SetCondition( - mcpv1alpha1.ConditionOIDCConfigRefValidated, - mcpv1alpha1.ConditionReasonOIDCConfigRefNotFound, + mcpv1beta1.ConditionOIDCConfigRefValidated, + mcpv1beta1.ConditionReasonOIDCConfigRefNotFound, fmt.Sprintf("MCPOIDCConfig %s not found: %v", ref.Name, err), metav1.ConditionFalse, ) @@ -2963,8 +2963,8 @@ func (r *VirtualMCPServerReconciler) handleOIDCConfig( if oidcConfig == nil { statusManager.SetCondition( - mcpv1alpha1.ConditionOIDCConfigRefValidated, - mcpv1alpha1.ConditionReasonOIDCConfigRefNotFound, + mcpv1beta1.ConditionOIDCConfigRefValidated, + mcpv1beta1.ConditionReasonOIDCConfigRefNotFound, fmt.Sprintf("MCPOIDCConfig %s not found", ref.Name), metav1.ConditionFalse, ) @@ -2972,15 +2972,15 @@ func (r *VirtualMCPServerReconciler) handleOIDCConfig( } // Check that the MCPOIDCConfig is valid - validCondition := meta.FindStatusCondition(oidcConfig.Status.Conditions, mcpv1alpha1.ConditionTypeOIDCConfigValid) + validCondition := meta.FindStatusCondition(oidcConfig.Status.Conditions, mcpv1beta1.ConditionTypeOIDCConfigValid) if validCondition == nil || validCondition.Status != metav1.ConditionTrue { msg := fmt.Sprintf("MCPOIDCConfig %s is not valid", ref.Name) if validCondition != nil { msg = fmt.Sprintf("MCPOIDCConfig %s is not valid: %s", ref.Name, validCondition.Message) } statusManager.SetCondition( - mcpv1alpha1.ConditionOIDCConfigRefValidated, - mcpv1alpha1.ConditionReasonOIDCConfigRefNotValid, + mcpv1beta1.ConditionOIDCConfigRefValidated, + mcpv1beta1.ConditionReasonOIDCConfigRefNotValid, msg, metav1.ConditionFalse, ) @@ -2995,8 +2995,8 @@ func (r *VirtualMCPServerReconciler) handleOIDCConfig( // Set valid condition statusManager.SetCondition( - mcpv1alpha1.ConditionOIDCConfigRefValidated, - mcpv1alpha1.ConditionReasonOIDCConfigRefValid, + mcpv1beta1.ConditionOIDCConfigRefValidated, + mcpv1beta1.ConditionReasonOIDCConfigRefValid, fmt.Sprintf("MCPOIDCConfig %s is valid and ready", ref.Name), metav1.ConditionTrue, ) @@ -3019,10 +3019,10 @@ func (r *VirtualMCPServerReconciler) handleOIDCConfig( // the MCPOIDCConfig's ReferencingWorkloads status field. func (r *VirtualMCPServerReconciler) updateOIDCConfigReferencingWorkloads( ctx context.Context, - oidcConfig *mcpv1alpha1.MCPOIDCConfig, + oidcConfig *mcpv1beta1.MCPOIDCConfig, vmcpName string, ) error { - ref := mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindVirtualMCPServer, Name: vmcpName} + ref := mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindVirtualMCPServer, Name: vmcpName} // Check if already listed for _, entry := range oidcConfig.Status.ReferencingWorkloads { if entry.Kind == ref.Kind && entry.Name == ref.Name { @@ -3043,12 +3043,12 @@ func (r *VirtualMCPServerReconciler) updateOIDCConfigReferencingWorkloads( func (r *VirtualMCPServerReconciler) mapOIDCConfigToVirtualMCPServer( ctx context.Context, obj client.Object, ) []reconcile.Request { - oidcConfig, ok := obj.(*mcpv1alpha1.MCPOIDCConfig) + oidcConfig, ok := obj.(*mcpv1beta1.MCPOIDCConfig) if !ok { return nil } - vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + vmcpList := &mcpv1beta1.VirtualMCPServerList{} if err := r.List(ctx, vmcpList, client.InNamespace(oidcConfig.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list VirtualMCPServers for MCPOIDCConfig watch") return nil diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go index 2aa6d3cb6c..97626acbfa 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go @@ -32,7 +32,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/virtualmcpserverstatus" @@ -51,42 +51,42 @@ func TestVirtualMCPServerValidateGroupRef(t *testing.T) { tests := []struct { name string - vmcp *mcpv1alpha1.VirtualMCPServer - mcpGroup *mcpv1alpha1.MCPGroup - mcpServers []mcpv1alpha1.MCPServer + vmcp *mcpv1beta1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + mcpServers []mcpv1beta1.MCPServer expectError bool - expectedPhase mcpv1alpha1.VirtualMCPServerPhase + expectedPhase mcpv1beta1.VirtualMCPServerPhase expectedReason string }{ { name: "valid group ref with ready group", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, }, - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, Servers: []string{"backend-1", "backend-2"}, }, }, - mcpServers: []mcpv1alpha1.MCPServer{ + mcpServers: []mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "backend-1", Namespace: "default", }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, URL: "http://backend-1.default.svc.cluster.local:8080", }, }, @@ -95,53 +95,53 @@ func TestVirtualMCPServerValidateGroupRef(t *testing.T) { Name: "backend-2", Namespace: "default", }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, URL: "http://backend-2.default.svc.cluster.local:8080", }, }, }, expectError: false, - expectedReason: mcpv1alpha1.ConditionReasonVirtualMCPServerGroupRefValid, + expectedReason: mcpv1beta1.ConditionReasonVirtualMCPServerGroupRefValid, }, { name: "group ref not found", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "missing-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "missing-group"}, }, }, expectError: true, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhaseFailed, - expectedReason: mcpv1alpha1.ConditionReasonVirtualMCPServerGroupRefNotFound, + expectedPhase: mcpv1beta1.VirtualMCPServerPhaseFailed, + expectedReason: mcpv1beta1.ConditionReasonVirtualMCPServerGroupRefNotFound, }, { name: "group ref not ready", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "pending-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "pending-group"}, }, }, - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "pending-group", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhasePending, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhasePending, }, }, expectError: true, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhasePending, - expectedReason: mcpv1alpha1.ConditionReasonVirtualMCPServerGroupRefNotReady, + expectedPhase: mcpv1beta1.VirtualMCPServerPhasePending, + expectedReason: mcpv1beta1.ConditionReasonVirtualMCPServerGroupRefNotReady, }, } @@ -151,7 +151,7 @@ func TestVirtualMCPServerValidateGroupRef(t *testing.T) { // Setup fake client with resources scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) @@ -167,7 +167,7 @@ func TestVirtualMCPServerValidateGroupRef(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.VirtualMCPServer{}). + WithStatusSubresource(&mcpv1beta1.VirtualMCPServer{}). Build() r := &VirtualMCPServerReconciler{ @@ -187,7 +187,7 @@ func TestVirtualMCPServerValidateGroupRef(t *testing.T) { // Check condition reason for _, cond := range tt.vmcp.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeVirtualMCPServerGroupRefValidated { + if cond.Type == mcpv1beta1.ConditionTypeVirtualMCPServerGroupRefValidated { assert.Equal(t, tt.expectedReason, cond.Reason) } } @@ -197,7 +197,7 @@ func TestVirtualMCPServerValidateGroupRef(t *testing.T) { // Check condition is set to true foundCondition := false for _, cond := range tt.vmcp.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeVirtualMCPServerGroupRefValidated { + if cond.Type == mcpv1beta1.ConditionTypeVirtualMCPServerGroupRefValidated { foundCondition = true assert.Equal(t, metav1.ConditionTrue, cond.Status) assert.Equal(t, tt.expectedReason, cond.Reason) @@ -213,18 +213,18 @@ func TestVirtualMCPServerValidateGroupRef(t *testing.T) { func TestVirtualMCPServerEnsureRBACResources(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) @@ -291,19 +291,19 @@ func TestVirtualMCPServerEnsureRBACResources(t *testing.T) { func TestVirtualMCPServerEnsureRBACResources_Update(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "update-vmcp", Namespace: "default", UID: "test-uid", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) @@ -375,18 +375,18 @@ func TestVirtualMCPServerEnsureRBACResources_Update(t *testing.T) { func TestVirtualMCPServerEnsureRBACResources_Idempotency(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "idempotent-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) @@ -437,22 +437,22 @@ func TestVirtualMCPServerEnsureRBACResources_Idempotency(t *testing.T) { func TestVirtualMCPServerEnsureRBACResources_InlineMode(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "inline-mode-vmcp", Namespace: "default", UID: "test-uid", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "inline", }, }, } scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) @@ -507,22 +507,22 @@ func TestVirtualMCPServerEnsureRBACResources_InlineMode(t *testing.T) { func TestVirtualMCPServerEnsureRBACResources_DiscoveredMode(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "discovered-mode-vmcp", Namespace: "default", UID: "test-uid", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, } scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) @@ -575,20 +575,20 @@ func TestVirtualMCPServerEnsureRBACResources_CustomServiceAccount(t *testing.T) t.Parallel() customSA := "custom-vmcp-sa" - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "custom-sa-vmcp", Namespace: "default", UID: "test-uid", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, ServiceAccount: &customSA, }, } scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) @@ -635,24 +635,24 @@ func TestVirtualMCPServerEnsureRBACResources_CustomServiceAccount(t *testing.T) func TestVirtualMCPServerEnsureDeployment(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } // Create MCPGroup that the VirtualMCPServer references - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, } @@ -671,7 +671,7 @@ func TestVirtualMCPServerEnsureDeployment(t *testing.T) { } scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) @@ -718,18 +718,18 @@ func TestVirtualMCPServerEnsureDeployment(t *testing.T) { func TestVirtualMCPServerEnsureService(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) fakeClient := fake.NewClientBuilder(). @@ -797,19 +797,19 @@ func TestVirtualMCPServerServiceType(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, ServiceType: tt.serviceType, }, } scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) r := &VirtualMCPServerReconciler{ @@ -828,13 +828,13 @@ func TestVirtualMCPServerServiceType(t *testing.T) { func TestVirtualMCPServerServiceNeedsUpdate(t *testing.T) { t.Parallel() - baseVmcp := &mcpv1alpha1.VirtualMCPServer{ + baseVmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, ServiceType: "ClusterIP", }, } @@ -857,7 +857,7 @@ func TestVirtualMCPServerServiceNeedsUpdate(t *testing.T) { tests := []struct { name string service *corev1.Service - vmcp *mcpv1alpha1.VirtualMCPServer + vmcp *mcpv1beta1.VirtualMCPServer needsUpdate bool }{ { @@ -869,7 +869,7 @@ func TestVirtualMCPServerServiceNeedsUpdate(t *testing.T) { { name: "service type changed to LoadBalancer", service: baseService.DeepCopy(), - vmcp: func() *mcpv1alpha1.VirtualMCPServer { + vmcp: func() *mcpv1beta1.VirtualMCPServer { v := baseVmcp.DeepCopy() v.Spec.ServiceType = "LoadBalancer" return v @@ -879,7 +879,7 @@ func TestVirtualMCPServerServiceNeedsUpdate(t *testing.T) { { name: "service type changed to NodePort", service: baseService.DeepCopy(), - vmcp: func() *mcpv1alpha1.VirtualMCPServer { + vmcp: func() *mcpv1beta1.VirtualMCPServer { v := baseVmcp.DeepCopy() v.Spec.ServiceType = "NodePort" return v @@ -913,7 +913,7 @@ func TestVirtualMCPServerServiceNeedsUpdate(t *testing.T) { s.Spec.SessionAffinity = corev1.ServiceAffinityClientIP return s }(), - vmcp: func() *mcpv1alpha1.VirtualMCPServer { + vmcp: func() *mcpv1beta1.VirtualMCPServer { v := baseVmcp.DeepCopy() v.Spec.SessionAffinity = string(corev1.ServiceAffinityNone) return v @@ -927,7 +927,7 @@ func TestVirtualMCPServerServiceNeedsUpdate(t *testing.T) { s.Spec.SessionAffinity = corev1.ServiceAffinityNone return s }(), - vmcp: func() *mcpv1alpha1.VirtualMCPServer { + vmcp: func() *mcpv1beta1.VirtualMCPServer { v := baseVmcp.DeepCopy() v.Spec.SessionAffinity = string(corev1.ServiceAffinityNone) return v @@ -953,13 +953,13 @@ func TestVirtualMCPServerUpdateStatus(t *testing.T) { tests := []struct { name string - vmcp *mcpv1alpha1.VirtualMCPServer + vmcp *mcpv1beta1.VirtualMCPServer pods []corev1.Pod - expectedPhase mcpv1alpha1.VirtualMCPServerPhase + expectedPhase mcpv1beta1.VirtualMCPServerPhase }{ { name: "ready pods", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", @@ -983,11 +983,11 @@ func TestVirtualMCPServerUpdateStatus(t *testing.T) { }, }, }, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhaseReady, + expectedPhase: mcpv1beta1.VirtualMCPServerPhaseReady, }, { name: "running but not ready pods", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", @@ -1012,11 +1012,11 @@ func TestVirtualMCPServerUpdateStatus(t *testing.T) { }, }, }, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhasePending, + expectedPhase: mcpv1beta1.VirtualMCPServerPhasePending, }, { name: "pending pods", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", @@ -1034,11 +1034,11 @@ func TestVirtualMCPServerUpdateStatus(t *testing.T) { }, }, }, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhasePending, + expectedPhase: mcpv1beta1.VirtualMCPServerPhasePending, }, { name: "failed pods", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", @@ -1056,7 +1056,7 @@ func TestVirtualMCPServerUpdateStatus(t *testing.T) { }, }, }, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhaseFailed, + expectedPhase: mcpv1beta1.VirtualMCPServerPhaseFailed, }, } @@ -1065,7 +1065,7 @@ func TestVirtualMCPServerUpdateStatus(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) objs := []client.Object{tt.vmcp} @@ -1076,7 +1076,7 @@ func TestVirtualMCPServerUpdateStatus(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.VirtualMCPServer{}). + WithStatusSubresource(&mcpv1beta1.VirtualMCPServer{}). Build() r := &VirtualMCPServerReconciler{ @@ -1137,12 +1137,12 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) tests := []struct { name string - vmcp *mcpv1alpha1.VirtualMCPServer + vmcp *mcpv1beta1.VirtualMCPServer secrets []client.Object expectAuthCondition bool expectedAuthStatus metav1.ConditionStatus @@ -1151,14 +1151,14 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { }{ { name: "valid auth with no secrets required (anonymous)", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -1166,32 +1166,32 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { secrets: []client.Object{}, expectAuthCondition: true, expectedAuthStatus: metav1.ConditionTrue, - expectedAuthReason: mcpv1alpha1.ConditionReasonAuthValid, + expectedAuthReason: mcpv1beta1.ConditionReasonAuthValid, expectError: false, }, { name: "OIDC with missing client secret via MCPOIDCConfig", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, }, }, }, secrets: []client.Object{ - &mcpv1alpha1.MCPOIDCConfig{ + &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://issuer.example.com", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "missing-secret", Key: "client-secret", }, @@ -1201,32 +1201,32 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { }, expectAuthCondition: true, expectedAuthStatus: metav1.ConditionFalse, - expectedAuthReason: mcpv1alpha1.ConditionReasonAuthInvalid, + expectedAuthReason: mcpv1beta1.ConditionReasonAuthInvalid, expectError: true, }, { name: "OIDC with valid client secret via MCPOIDCConfig", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, }, }, }, secrets: []client.Object{ - &mcpv1alpha1.MCPOIDCConfig{ + &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://issuer.example.com", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "oidc-secret", Key: "client-secret", }, @@ -1245,32 +1245,32 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { }, expectAuthCondition: true, expectedAuthStatus: metav1.ConditionTrue, - expectedAuthReason: mcpv1alpha1.ConditionReasonAuthValid, + expectedAuthReason: mcpv1beta1.ConditionReasonAuthValid, expectError: false, }, { name: "OIDC secret exists but missing required key via MCPOIDCConfig", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, }, }, }, secrets: []client.Object{ - &mcpv1alpha1.MCPOIDCConfig{ + &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://issuer.example.com", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "oidc-secret", Key: "client-secret", }, @@ -1289,7 +1289,7 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { }, expectAuthCondition: true, expectedAuthStatus: metav1.ConditionFalse, - expectedAuthReason: mcpv1alpha1.ConditionReasonAuthInvalid, + expectedAuthReason: mcpv1beta1.ConditionReasonAuthInvalid, expectError: true, }, } @@ -1303,7 +1303,7 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.VirtualMCPServer{}). + WithStatusSubresource(&mcpv1beta1.VirtualMCPServer{}). Build() r := &VirtualMCPServerReconciler{ @@ -1328,7 +1328,7 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { // Find AuthConfigured condition var authCondition *metav1.Condition for i := range tt.vmcp.Status.Conditions { - if tt.vmcp.Status.Conditions[i].Type == mcpv1alpha1.ConditionTypeAuthConfigured { + if tt.vmcp.Status.Conditions[i].Type == mcpv1beta1.ConditionTypeAuthConfigured { authCondition = &tt.vmcp.Status.Conditions[i] break } @@ -1347,7 +1347,7 @@ func TestVirtualMCPServerReconcile_NotFound(t *testing.T) { // Setup scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) @@ -1379,28 +1379,28 @@ func TestVirtualMCPServerApplyStatusUpdates(t *testing.T) { tests := []struct { name string - setupVMCP func() *mcpv1alpha1.VirtualMCPServer - setupCollector func(vmcp *mcpv1alpha1.VirtualMCPServer) virtualmcpserverstatus.StatusManager + setupVMCP func() *mcpv1beta1.VirtualMCPServer + setupCollector func(vmcp *mcpv1beta1.VirtualMCPServer) virtualmcpserverstatus.StatusManager expectUpdate bool expectError bool }{ { name: "successful status update", - setupVMCP: func() *mcpv1alpha1.VirtualMCPServer { - return &mcpv1alpha1.VirtualMCPServer{ + setupVMCP: func() *mcpv1beta1.VirtualMCPServer { + return &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", Generation: 1, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } }, - setupCollector: func(vmcp *mcpv1alpha1.VirtualMCPServer) virtualmcpserverstatus.StatusManager { + setupCollector: func(vmcp *mcpv1beta1.VirtualMCPServer) virtualmcpserverstatus.StatusManager { collector := virtualmcpserverstatus.NewStatusManager(vmcp) - collector.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseReady) + collector.SetPhase(mcpv1beta1.VirtualMCPServerPhaseReady) collector.SetMessage("All resources ready") return collector }, @@ -1409,19 +1409,19 @@ func TestVirtualMCPServerApplyStatusUpdates(t *testing.T) { }, { name: "no changes to apply", - setupVMCP: func() *mcpv1alpha1.VirtualMCPServer { - return &mcpv1alpha1.VirtualMCPServer{ + setupVMCP: func() *mcpv1beta1.VirtualMCPServer { + return &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", Generation: 1, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } }, - setupCollector: func(vmcp *mcpv1alpha1.VirtualMCPServer) virtualmcpserverstatus.StatusManager { + setupCollector: func(vmcp *mcpv1beta1.VirtualMCPServer) virtualmcpserverstatus.StatusManager { return virtualmcpserverstatus.NewStatusManager(vmcp) }, expectUpdate: false, @@ -1429,21 +1429,21 @@ func TestVirtualMCPServerApplyStatusUpdates(t *testing.T) { }, { name: "batch update with multiple changes", - setupVMCP: func() *mcpv1alpha1.VirtualMCPServer { - return &mcpv1alpha1.VirtualMCPServer{ + setupVMCP: func() *mcpv1beta1.VirtualMCPServer { + return &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", Generation: 1, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } }, - setupCollector: func(vmcp *mcpv1alpha1.VirtualMCPServer) virtualmcpserverstatus.StatusManager { + setupCollector: func(vmcp *mcpv1beta1.VirtualMCPServer) virtualmcpserverstatus.StatusManager { collector := virtualmcpserverstatus.NewStatusManager(vmcp) - collector.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseReady) + collector.SetPhase(mcpv1beta1.VirtualMCPServerPhaseReady) collector.SetMessage("All resources ready") collector.SetURL("http://test.example.com") collector.SetObservedGeneration(1) @@ -1462,7 +1462,7 @@ func TestVirtualMCPServerApplyStatusUpdates(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) @@ -1489,7 +1489,7 @@ func TestVirtualMCPServerApplyStatusUpdates(t *testing.T) { assert.NoError(t, err) // Verify the status was updated - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(context.Background(), types.NamespacedName{ Name: vmcp.Name, Namespace: vmcp.Namespace, @@ -1498,7 +1498,7 @@ func TestVirtualMCPServerApplyStatusUpdates(t *testing.T) { if tt.expectUpdate { // Verify updates were applied - assert.NotEqual(t, mcpv1alpha1.VirtualMCPServerPhase(""), updatedVMCP.Status.Phase) + assert.NotEqual(t, mcpv1beta1.VirtualMCPServerPhase(""), updatedVMCP.Status.Phase) } } }) @@ -1509,19 +1509,19 @@ func TestVirtualMCPServerApplyStatusUpdates_ResourceNotFound(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", Generation: 1, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -1536,7 +1536,7 @@ func TestVirtualMCPServerApplyStatusUpdates_ResourceNotFound(t *testing.T) { } collector := virtualmcpserverstatus.NewStatusManager(vmcp) - collector.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseReady) + collector.SetPhase(mcpv1beta1.VirtualMCPServerPhaseReady) err := reconciler.applyStatusUpdates(context.Background(), vmcp, collector) @@ -1549,38 +1549,38 @@ func TestVirtualMCPServerEnsureAllResources_Errors(t *testing.T) { tests := []struct { name string - setupVMCP func() *mcpv1alpha1.VirtualMCPServer - setupClient func(t *testing.T, vmcp *mcpv1alpha1.VirtualMCPServer) client.Client + setupVMCP func() *mcpv1beta1.VirtualMCPServer + setupClient func(t *testing.T, vmcp *mcpv1beta1.VirtualMCPServer) client.Client expectError bool }{ { name: "no auth configured - valid", - setupVMCP: func() *mcpv1alpha1.VirtualMCPServer { - return &mcpv1alpha1.VirtualMCPServer{ + setupVMCP: func() *mcpv1beta1.VirtualMCPServer { + return &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", Generation: 1, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } }, - setupClient: func(_ *testing.T, vmcp *mcpv1alpha1.VirtualMCPServer) client.Client { + setupClient: func(_ *testing.T, vmcp *mcpv1beta1.VirtualMCPServer) client.Client { scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, } return fake.NewClientBuilder(). @@ -1622,7 +1622,7 @@ func TestVirtualMCPServerContainerNeedsUpdate(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) @@ -1630,20 +1630,20 @@ func TestVirtualMCPServerContainerNeedsUpdate(t *testing.T) { Scheme: scheme, } - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } tests := []struct { name string deployment *appsv1.Deployment - vmcp *mcpv1alpha1.VirtualMCPServer + vmcp *mcpv1beta1.VirtualMCPServer expectedUpdate bool }{ { @@ -1806,13 +1806,13 @@ func TestVirtualMCPServerContainerNeedsUpdate(t *testing.T) { }, }, }, - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, Config: vmcpconfig.Config{ Group: testGroupName, Operational: &vmcpconfig.OperationalConfig{ @@ -1890,7 +1890,7 @@ func TestVirtualMCPServerDeploymentMetadataNeedsUpdate(t *testing.T) { reconciler := &VirtualMCPServerReconciler{} - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", @@ -1900,7 +1900,7 @@ func TestVirtualMCPServerDeploymentMetadataNeedsUpdate(t *testing.T) { tests := []struct { name string deployment *appsv1.Deployment - vmcp *mcpv1alpha1.VirtualMCPServer + vmcp *mcpv1beta1.VirtualMCPServer expectedUpdate bool }{ { @@ -1972,13 +1972,13 @@ func TestVirtualMCPServerPodTemplateMetadataNeedsUpdate(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) reconciler := &VirtualMCPServerReconciler{ Scheme: scheme, } - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", @@ -1993,7 +1993,7 @@ func TestVirtualMCPServerPodTemplateMetadataNeedsUpdate(t *testing.T) { tests := []struct { name string deployment *appsv1.Deployment - vmcp *mcpv1alpha1.VirtualMCPServer + vmcp *mcpv1beta1.VirtualMCPServer checksum string expectedUpdate bool }{ @@ -2106,7 +2106,7 @@ func TestVirtualMCPServerDeploymentNeedsUpdate(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) @@ -2114,13 +2114,13 @@ func TestVirtualMCPServerDeploymentNeedsUpdate(t *testing.T) { Scheme: scheme, } - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -2280,29 +2280,29 @@ func TestVirtualMCPServerReconcile_HappyPath(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", Generation: 1, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, } @@ -2398,7 +2398,7 @@ func TestVirtualMCPServerReconcile_HappyPath(t *testing.T) { assert.Equal(t, ctrl.Result{}, result) // Verify status was updated - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} err = k8sClient.Get(context.Background(), types.NamespacedName{ Name: vmcp.Name, Namespace: vmcp.Namespace, @@ -2413,19 +2413,19 @@ func TestVirtualMCPServerReconcile_ValidateGroupRefError(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", Generation: 1, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "nonexistent-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "nonexistent-group"}, }, } @@ -2454,14 +2454,14 @@ func TestVirtualMCPServerReconcile_ValidateGroupRefError(t *testing.T) { assert.Equal(t, ctrl.Result{}, result) // Verify status was updated with error condition - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} err = k8sClient.Get(context.Background(), types.NamespacedName{ Name: vmcp.Name, Namespace: vmcp.Namespace, }, updatedVMCP) require.NoError(t, err) - assert.Equal(t, mcpv1alpha1.VirtualMCPServerPhaseFailed, updatedVMCP.Status.Phase) + assert.Equal(t, mcpv1beta1.VirtualMCPServerPhaseFailed, updatedVMCP.Status.Phase) assert.NotEmpty(t, updatedVMCP.Status.Message) } @@ -2469,29 +2469,29 @@ func TestVirtualMCPServerReconcile_GroupNotReady(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", Generation: 1, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testGroupName, Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhasePending, // Not ready + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhasePending, // Not ready }, } @@ -2520,21 +2520,21 @@ func TestVirtualMCPServerReconcile_GroupNotReady(t *testing.T) { assert.Equal(t, ctrl.Result{}, result) // Verify status was updated - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} err = k8sClient.Get(context.Background(), types.NamespacedName{ Name: vmcp.Name, Namespace: vmcp.Namespace, }, updatedVMCP) require.NoError(t, err) - assert.Equal(t, mcpv1alpha1.VirtualMCPServerPhasePending, updatedVMCP.Status.Phase) + assert.Equal(t, mcpv1beta1.VirtualMCPServerPhasePending, updatedVMCP.Status.Phase) } func TestVirtualMCPServerReconcile_GetError(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) // Create empty client - resource won't be found but we'll test non-NotFound errors // by using a client that returns a generic error @@ -2565,17 +2565,17 @@ func TestVirtualMCPServerEnsureDeployment_ConfigMapNotFound(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -2601,17 +2601,17 @@ func TestVirtualMCPServerEnsureDeployment_CreateDeployment(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -2658,17 +2658,17 @@ func TestVirtualMCPServerEnsureDeployment_UpdateDeployment(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -2741,17 +2741,17 @@ func TestVirtualMCPServerEnsureDeployment_NoUpdateNeeded(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -2828,16 +2828,16 @@ func TestVirtualMCPServerEnsureService_CreateService(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -2870,16 +2870,16 @@ func TestVirtualMCPServerEnsureService_UpdateService(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, ServiceType: "LoadBalancer", }, } @@ -2932,16 +2932,16 @@ func TestVirtualMCPServerEnsureService_NoUpdateNeeded(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -2989,46 +2989,46 @@ func TestVirtualMCPServerValidateEmbeddingServerRef(t *testing.T) { tests := []struct { name string - vmcp *mcpv1alpha1.VirtualMCPServer - embeddingServer *mcpv1alpha1.EmbeddingServer + vmcp *mcpv1beta1.VirtualMCPServer + embeddingServer *mcpv1beta1.EmbeddingServer expectError bool - expectedPhase mcpv1alpha1.VirtualMCPServerPhase + expectedPhase mcpv1beta1.VirtualMCPServerPhase expectedReason string }{ { name: "no ref configured (skip validation)", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, }, expectError: false, }, { name: "referenced EmbeddingServer exists and is running", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{ Name: "shared-embedding", }, }, }, - embeddingServer: &mcpv1alpha1.EmbeddingServer{ + embeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "shared-embedding", Namespace: "default", }, - Status: mcpv1alpha1.EmbeddingServerStatus{ - Phase: mcpv1alpha1.EmbeddingServerPhaseReady, + Status: mcpv1beta1.EmbeddingServerStatus{ + Phase: mcpv1beta1.EmbeddingServerPhaseReady, ReadyReplicas: 1, }, }, @@ -3036,43 +3036,43 @@ func TestVirtualMCPServerValidateEmbeddingServerRef(t *testing.T) { }, { name: "referenced EmbeddingServer not found", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{ Name: "missing-embedding", }, }, }, expectError: true, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhaseFailed, - expectedReason: mcpv1alpha1.ConditionReasonEmbeddingServerNotFound, + expectedPhase: mcpv1beta1.VirtualMCPServerPhaseFailed, + expectedReason: mcpv1beta1.ConditionReasonEmbeddingServerNotFound, }, { name: "referenced EmbeddingServer exists but not ready (pending) - existence validated", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{ Name: "pending-embedding", }, }, }, - embeddingServer: &mcpv1alpha1.EmbeddingServer{ + embeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "pending-embedding", Namespace: "default", }, - Status: mcpv1alpha1.EmbeddingServerStatus{ - Phase: mcpv1alpha1.EmbeddingServerPhasePending, + Status: mcpv1beta1.EmbeddingServerStatus{ + Phase: mcpv1beta1.EmbeddingServerPhasePending, ReadyReplicas: 0, }, }, @@ -3080,25 +3080,25 @@ func TestVirtualMCPServerValidateEmbeddingServerRef(t *testing.T) { }, { name: "referenced EmbeddingServer running but zero ready replicas - existence validated", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{ Name: "no-replicas-embedding", }, }, }, - embeddingServer: &mcpv1alpha1.EmbeddingServer{ + embeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "no-replicas-embedding", Namespace: "default", }, - Status: mcpv1alpha1.EmbeddingServerStatus{ - Phase: mcpv1alpha1.EmbeddingServerPhaseReady, + Status: mcpv1beta1.EmbeddingServerStatus{ + Phase: mcpv1beta1.EmbeddingServerPhaseReady, ReadyReplicas: 0, }, }, @@ -3112,7 +3112,7 @@ func TestVirtualMCPServerValidateEmbeddingServerRef(t *testing.T) { // Setup fake client with resources scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) @@ -3126,8 +3126,8 @@ func TestVirtualMCPServerValidateEmbeddingServerRef(t *testing.T) { WithScheme(scheme). WithObjects(objs...). WithStatusSubresource( - &mcpv1alpha1.VirtualMCPServer{}, - &mcpv1alpha1.EmbeddingServer{}, + &mcpv1beta1.VirtualMCPServer{}, + &mcpv1beta1.EmbeddingServer{}, ). Build() @@ -3148,7 +3148,7 @@ func TestVirtualMCPServerValidateEmbeddingServerRef(t *testing.T) { // Check condition reason for _, cond := range tt.vmcp.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeEmbeddingServerReady { + if cond.Type == mcpv1beta1.ConditionTypeEmbeddingServerReady { assert.Equal(t, tt.expectedReason, cond.Reason) assert.Equal(t, metav1.ConditionFalse, cond.Status) } @@ -3166,20 +3166,20 @@ func TestVirtualMCPServerEnsureDeployment_ReplicaSync_SpecDriven(t *testing.T) { t.Parallel() specReplicas := int32(3) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-replica-sync", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, Replicas: &specReplicas, }, } - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: testGroupName, Namespace: "default"}, - Status: mcpv1alpha1.MCPGroupStatus{Phase: mcpv1alpha1.MCPGroupPhaseReady}, + Status: mcpv1beta1.MCPGroupStatus{Phase: mcpv1beta1.MCPGroupPhaseReady}, } configMap := &corev1.ConfigMap{ @@ -3212,7 +3212,7 @@ func TestVirtualMCPServerEnsureDeployment_ReplicaSync_SpecDriven(t *testing.T) { } scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) @@ -3245,20 +3245,20 @@ func TestVirtualMCPServerEnsureDeployment_ReplicaSync_SpecDriven(t *testing.T) { func TestVirtualMCPServerEnsureDeployment_ReplicaSync_NilPassthrough(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-nil-passthrough", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, Replicas: nil, // HPA manages replicas }, } - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: testGroupName, Namespace: "default"}, - Status: mcpv1alpha1.MCPGroupStatus{Phase: mcpv1alpha1.MCPGroupPhaseReady}, + Status: mcpv1beta1.MCPGroupStatus{Phase: mcpv1beta1.MCPGroupPhaseReady}, } configMap := &corev1.ConfigMap{ @@ -3291,7 +3291,7 @@ func TestVirtualMCPServerEnsureDeployment_ReplicaSync_NilPassthrough(t *testing. } scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) @@ -3322,7 +3322,7 @@ func TestVirtualMCPServerEnsureDeployment_ReplicaSync_NilPassthrough(t *testing. // mustBuildEnvVarsForVmcp is a test helper that calls buildEnvVarsForVmcp and panics on error. // All test VirtualMCPServers use anonymous auth (no OIDCConfigRef), so the error path is unreachable. -func mustBuildEnvVarsForVmcp(r *VirtualMCPServerReconciler, vmcp *mcpv1alpha1.VirtualMCPServer) []corev1.EnvVar { +func mustBuildEnvVarsForVmcp(r *VirtualMCPServerReconciler, vmcp *mcpv1beta1.VirtualMCPServer) []corev1.EnvVar { env, err := r.buildEnvVarsForVmcp(context.Background(), vmcp, nil, []workloads.TypedWorkload{}) if err != nil { panic("mustBuildEnvVarsForVmcp: " + err.Error()) @@ -3334,39 +3334,39 @@ func mustBuildEnvVarsForVmcp(r *VirtualMCPServerReconciler, vmcp *mcpv1alpha1.Vi func TestGetExternalAuthConfigNameFromWorkload(t *testing.T) { t.Parallel() - mcpServerMap := map[string]*mcpv1alpha1.MCPServer{ + mcpServerMap := map[string]*mcpv1beta1.MCPServer{ "server-with-auth": { - Spec: mcpv1alpha1.MCPServerSpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "server-auth-config", }, }, }, "server-no-auth": { - Spec: mcpv1alpha1.MCPServerSpec{}, + Spec: mcpv1beta1.MCPServerSpec{}, }, } - mcpRemoteProxyMap := map[string]*mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxyMap := map[string]*mcpv1beta1.MCPRemoteProxy{ "proxy-with-auth": { - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "proxy-auth-config", }, }, }, } - mcpServerEntryMap := map[string]*mcpv1alpha1.MCPServerEntry{ + mcpServerEntryMap := map[string]*mcpv1beta1.MCPServerEntry{ "entry-with-auth": { - Spec: mcpv1alpha1.MCPServerEntrySpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerEntrySpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "entry-auth-config", }, }, }, "entry-no-auth": { - Spec: mcpv1alpha1.MCPServerEntrySpec{}, + Spec: mcpv1beta1.MCPServerEntrySpec{}, }, } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go index 7dc6377c7a..c764c494cd 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go @@ -25,7 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" "github.com/stacklok/toolhive/pkg/container/kubernetes" @@ -126,9 +126,9 @@ var vmcpDiscoveredRBACRules = []rbacv1.PolicyRule{ // used for CA bundle volumes and OpenTelemetry env vars without redundant API calls. func (r *VirtualMCPServerReconciler) deploymentForVirtualMCPServer( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, vmcpConfigChecksum string, - telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) *appsv1.Deployment { ls := labelsForVirtualMCPServer(vmcp.Name) @@ -250,7 +250,7 @@ func (r *VirtualMCPServerReconciler) deploymentForVirtualMCPServer( // buildContainerArgsForVmcp builds the container arguments for vmcp func (*VirtualMCPServerReconciler) buildContainerArgsForVmcp( - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) []string { args := []string{ "serve", @@ -272,7 +272,7 @@ func (*VirtualMCPServerReconciler) buildContainerArgsForVmcp( // buildVolumesForVmcp builds volumes and volume mounts for vmcp func (r *VirtualMCPServerReconciler) buildVolumesForVmcp( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) ([]corev1.VolumeMount, []corev1.Volume, error) { volumeMounts := []corev1.VolumeMount{} volumes := []corev1.Volume{} @@ -320,8 +320,8 @@ func (r *VirtualMCPServerReconciler) buildVolumesForVmcp( // telemetryCfg is the already-fetched MCPTelemetryConfig (nil when not referenced). func (r *VirtualMCPServerReconciler) buildEnvVarsForVmcp( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, - telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, + vmcp *mcpv1beta1.VirtualMCPServer, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) ([]corev1.EnvVar, error) { env := []corev1.EnvVar{} @@ -365,7 +365,7 @@ func (r *VirtualMCPServerReconciler) buildEnvVarsForVmcp( // buildOIDCEnvVars builds environment variables for OIDC client secret mounting. func (r *VirtualMCPServerReconciler) buildOIDCEnvVars( - ctx context.Context, vmcp *mcpv1alpha1.VirtualMCPServer, + ctx context.Context, vmcp *mcpv1beta1.VirtualMCPServer, ) ([]corev1.EnvVar, error) { var env []corev1.EnvVar @@ -382,7 +382,7 @@ func (r *VirtualMCPServerReconciler) buildOIDCEnvVars( vmcp.Spec.IncomingAuth.OIDCConfigRef.Name, err) } if oidcCfg != nil && - oidcCfg.Spec.Type == mcpv1alpha1.MCPOIDCConfigTypeInline && + oidcCfg.Spec.Type == mcpv1beta1.MCPOIDCConfigTypeInline && oidcCfg.Spec.Inline != nil && oidcCfg.Spec.Inline.ClientSecretRef != nil { env = append(env, corev1.EnvVar{ @@ -405,7 +405,7 @@ func (r *VirtualMCPServerReconciler) buildOIDCEnvVars( // buildHMACSecretEnvVar builds environment variable for HMAC secret mounting. // This secret is used for session token binding in Session Management V2. // The operator automatically generates and manages this secret if it doesn't exist. -func (*VirtualMCPServerReconciler) buildHMACSecretEnvVar(vmcp *mcpv1alpha1.VirtualMCPServer) corev1.EnvVar { +func (*VirtualMCPServerReconciler) buildHMACSecretEnvVar(vmcp *mcpv1beta1.VirtualMCPServer) corev1.EnvVar { secretName := fmt.Sprintf("%s-hmac-secret", vmcp.Name) return corev1.EnvVar{ @@ -423,9 +423,9 @@ func (*VirtualMCPServerReconciler) buildHMACSecretEnvVar(vmcp *mcpv1alpha1.Virtu // buildRedisPasswordEnvVar returns the THV_SESSION_REDIS_PASSWORD env var when // sessionStorage.provider == "redis" and passwordRef is set; returns nil otherwise. -func (*VirtualMCPServerReconciler) buildRedisPasswordEnvVar(vmcp *mcpv1alpha1.VirtualMCPServer) []corev1.EnvVar { +func (*VirtualMCPServerReconciler) buildRedisPasswordEnvVar(vmcp *mcpv1beta1.VirtualMCPServer) []corev1.EnvVar { if vmcp.Spec.SessionStorage == nil || - vmcp.Spec.SessionStorage.Provider != mcpv1alpha1.SessionStorageProviderRedis || + vmcp.Spec.SessionStorage.Provider != mcpv1beta1.SessionStorageProviderRedis || vmcp.Spec.SessionStorage.PasswordRef == nil { return nil } @@ -445,7 +445,7 @@ func (*VirtualMCPServerReconciler) buildRedisPasswordEnvVar(vmcp *mcpv1alpha1.Vi // buildOutgoingAuthEnvVars builds environment variables for outgoing auth secrets. func (r *VirtualMCPServerReconciler) buildOutgoingAuthEnvVars( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, typedWorkloads []workloads.TypedWorkload, ) []corev1.EnvVar { var env []corev1.EnvVar @@ -486,7 +486,7 @@ func (r *VirtualMCPServerReconciler) buildOutgoingAuthEnvVars( // and returns environment variables for their client secrets. This is used for discovered mode. func (r *VirtualMCPServerReconciler) discoverExternalAuthConfigSecrets( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, typedWorkloads []workloads.TypedWorkload, ) []corev1.EnvVar { ctxLogger := log.FromContext(ctx) @@ -555,7 +555,7 @@ func (r *VirtualMCPServerReconciler) discoverExternalAuthConfigSecrets( // and returns environment variables for their client secrets. func (r *VirtualMCPServerReconciler) discoverInlineExternalAuthConfigSecrets( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) []corev1.EnvVar { var envVars []corev1.EnvVar seenConfigs := make(map[string]bool) // Track which ExternalAuthConfigs we've already processed @@ -614,10 +614,10 @@ func (r *VirtualMCPServerReconciler) getExternalAuthConfigSecretEnvVar( } var envVarName string - var secretRef *mcpv1alpha1.SecretKeyRef + var secretRef *mcpv1beta1.SecretKeyRef switch externalAuthConfig.Spec.Type { - case mcpv1alpha1.ExternalAuthTypeTokenExchange: + case mcpv1beta1.ExternalAuthTypeTokenExchange: if externalAuthConfig.Spec.TokenExchange == nil { return nil, nil } @@ -627,7 +627,7 @@ func (r *VirtualMCPServerReconciler) getExternalAuthConfigSecretEnvVar( envVarName = ctrlutil.GenerateUniqueTokenExchangeEnvVarName(externalAuthConfigName) secretRef = externalAuthConfig.Spec.TokenExchange.ClientSecretRef - case mcpv1alpha1.ExternalAuthTypeHeaderInjection: + case mcpv1beta1.ExternalAuthTypeHeaderInjection: if externalAuthConfig.Spec.HeaderInjection == nil { return nil, nil } @@ -637,26 +637,26 @@ func (r *VirtualMCPServerReconciler) getExternalAuthConfigSecretEnvVar( envVarName = ctrlutil.GenerateUniqueHeaderInjectionEnvVarName(externalAuthConfigName) secretRef = externalAuthConfig.Spec.HeaderInjection.ValueSecretRef - case mcpv1alpha1.ExternalAuthTypeBearerToken: + case mcpv1beta1.ExternalAuthTypeBearerToken: // Bearer token secrets are handled differently (via RemoteAuthConfig in RunConfig) // No environment variable mounting needed for bearer tokens return nil, nil - case mcpv1alpha1.ExternalAuthTypeUnauthenticated: + case mcpv1beta1.ExternalAuthTypeUnauthenticated: // No secrets to mount for unauthenticated return nil, nil - case mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer: + case mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer: // Embedded auth server secrets are handled separately (via volume mounts, not env vars) // Controller integration will be in a future task return nil, nil - case mcpv1alpha1.ExternalAuthTypeAWSSts: + case mcpv1beta1.ExternalAuthTypeAWSSts: // AWS STS authentication doesn't require secret mounting via env vars // It uses the incoming OIDC token for AssumeRoleWithWebIdentity return nil, nil - case mcpv1alpha1.ExternalAuthTypeUpstreamInject: + case mcpv1beta1.ExternalAuthTypeUpstreamInject: // Upstream inject uses the embedded auth server's upstream tokens at runtime // No secrets to mount via env vars return nil, nil @@ -681,7 +681,7 @@ func (r *VirtualMCPServerReconciler) getExternalAuthConfigSecretEnvVar( // buildDeploymentMetadataForVmcp builds deployment-level labels and annotations func (*VirtualMCPServerReconciler) buildDeploymentMetadataForVmcp( baseLabels map[string]string, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) (map[string]string, map[string]string) { deploymentLabels := baseLabels deploymentAnnotations := make(map[string]string) @@ -704,7 +704,7 @@ func (*VirtualMCPServerReconciler) buildDeploymentMetadataForVmcp( // buildPodTemplateMetadata builds pod template labels and annotations for vmcp func (*VirtualMCPServerReconciler) buildPodTemplateMetadata( baseLabels map[string]string, - _ *mcpv1alpha1.VirtualMCPServer, + _ *mcpv1beta1.VirtualMCPServer, vmcpConfigChecksum string, ) (map[string]string, map[string]string) { templateLabels := baseLabels @@ -719,7 +719,7 @@ func (*VirtualMCPServerReconciler) buildPodTemplateMetadata( // buildSecurityContextsForVmcp builds pod and container security contexts func (r *VirtualMCPServerReconciler) buildSecurityContextsForVmcp( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) (*corev1.PodSecurityContext, *corev1.SecurityContext) { if r.PlatformDetector == nil { r.PlatformDetector = ctrlutil.NewSharedPlatformDetector() @@ -737,7 +737,7 @@ func (r *VirtualMCPServerReconciler) buildSecurityContextsForVmcp( // buildContainerPortsForVmcp builds container port configuration func (*VirtualMCPServerReconciler) buildContainerPortsForVmcp( - _ *mcpv1alpha1.VirtualMCPServer, + _ *mcpv1beta1.VirtualMCPServer, ) []corev1.ContainerPort { return []corev1.ContainerPort{{ ContainerPort: vmcpDefaultPort, @@ -749,7 +749,7 @@ func (*VirtualMCPServerReconciler) buildContainerPortsForVmcp( // serviceForVirtualMCPServer returns a VirtualMCPServer Service object func (r *VirtualMCPServerReconciler) serviceForVirtualMCPServer( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) *corev1.Service { ls := labelsForVirtualMCPServer(vmcp.Name) svcName := vmcpServiceName(vmcp.Name) @@ -801,7 +801,7 @@ func (r *VirtualMCPServerReconciler) serviceForVirtualMCPServer( // buildServiceMetadataForVmcp builds service labels and annotations func (*VirtualMCPServerReconciler) buildServiceMetadataForVmcp( baseLabels map[string]string, - _ *mcpv1alpha1.VirtualMCPServer, + _ *mcpv1beta1.VirtualMCPServer, ) (map[string]string, map[string]string) { serviceLabels := baseLabels serviceAnnotations := make(map[string]string) @@ -835,7 +835,7 @@ func getVmcpImage() string { //nolint:gocyclo // Secret validation requires checking multiple optional config paths func (r *VirtualMCPServerReconciler) validateSecretReferences( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) error { // Validate MCPOIDCConfig inline client secret if configured if vmcp.Spec.IncomingAuth != nil && vmcp.Spec.IncomingAuth.OIDCConfigRef != nil { @@ -846,7 +846,7 @@ func (r *VirtualMCPServerReconciler) validateSecretReferences( vmcp.Spec.IncomingAuth.OIDCConfigRef.Name, err) } if oidcCfg != nil && - oidcCfg.Spec.Type == mcpv1alpha1.MCPOIDCConfigTypeInline && + oidcCfg.Spec.Type == mcpv1beta1.MCPOIDCConfigTypeInline && oidcCfg.Spec.Inline != nil && oidcCfg.Spec.Inline.ClientSecretRef != nil { if err := r.validateSecretKeyRef(ctx, vmcp.Namespace, @@ -880,7 +880,7 @@ func (r *VirtualMCPServerReconciler) validateSecretReferences( func (*VirtualMCPServerReconciler) validateBackendAuthSecrets( _ context.Context, _ string, - _ *mcpv1alpha1.BackendAuthConfig, + _ *mcpv1beta1.BackendAuthConfig, _ string, ) error { // No backend auth types currently require secret validation @@ -892,7 +892,7 @@ func (*VirtualMCPServerReconciler) validateBackendAuthSecrets( func (r *VirtualMCPServerReconciler) validateSecretKeyRef( ctx context.Context, namespace string, - secretRef *mcpv1alpha1.SecretKeyRef, + secretRef *mcpv1beta1.SecretKeyRef, secretDesc string, ) error { if secretRef == nil { @@ -928,7 +928,7 @@ func (r *VirtualMCPServerReconciler) validateSecretKeyRef( // - The "vmcp" container is preserved from the controller-generated spec func (*VirtualMCPServerReconciler) applyPodTemplateSpecToDeployment( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, deployment *appsv1.Deployment, ) error { ctxLogger := log.FromContext(ctx) @@ -987,7 +987,7 @@ const ( // caBundleMountPath returns the mount path for a CA bundle ConfigMap for a given entry name. // The key defaults to "ca.crt" if not specified in the CABundleSource. -func caBundleMountPath(entryName string, caBundleRef *mcpv1alpha1.CABundleSource) string { +func caBundleMountPath(entryName string, caBundleRef *mcpv1beta1.CABundleSource) string { if caBundleRef == nil { return path.Join(caBundleBasePath, entryName, "ca.crt") } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go index fb4729f679..908beb06bb 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go @@ -29,7 +29,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" @@ -40,18 +40,18 @@ import ( func TestDeploymentForVirtualMCPServer(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) r := &VirtualMCPServerReconciler{ Scheme: scheme, @@ -96,17 +96,17 @@ func TestDeploymentForVirtualMCPServer(t *testing.T) { func TestDeploymentForVirtualMCPServer_WithRedisPassword(t *testing.T) { t.Parallel() - passwordRef := &mcpv1alpha1.SecretKeyRef{Name: "redis-secret", Key: "password"} + passwordRef := &mcpv1beta1.SecretKeyRef{Name: "redis-secret", Key: "password"} - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp-redis", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379", PasswordRef: passwordRef, }, @@ -114,7 +114,7 @@ func TestDeploymentForVirtualMCPServer_WithRedisPassword(t *testing.T) { } scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) r := &VirtualMCPServerReconciler{ Scheme: scheme, @@ -146,31 +146,31 @@ func TestBuildContainerArgsForVmcp(t *testing.T) { tests := []struct { name string - vmcp *mcpv1alpha1.VirtualMCPServer + vmcp *mcpv1beta1.VirtualMCPServer wantArgs []string }{ { name: "without log level", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, wantArgs: []string{"serve", "--config=/etc/vmcp-config/config.yaml", "--host=0.0.0.0", "--port=4483"}, }, { name: "with log level debug", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ Operational: &vmcpconfig.OperationalConfig{ LogLevel: "debug", @@ -198,13 +198,13 @@ func TestBuildContainerArgsForVmcp(t *testing.T) { func TestBuildVolumesForVmcp(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } @@ -228,13 +228,13 @@ func TestBuildVolumesForVmcp(t *testing.T) { func TestBuildEnvVarsForVmcp(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "test-namespace", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } @@ -267,11 +267,11 @@ func TestBuildRedisPasswordEnvVar(t *testing.T) { r := &VirtualMCPServerReconciler{} - passwordRef := &mcpv1alpha1.SecretKeyRef{Name: "redis-secret", Key: "password"} + passwordRef := &mcpv1beta1.SecretKeyRef{Name: "redis-secret", Key: "password"} tests := []struct { name string - storage *mcpv1alpha1.SessionStorageConfig + storage *mcpv1beta1.SessionStorageConfig expectEnVar bool }{ { @@ -281,17 +281,17 @@ func TestBuildRedisPasswordEnvVar(t *testing.T) { }, { name: "memory provider produces no env var", - storage: &mcpv1alpha1.SessionStorageConfig{Provider: "memory"}, + storage: &mcpv1beta1.SessionStorageConfig{Provider: "memory"}, expectEnVar: false, }, { name: "redis without passwordRef produces no env var", - storage: &mcpv1alpha1.SessionStorageConfig{Provider: mcpv1alpha1.SessionStorageProviderRedis, Address: "redis:6379"}, + storage: &mcpv1beta1.SessionStorageConfig{Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379"}, expectEnVar: false, }, { name: "redis with passwordRef produces THV_SESSION_REDIS_PASSWORD", - storage: &mcpv1alpha1.SessionStorageConfig{Provider: mcpv1alpha1.SessionStorageProviderRedis, Address: "redis:6379", PasswordRef: passwordRef}, + storage: &mcpv1beta1.SessionStorageConfig{Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379", PasswordRef: passwordRef}, expectEnVar: true, }, } @@ -299,9 +299,9 @@ func TestBuildRedisPasswordEnvVar(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{SessionStorage: tc.storage}, + Spec: mcpv1beta1.VirtualMCPServerSpec{SessionStorage: tc.storage}, } env := r.buildRedisPasswordEnvVar(vmcp) if tc.expectEnVar { @@ -324,7 +324,7 @@ func TestBuildDeploymentMetadataForVmcp(t *testing.T) { t.Parallel() baseLabels := labelsForVirtualMCPServer("test-vmcp") - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", @@ -343,7 +343,7 @@ func TestBuildPodTemplateMetadata(t *testing.T) { t.Parallel() baseLabels := labelsForVirtualMCPServer("test-vmcp") - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", @@ -362,7 +362,7 @@ func TestBuildPodTemplateMetadata(t *testing.T) { func TestBuildSecurityContextsForVmcp(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", @@ -383,7 +383,7 @@ func TestBuildSecurityContextsForVmcp(t *testing.T) { func TestBuildContainerPortsForVmcp(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", @@ -403,18 +403,18 @@ func TestBuildContainerPortsForVmcp(t *testing.T) { func TestServiceForVirtualMCPServer(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) r := &VirtualMCPServerReconciler{ @@ -443,19 +443,19 @@ func TestServiceForVirtualMCPServer(t *testing.T) { func TestServiceForVirtualMCPServerSessionAffinityNone(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, SessionAffinity: string(corev1.ServiceAffinityNone), }, } scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) r := &VirtualMCPServerReconciler{ @@ -473,7 +473,7 @@ func TestBuildServiceMetadataForVmcp(t *testing.T) { t.Parallel() baseLabels := labelsForVirtualMCPServer("test-vmcp") - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", @@ -538,7 +538,7 @@ func TestDeploymentNeedsUpdate(t *testing.T) { // Test nil inputs assert.True(t, r.deploymentNeedsUpdate(context.Background(), nil, nil, "", nil, []workloads.TypedWorkload{})) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", @@ -558,7 +558,7 @@ func TestServiceNeedsUpdate(t *testing.T) { // Test nil inputs assert.True(t, r.serviceNeedsUpdate(nil, nil)) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", @@ -584,13 +584,13 @@ func TestCABundleMountPath(t *testing.T) { tests := []struct { name string entryName string - caBundleRef *mcpv1alpha1.CABundleSource + caBundleRef *mcpv1beta1.CABundleSource expectedPath string }{ { name: "default key (no key specified)", entryName: "my-entry", - caBundleRef: &mcpv1alpha1.CABundleSource{ + caBundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "ca-configmap"}, }, @@ -600,7 +600,7 @@ func TestCABundleMountPath(t *testing.T) { { name: "custom key specified", entryName: "my-entry", - caBundleRef: &mcpv1alpha1.CABundleSource{ + caBundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "ca-configmap"}, Key: "custom-ca.pem", @@ -611,7 +611,7 @@ func TestCABundleMountPath(t *testing.T) { { name: "nil configMapRef uses default key", entryName: "another-entry", - caBundleRef: &mcpv1alpha1.CABundleSource{ + caBundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: nil, }, expectedPath: "/etc/toolhive/ca-bundles/another-entry/ca.crt", @@ -699,7 +699,7 @@ func TestBuildCABundleVolumesForEntries(t *testing.T) { tests := []struct { name string - entries []mcpv1alpha1.MCPServerEntry + entries []mcpv1beta1.MCPServerEntry workloads []workloads.TypedWorkload expectedVolumes int expectedMounts int @@ -716,13 +716,13 @@ func TestBuildCABundleVolumesForEntries(t *testing.T) { }, { name: "entry without caBundleRef yields no volumes", - entries: []mcpv1alpha1.MCPServerEntry{ + entries: []mcpv1beta1.MCPServerEntry{ { ObjectMeta: metav1.ObjectMeta{Name: "entry-no-ca", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -734,14 +734,14 @@ func TestBuildCABundleVolumesForEntries(t *testing.T) { }, { name: "entry with caBundleRef produces volume and mount", - entries: []mcpv1alpha1.MCPServerEntry{ + entries: []mcpv1beta1.MCPServerEntry{ { ObjectMeta: metav1.ObjectMeta{Name: "entry-with-ca", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - CABundleRef: &mcpv1alpha1.CABundleSource{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "my-ca-configmap"}, Key: "ca.crt", @@ -770,14 +770,14 @@ func TestBuildCABundleVolumesForEntries(t *testing.T) { }, { name: "entry with custom key in caBundleRef", - entries: []mcpv1alpha1.MCPServerEntry{ + entries: []mcpv1beta1.MCPServerEntry{ { ObjectMeta: metav1.ObjectMeta{Name: "custom-key-entry", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - CABundleRef: &mcpv1alpha1.CABundleSource{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "custom-ca"}, Key: "custom-cert.pem", @@ -800,14 +800,14 @@ func TestBuildCABundleVolumesForEntries(t *testing.T) { }, { name: "mixed workload types only produces volumes for entries with CA bundles", - entries: []mcpv1alpha1.MCPServerEntry{ + entries: []mcpv1beta1.MCPServerEntry{ { ObjectMeta: metav1.ObjectMeta{Name: "entry-with-ca", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - CABundleRef: &mcpv1alpha1.CABundleSource{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "ca-cm"}, }, @@ -816,10 +816,10 @@ func TestBuildCABundleVolumesForEntries(t *testing.T) { }, { ObjectMeta: metav1.ObjectMeta{Name: "entry-without-ca", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp2.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -838,7 +838,7 @@ func TestBuildCABundleVolumesForEntries(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) objs := make([]client.Object, 0, len(tt.entries)) diff --git a/cmd/thv-operator/controllers/virtualmcpserver_embedding.go b/cmd/thv-operator/controllers/virtualmcpserver_embedding.go index f8e433c9da..a65a93ec8c 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_embedding.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_embedding.go @@ -10,7 +10,7 @@ import ( "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // isEmbeddingServerReady checks whether the referenced EmbeddingServer @@ -20,14 +20,14 @@ import ( // to detect the "configured but not ready" case that requires requeue. func (r *VirtualMCPServerReconciler) isEmbeddingServerReady( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) (*string, error) { name := embeddingServerNameForVMCP(vmcp) if name == "" { return nil, nil // No embedding server configured, skip check } - es := &mcpv1alpha1.EmbeddingServer{} + es := &mcpv1beta1.EmbeddingServer{} err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: vmcp.Namespace}, es) if err != nil { if errors.IsNotFound(err) { @@ -36,13 +36,13 @@ func (r *VirtualMCPServerReconciler) isEmbeddingServerReady( return nil, fmt.Errorf("failed to get EmbeddingServer %s: %w", name, err) } - if es.Status.Phase == mcpv1alpha1.EmbeddingServerPhaseReady && es.Status.ReadyReplicas > 0 { + if es.Status.Phase == mcpv1beta1.EmbeddingServerPhaseReady && es.Status.ReadyReplicas > 0 { url := es.Status.URL return &url, nil } // Propagate failure so the VirtualMCPServer surfaces it instead of staying Pending - if es.Status.Phase == mcpv1alpha1.EmbeddingServerPhaseFailed { + if es.Status.Phase == mcpv1beta1.EmbeddingServerPhaseFailed { return nil, fmt.Errorf("EmbeddingServer %s has failed", name) } @@ -55,14 +55,14 @@ func (r *VirtualMCPServerReconciler) isEmbeddingServerReady( // Returns empty string if no embedding server is configured. func (r *VirtualMCPServerReconciler) resolveEmbeddingServiceURL( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) (string, error) { name := embeddingServerNameForVMCP(vmcp) if name == "" { return "", nil } - es := &mcpv1alpha1.EmbeddingServer{} + es := &mcpv1beta1.EmbeddingServer{} if err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: vmcp.Namespace}, es); err != nil { return "", fmt.Errorf("failed to get EmbeddingServer %s: %w", name, err) } @@ -72,7 +72,7 @@ func (r *VirtualMCPServerReconciler) resolveEmbeddingServiceURL( // embeddingServerNameForVMCP resolves the EmbeddingServer name for a VirtualMCPServer. // Returns empty string if no embedding server is configured. -func embeddingServerNameForVMCP(vmcp *mcpv1alpha1.VirtualMCPServer) string { +func embeddingServerNameForVMCP(vmcp *mcpv1beta1.VirtualMCPServer) string { if vmcp.Spec.EmbeddingServerRef != nil { return vmcp.Spec.EmbeddingServerRef.Name } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go index b3c77838f7..e9871d2d63 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/pkg/authserver" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" @@ -31,23 +31,23 @@ func TestConvertExternalAuthConfigToStrategy(t *testing.T) { tests := []struct { name string - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig expectError bool validate func(*testing.T, *authtypes.BackendAuthStrategy) }{ { name: "token exchange with all fields", - externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "test-secret", Key: "client-secret"}, + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "test-secret", Key: "client-secret"}, Audience: "backend-service", Scopes: []string{"read", "write"}, SubjectTokenType: "access_token", @@ -70,14 +70,14 @@ func TestConvertExternalAuthConfigToStrategy(t *testing.T) { }, { name: "token exchange with minimal fields", - externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "minimal-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", Audience: "backend-service", }, @@ -98,14 +98,14 @@ func TestConvertExternalAuthConfigToStrategy(t *testing.T) { }, { name: "token exchange with id_token type", - externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "id-token-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", Audience: "backend-service", SubjectTokenType: "id_token", @@ -120,13 +120,13 @@ func TestConvertExternalAuthConfigToStrategy(t *testing.T) { }, { name: "token exchange with nil TokenExchange config", - externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "nil-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, // TokenExchange is nil }, }, @@ -134,16 +134,16 @@ func TestConvertExternalAuthConfigToStrategy(t *testing.T) { }, { name: "header injection", - externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "header-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "api-key-secret", Key: "api-key", }, @@ -163,12 +163,12 @@ func TestConvertExternalAuthConfigToStrategy(t *testing.T) { }, { name: "unsupported auth type", - externalAuthConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "unsupported", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: "unsupported_type", }, }, @@ -181,7 +181,7 @@ func TestConvertExternalAuthConfigToStrategy(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) // Set up fake client (no secrets needed - secrets are mounted as env vars, not resolved) @@ -215,9 +215,9 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { tests := []struct { name string - vmcp *mcpv1alpha1.VirtualMCPServer - mcpServers []mcpv1alpha1.MCPServer - authConfigs []mcpv1alpha1.MCPExternalAuthConfig + vmcp *mcpv1beta1.VirtualMCPServer + mcpServers []mcpv1beta1.MCPServer + authConfigs []mcpv1beta1.MCPExternalAuthConfig workloadNames []workloads.TypedWorkload expectAuthErrors bool // Set to true if test expects auth config errors (non-fatal) validate func(*testing.T, *vmcpconfig.OutgoingAuthConfig) @@ -225,26 +225,26 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { }{ { name: "discovered mode with external auth config", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, }, - mcpServers: []mcpv1alpha1.MCPServer{ + mcpServers: []mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "backend-1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-config-1", }, }, @@ -254,20 +254,20 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { Name: "backend-2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ // No ExternalAuthConfigRef }, }, }, - authConfigs: []mcpv1alpha1.MCPExternalAuthConfig{ + authConfigs: []mcpv1beta1.MCPExternalAuthConfig{ { ObjectMeta: metav1.ObjectMeta{ Name: "auth-config-1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", Audience: "backend-service", }, @@ -296,19 +296,19 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { }, { name: "discovered mode with inline overrides", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ "backend-1": { - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-config-override", }, }, @@ -316,14 +316,14 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { }, }, }, - mcpServers: []mcpv1alpha1.MCPServer{ + mcpServers: []mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "backend-1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-config-1", }, }, @@ -333,22 +333,22 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { Name: "backend-2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-config-2", }, }, }, }, - authConfigs: []mcpv1alpha1.MCPExternalAuthConfig{ + authConfigs: []mcpv1beta1.MCPExternalAuthConfig{ { ObjectMeta: metav1.ObjectMeta{ Name: "auth-config-1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", Audience: "backend-service", }, @@ -359,9 +359,9 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { Name: "auth-config-2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth2.example.com/token", Audience: "backend-service-2", }, @@ -372,9 +372,9 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { Name: "auth-config-override", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth-override.example.com/token", Audience: "backend-service-override", }, @@ -406,19 +406,19 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { }, { name: "inline mode ignores discovered configs", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "inline", - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ "backend-1": { - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-config-1", }, }, @@ -426,28 +426,28 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { }, }, }, - mcpServers: []mcpv1alpha1.MCPServer{ + mcpServers: []mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "backend-1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-config-1", }, }, }, }, - authConfigs: []mcpv1alpha1.MCPExternalAuthConfig{ + authConfigs: []mcpv1beta1.MCPExternalAuthConfig{ { ObjectMeta: metav1.ObjectMeta{ Name: "auth-config-1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", Audience: "backend-service", }, @@ -470,33 +470,33 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { }, { name: "default auth config", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", - Default: &mcpv1alpha1.BackendAuthConfig{ - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Default: &mcpv1beta1.BackendAuthConfig{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "default-auth-config", }, }, }, }, }, - authConfigs: []mcpv1alpha1.MCPExternalAuthConfig{ + authConfigs: []mcpv1beta1.MCPExternalAuthConfig{ { ObjectMeta: metav1.ObjectMeta{ Name: "default-auth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", Audience: "backend-service", }, @@ -512,19 +512,19 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { }, { name: "inline mode with ExternalAuthConfigRef", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "inline", - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ "backend-1": { - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-config-1", }, }, @@ -532,15 +532,15 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { }, }, }, - authConfigs: []mcpv1alpha1.MCPExternalAuthConfig{ + authConfigs: []mcpv1beta1.MCPExternalAuthConfig{ { ObjectMeta: metav1.ObjectMeta{ Name: "auth-config-1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", Audience: "backend-service", ClientID: "test-client", @@ -560,26 +560,26 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { }, { name: "missing ExternalAuthConfig should be skipped gracefully", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, }, - mcpServers: []mcpv1alpha1.MCPServer{ + mcpServers: []mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "backend-1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "missing-auth-config", }, }, @@ -610,13 +610,13 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { }, { name: "defaults to discovered mode when source not specified", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, // No OutgoingAuth specified }, }, @@ -628,18 +628,18 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { }, { name: "default auth config error is collected but doesn't fail reconciliation", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", - Default: &mcpv1alpha1.BackendAuthConfig{ + Default: &mcpv1beta1.BackendAuthConfig{ Type: "externalAuthConfigRef", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "missing-default-auth", // Auth config doesn't exist }, }, @@ -665,19 +665,19 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { }, { name: "backend-specific auth config error is collected but doesn't fail reconciliation", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ "api-backend": { Type: "externalAuthConfigRef", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "missing-backend-auth", }, }, @@ -709,7 +709,7 @@ func TestBuildOutgoingAuthConfig(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) // Build objects list for fake client objects := []client.Object{tt.vmcp} @@ -759,28 +759,28 @@ func TestConvertBackendAuthConfigToVMCP(t *testing.T) { tests := []struct { name string - crdConfig *mcpv1alpha1.BackendAuthConfig - authConfigs []mcpv1alpha1.MCPExternalAuthConfig + crdConfig *mcpv1beta1.BackendAuthConfig + authConfigs []mcpv1beta1.MCPExternalAuthConfig expectError bool validate func(*testing.T, *authtypes.BackendAuthStrategy) }{ { name: "externalAuthConfigRef type", - crdConfig: &mcpv1alpha1.BackendAuthConfig{ - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + crdConfig: &mcpv1beta1.BackendAuthConfig{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth-config", }, }, - authConfigs: []mcpv1alpha1.MCPExternalAuthConfig{ + authConfigs: []mcpv1beta1.MCPExternalAuthConfig{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-auth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", Audience: "backend-service", ClientID: "test-client", @@ -799,9 +799,9 @@ func TestConvertBackendAuthConfigToVMCP(t *testing.T) { }, { name: "missing ExternalAuthConfig", - crdConfig: &mcpv1alpha1.BackendAuthConfig{ - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + crdConfig: &mcpv1beta1.BackendAuthConfig{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "missing-config", }, }, @@ -814,7 +814,7 @@ func TestConvertBackendAuthConfigToVMCP(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) objects := []client.Object{} for i := range tt.authConfigs { @@ -976,12 +976,12 @@ func tokenExchangeStrategy(subjectProviderName string) *authtypes.BackendAuthStr } // embeddedAuthServerCfg builds a minimal EmbeddedAuthServerConfig with the given upstream names. -func embeddedAuthServerCfg(upstreamNames ...string) *mcpv1alpha1.EmbeddedAuthServerConfig { - cfg := &mcpv1alpha1.EmbeddedAuthServerConfig{} +func embeddedAuthServerCfg(upstreamNames ...string) *mcpv1beta1.EmbeddedAuthServerConfig { + cfg := &mcpv1beta1.EmbeddedAuthServerConfig{} for _, name := range upstreamNames { - cfg.UpstreamProviders = append(cfg.UpstreamProviders, mcpv1alpha1.UpstreamProviderConfig{ + cfg.UpstreamProviders = append(cfg.UpstreamProviders, mcpv1beta1.UpstreamProviderConfig{ Name: name, - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, + Type: mcpv1beta1.UpstreamProviderTypeOIDC, }) } return cfg @@ -995,7 +995,7 @@ func TestInjectSubjectProviderIfNeeded(t *testing.T) { tests := []struct { name string strategy *authtypes.BackendAuthStrategy - embeddedCfg *mcpv1alpha1.EmbeddedAuthServerConfig + embeddedCfg *mcpv1beta1.EmbeddedAuthServerConfig wantSubjectProviderName string wantSamePointer bool }{ @@ -1091,72 +1091,72 @@ func TestBuildOutgoingAuthConfig_SubjectProviderInjection(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // A shared MCPExternalAuthConfig with token_exchange and no SubjectProviderName. - defaultAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + defaultAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "default-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", // SubjectProviderName intentionally left empty }, }, } - discoveredAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + discoveredAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "discovered-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", // SubjectProviderName intentionally left empty }, }, } - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "backend-1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "discovered-auth", }, }, } - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", // Default references an MCPExternalAuthConfig (the only supported form // for a default auth in the CRD). - Default: &mcpv1alpha1.BackendAuthConfig{ - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Default: &mcpv1beta1.BackendAuthConfig{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "default-auth", }, }, }, - AuthServerConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + AuthServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "myidp", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, + Type: mcpv1beta1.UpstreamProviderTypeOIDC, }, }, }, @@ -1249,15 +1249,15 @@ func TestDiscoverExternalAuthConfigSecrets_DeterministicOrdering(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, @@ -1266,28 +1266,28 @@ func TestDiscoverExternalAuthConfigSecrets_DeterministicOrdering(t *testing.T) { // Four MCPServers each referencing a distinct MCPExternalAuthConfig. // The MCPServer names match the workload Names in tt.workloadOrder. mcpServers := []client.Object{ - &mcpv1alpha1.MCPServer{ + &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "server-alpha", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "alpha-auth"}, + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "alpha-auth"}, }, }, - &mcpv1alpha1.MCPServer{ + &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "server-beta", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "beta-auth"}, + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "beta-auth"}, }, }, - &mcpv1alpha1.MCPServer{ + &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "server-mu", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "mu-auth"}, + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "mu-auth"}, }, }, - &mcpv1alpha1.MCPServer{ + &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "server-zeta", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "zeta-auth"}, + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "zeta-auth"}, }, }, } @@ -1295,47 +1295,47 @@ func TestDiscoverExternalAuthConfigSecrets_DeterministicOrdering(t *testing.T) { // One MCPExternalAuthConfig per MCPServer, each with a client secret ref so // getExternalAuthConfigSecretEnvVar returns a non-nil env var. authConfigs := []client.Object{ - &mcpv1alpha1.MCPExternalAuthConfig{ + &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "alpha-auth", Namespace: "default"}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://alpha.example.com/token", Audience: "alpha-service", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "alpha-secret", Key: "client-secret"}, + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "alpha-secret", Key: "client-secret"}, }, }, }, - &mcpv1alpha1.MCPExternalAuthConfig{ + &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "beta-auth", Namespace: "default"}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://beta.example.com/token", Audience: "beta-service", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "beta-secret", Key: "client-secret"}, + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "beta-secret", Key: "client-secret"}, }, }, }, - &mcpv1alpha1.MCPExternalAuthConfig{ + &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "mu-auth", Namespace: "default"}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://mu.example.com/token", Audience: "mu-service", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "mu-secret", Key: "client-secret"}, + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "mu-secret", Key: "client-secret"}, }, }, }, - &mcpv1alpha1.MCPExternalAuthConfig{ + &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "zeta-auth", Namespace: "default"}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://zeta.example.com/token", Audience: "zeta-service", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "zeta-secret", Key: "client-secret"}, + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "zeta-secret", Key: "client-secret"}, }, }, }, @@ -1402,39 +1402,39 @@ func TestDiscoverInlineExternalAuthConfigSecrets_DeterministicOrdering(t *testin // Alphabetical order: ALPHA < BETA < MU < ZETA scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "inline", // Map with four backends — Go map iteration order is non-deterministic. - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ "backend-zeta": { - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "inline-zeta-auth", }, }, "backend-mu": { - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "inline-mu-auth", }, }, "backend-beta": { - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "inline-beta-auth", }, }, "backend-alpha": { - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "inline-alpha-auth", }, }, @@ -1444,47 +1444,47 @@ func TestDiscoverInlineExternalAuthConfigSecrets_DeterministicOrdering(t *testin } authConfigs := []client.Object{ - &mcpv1alpha1.MCPExternalAuthConfig{ + &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "inline-alpha-auth", Namespace: "default"}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://alpha.example.com/token", Audience: "alpha-service", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "inline-alpha-secret", Key: "client-secret"}, + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "inline-alpha-secret", Key: "client-secret"}, }, }, }, - &mcpv1alpha1.MCPExternalAuthConfig{ + &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "inline-beta-auth", Namespace: "default"}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://beta.example.com/token", Audience: "beta-service", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "inline-beta-secret", Key: "client-secret"}, + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "inline-beta-secret", Key: "client-secret"}, }, }, }, - &mcpv1alpha1.MCPExternalAuthConfig{ + &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "inline-mu-auth", Namespace: "default"}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://mu.example.com/token", Audience: "mu-service", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "inline-mu-secret", Key: "client-secret"}, + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "inline-mu-secret", Key: "client-secret"}, }, }, }, - &mcpv1alpha1.MCPExternalAuthConfig{ + &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "inline-zeta-auth", Namespace: "default"}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://zeta.example.com/token", Audience: "zeta-service", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "inline-zeta-secret", Key: "client-secret"}, + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "inline-zeta-secret", Key: "client-secret"}, }, }, }, @@ -1533,47 +1533,47 @@ func TestBuildOutgoingAuthConfig_InlineBackendSubjectProviderInjection(t *testin t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // MCPExternalAuthConfig referenced by the inline Backends override. - inlineAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + inlineAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "inline-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", // SubjectProviderName intentionally left empty }, }, } - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", // Inline Backends override — the path exercised by this test. - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ "inline-backend": { - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "inline-auth", }, }, }, }, - AuthServerConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + AuthServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "corporate-idp", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, + Type: mcpv1beta1.UpstreamProviderTypeOIDC, }, }, }, diff --git a/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go b/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go index 3b90731626..04ed419576 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go @@ -17,7 +17,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" "github.com/stacklok/toolhive/pkg/vmcp/workloads" ) @@ -33,7 +33,7 @@ const ( func TestVirtualMCPServerPodTemplateSpecDeterministic(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) @@ -41,13 +41,13 @@ func TestVirtualMCPServerPodTemplateSpecDeterministic(t *testing.T) { vmcpName := testPodTemplateVmcpName groupName := testPodTemplateGroupName - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: groupName, Namespace: namespace, }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, } @@ -57,13 +57,13 @@ func TestVirtualMCPServerPodTemplateSpecDeterministic(t *testing.T) { }, } - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, PodTemplateSpec: podTemplateSpecToRawExtension(t, podTemplate), }, } @@ -111,7 +111,7 @@ func TestVirtualMCPServerPodTemplateSpecDeterministic(t *testing.T) { func TestVirtualMCPServerPodTemplateSpecPreservesContainer(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) @@ -119,25 +119,25 @@ func TestVirtualMCPServerPodTemplateSpecPreservesContainer(t *testing.T) { vmcpName := testPodTemplateVmcpName groupName := testPodTemplateGroupName - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: groupName, Namespace: namespace, }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, } // Use raw JSON directly (simulating real user input) - only nodeSelector, no containers // This is the exact scenario that triggered the original bug - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, PodTemplateSpec: &runtime.RawExtension{ Raw: []byte(`{"spec":{"nodeSelector":{"disktype":"ssd"}}}`), }, @@ -266,13 +266,13 @@ func TestVirtualMCPServerPodTemplateSpecNeedsUpdate(t *testing.T) { }, } - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testPodTemplateVmcpName, Namespace: testPodTemplateNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testPodTemplateGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testPodTemplateGroupName}, PodTemplateSpec: tt.newPodTemplateSpec, }, } @@ -290,7 +290,7 @@ func TestVirtualMCPServerPodTemplateSpecNeedsUpdate(t *testing.T) { func TestVirtualMCPServerPodTemplateSpecResourceOverride(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) @@ -298,24 +298,24 @@ func TestVirtualMCPServerPodTemplateSpecResourceOverride(t *testing.T) { vmcpName := testPodTemplateVmcpName groupName := testPodTemplateGroupName - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: groupName, Namespace: namespace, }, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, } // Provide custom resources for the vmcp container via PodTemplateSpec - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, PodTemplateSpec: &runtime.RawExtension{ Raw: []byte(`{"spec":{"containers":[{"name":"vmcp","resources":{"requests":{"cpu":"200m","memory":"256Mi"},"limits":{"cpu":"1","memory":"1Gi"}}}]}}`), }, diff --git a/cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig.go b/cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig.go index afb3405f71..5224f242cf 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig.go @@ -13,7 +13,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/virtualmcpserverstatus" ) @@ -26,9 +26,9 @@ import ( // Uses the batched statusManager pattern instead of direct r.Status().Update(). func (r *VirtualMCPServerReconciler) handleTelemetryConfig( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, -) (*mcpv1alpha1.MCPTelemetryConfig, error) { +) (*mcpv1beta1.MCPTelemetryConfig, error) { ctxLogger := log.FromContext(ctx) if vmcp.Spec.TelemetryConfigRef == nil { @@ -37,7 +37,7 @@ func (r *VirtualMCPServerReconciler) handleTelemetryConfig( statusManager.SetTelemetryConfigHash("") } statusManager.RemoveConditionsWithPrefix( - mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, []string{}) + mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, []string{}) return nil, nil } @@ -46,7 +46,7 @@ func (r *VirtualMCPServerReconciler) handleTelemetryConfig( if err != nil { // Transient API error (not a NotFound) statusManager.SetTelemetryConfigRefValidatedCondition( - mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefFetchError, + mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefFetchError, err.Error(), metav1.ConditionFalse, ) @@ -56,7 +56,7 @@ func (r *VirtualMCPServerReconciler) handleTelemetryConfig( if telemetryConfig == nil { // Resource genuinely does not exist statusManager.SetTelemetryConfigRefValidatedCondition( - mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefNotFound, + mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefNotFound, fmt.Sprintf("MCPTelemetryConfig %s not found", vmcp.Spec.TelemetryConfigRef.Name), metav1.ConditionFalse, ) @@ -66,7 +66,7 @@ func (r *VirtualMCPServerReconciler) handleTelemetryConfig( // Validate that the MCPTelemetryConfig is valid (has Valid=True condition) if err := telemetryConfig.Validate(); err != nil { statusManager.SetTelemetryConfigRefValidatedCondition( - mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefInvalid, + mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefInvalid, fmt.Sprintf("MCPTelemetryConfig %s is invalid: %v", vmcp.Spec.TelemetryConfigRef.Name, err), metav1.ConditionFalse, ) @@ -75,7 +75,7 @@ func (r *VirtualMCPServerReconciler) handleTelemetryConfig( // Set valid condition statusManager.SetTelemetryConfigRefValidatedCondition( - mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid, + mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid, fmt.Sprintf("MCPTelemetryConfig %s is valid", vmcp.Spec.TelemetryConfigRef.Name), metav1.ConditionTrue, ) @@ -99,12 +99,12 @@ func (r *VirtualMCPServerReconciler) handleTelemetryConfig( func (r *VirtualMCPServerReconciler) mapTelemetryConfigToVirtualMCPServer( ctx context.Context, obj client.Object, ) []reconcile.Request { - telemetryConfig, ok := obj.(*mcpv1alpha1.MCPTelemetryConfig) + telemetryConfig, ok := obj.(*mcpv1beta1.MCPTelemetryConfig) if !ok { return nil } - vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + vmcpList := &mcpv1beta1.VirtualMCPServerList{} if err := r.List(ctx, vmcpList, client.InNamespace(telemetryConfig.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list VirtualMCPServers for MCPTelemetryConfig watch") return nil diff --git a/cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig_test.go b/cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig_test.go index 6e2f82035e..6011c73b69 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_telemetryconfig_test.go @@ -13,7 +13,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/virtualmcpserverstatus" ) @@ -21,12 +21,12 @@ func TestHandleTelemetryConfig_VirtualMCPServer(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) tests := []struct { name string - vmcp *mcpv1alpha1.VirtualMCPServer - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig + vmcp *mcpv1beta1.VirtualMCPServer + telemetryConfig *mcpv1beta1.MCPTelemetryConfig expectError bool expectTelCfgNil bool expectedHash string @@ -38,16 +38,16 @@ func TestHandleTelemetryConfig_VirtualMCPServer(t *testing.T) { }{ { name: "nil ref clears hash and removes condition", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{TelemetryConfigRef: nil}, - Status: mcpv1alpha1.VirtualMCPServerStatus{ + Spec: mcpv1beta1.VirtualMCPServerSpec{TelemetryConfigRef: nil}, + Status: mcpv1beta1.VirtualMCPServerStatus{ TelemetryConfigHash: "old-hash", Conditions: []metav1.Condition{ { - Type: mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + Type: mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, Status: metav1.ConditionTrue, - Reason: mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid, + Reason: mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid, }, }, }, @@ -59,86 +59,86 @@ func TestHandleTelemetryConfig_VirtualMCPServer(t *testing.T) { }, { name: "valid ref sets condition true and updates hash", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "my-telemetry"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "my-telemetry"}, }, }, - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{Name: "my-telemetry", Namespace: "default"}, Spec: newTelemetrySpec("https://otel-collector:4317", true, false), - Status: mcpv1alpha1.MCPTelemetryConfigStatus{ + Status: mcpv1beta1.MCPTelemetryConfigStatus{ ConfigHash: "abc123", }, }, expectError: false, expectedHash: "abc123", - expectedCondType: mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + expectedCondType: mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, expectedCondStatus: metav1.ConditionTrue, - expectedCondReason: mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid, + expectedCondReason: mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid, }, { name: "not found sets condition false", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "missing"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "missing"}, }, }, expectError: true, - expectedCondType: mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + expectedCondType: mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, expectedCondStatus: metav1.ConditionFalse, - expectedCondReason: mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefNotFound, + expectedCondReason: mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefNotFound, }, { name: "invalid config sets condition false", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "invalid-telemetry"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "invalid-telemetry"}, }, }, // Spec with endpoint but no tracing/metrics enabled -> Validate() fails - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{Name: "invalid-telemetry", Namespace: "default"}, - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "https://otel-collector:4317", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: false}, - Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{Enabled: false}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: false}, + Metrics: &mcpv1beta1.OpenTelemetryMetricsConfig{Enabled: false}, }, }, }, expectError: true, - expectedCondType: mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + expectedCondType: mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, expectedCondStatus: metav1.ConditionFalse, - expectedCondReason: mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefInvalid, + expectedCondReason: mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefInvalid, }, { name: "hash change triggers update", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "my-telemetry"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "my-telemetry"}, }, - Status: mcpv1alpha1.VirtualMCPServerStatus{ + Status: mcpv1beta1.VirtualMCPServerStatus{ TelemetryConfigHash: "old-hash", }, }, - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{Name: "my-telemetry", Namespace: "default"}, Spec: newTelemetrySpec("https://otel-collector:4317", true, false), - Status: mcpv1alpha1.MCPTelemetryConfigStatus{ + Status: mcpv1beta1.MCPTelemetryConfigStatus{ ConfigHash: "new-hash", }, }, expectError: false, expectedHash: "new-hash", - expectedCondType: mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + expectedCondType: mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, expectedCondStatus: metav1.ConditionTrue, - expectedCondReason: mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid, + expectedCondReason: mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid, }, } @@ -183,7 +183,7 @@ func TestHandleTelemetryConfig_VirtualMCPServer(t *testing.T) { if tt.expectCondRemoved { for _, c := range status.Conditions { assert.NotEqual(t, - mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, c.Type, "stale TelemetryConfigRefValidated condition should be removed") } } @@ -212,23 +212,23 @@ func TestMapTelemetryConfigToVirtualMCPServer(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - vmcp1 := &mcpv1alpha1.VirtualMCPServer{ + vmcp1 := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "vmcp1", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "shared-telemetry"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "shared-telemetry"}, }, } - vmcp2 := &mcpv1alpha1.VirtualMCPServer{ + vmcp2 := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "vmcp2", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "other-telemetry"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "other-telemetry"}, }, } - vmcp3 := &mcpv1alpha1.VirtualMCPServer{ + vmcp3 := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "vmcp3", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{}, // no ref + Spec: mcpv1beta1.VirtualMCPServerSpec{}, // no ref } fakeClient := fake.NewClientBuilder(). @@ -243,7 +243,7 @@ func TestMapTelemetryConfigToVirtualMCPServer(t *testing.T) { ctx := t.Context() - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{Name: "shared-telemetry", Namespace: "default"}, } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go index a4eae8bb90..b4c4956371 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go @@ -12,7 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/kubernetes/configmaps" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" @@ -33,9 +33,9 @@ import ( // statusManager is used to set auth config conditions for any conversion failures. func (r *VirtualMCPServerReconciler) ensureVmcpConfigConfigMap( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, typedWorkloads []workloads.TypedWorkload, - telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, statusManager virtualmcpserverstatus.StatusManager, ) error { // Create OIDC resolver and converter for CRD-to-config transformation @@ -70,10 +70,10 @@ func (r *VirtualMCPServerReconciler) ensureVmcpConfigConfigMap( // don't need to know about the two-step validation sequence. if err := vmcpconfig.ValidateAuthServerIntegration(config, authServerRC); err != nil { message := fmt.Sprintf("invalid auth server integration: %v", err) - statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) statusManager.SetMessage(message) statusManager.SetAuthServerConfigValidatedCondition( - mcpv1alpha1.ConditionReasonAuthServerConfigInvalid, + mcpv1beta1.ConditionReasonAuthServerConfigInvalid, message, metav1.ConditionFalse, ) @@ -144,7 +144,7 @@ func (r *VirtualMCPServerReconciler) ensureVmcpConfigConfigMap( // so the "ref set + optimizer nil" case no longer reaches this function. func (r *VirtualMCPServerReconciler) populateOptimizerEmbeddingService( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, config *vmcpconfig.Config, ) error { ctxLogger := log.FromContext(ctx) @@ -174,7 +174,7 @@ func (r *VirtualMCPServerReconciler) populateOptimizerEmbeddingService( // config.Optimizer.EmbeddingService, warning if it overrides a manually-set value. func (r *VirtualMCPServerReconciler) populateOptimizerFromRef( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, config *vmcpconfig.Config, ) error { ctxLogger := log.FromContext(ctx) @@ -213,7 +213,7 @@ func labelsForVmcpConfig(vmcpName string) map[string]string { // Used in static mode for ConfigMap generation to preserve backend metadata. func (r *VirtualMCPServerReconciler) discoverBackendsWithMetadata( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) ([]vmcptypes.Backend, error) { groupsManager := groups.NewCRDManager(r.Client, vmcp.Namespace) workloadDiscoverer := workloads.NewK8SDiscovererWithClient(r.Client, vmcp.Namespace) @@ -340,7 +340,7 @@ func (r *VirtualMCPServerReconciler) buildCABundlePathMap( } // extractInlineBackendNames extracts the list of inline backend names from the VirtualMCPServer spec. -func extractInlineBackendNames(vmcp *mcpv1alpha1.VirtualMCPServer) []string { +func extractInlineBackendNames(vmcp *mcpv1beta1.VirtualMCPServer) []string { if vmcp.Spec.OutgoingAuth == nil || vmcp.Spec.OutgoingAuth.Backends == nil { return nil } @@ -373,7 +373,7 @@ func determineValidInlineBackends(authConfig *vmcpconfig.OutgoingAuthConfig, inl // It builds auth configs, sets status conditions for all auth config types, and configures static backends for inline mode. func (r *VirtualMCPServerReconciler) processOutgoingAuth( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, config *vmcpconfig.Config, typedWorkloads []workloads.TypedWorkload, statusManager virtualmcpserverstatus.StatusManager, diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go index 5f934a0d47..5d0fe5efde 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go @@ -21,7 +21,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" oidcmocks "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc/mocks" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/virtualmcpserverstatus" statusmocks "github.com/stacklok/toolhive/cmd/thv-operator/pkg/virtualmcpserverstatus/mocks" @@ -45,7 +45,7 @@ func newNoOpMockResolver(t *testing.T) *oidcmocks.MockResolver { func newTestConverter(t *testing.T, resolver *oidcmocks.MockResolver) *vmcpconfigconv.Converter { t.Helper() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() converter, err := vmcpconfigconv.NewConverter(resolver, fakeClient) require.NoError(t, err) @@ -58,19 +58,19 @@ func TestCreateVmcpConfigFromVirtualMCPServer(t *testing.T) { tests := []struct { name string - vmcp *mcpv1alpha1.VirtualMCPServer + vmcp *mcpv1beta1.VirtualMCPServer expectedName string expectedGroupRef string }{ { name: "basic config", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, expectedName: "test-vmcp", @@ -100,26 +100,26 @@ func TestConvertOutgoingAuth(t *testing.T) { tests := []struct { name string - outgoingAuth *mcpv1alpha1.OutgoingAuthConfig + outgoingAuth *mcpv1beta1.OutgoingAuthConfig expectedSource string hasDefault bool backendCount int }{ { name: "discovered mode", - outgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ - Source: mcpv1alpha1.BackendAuthTypeDiscovered, + outgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ + Source: mcpv1beta1.BackendAuthTypeDiscovered, }, - expectedSource: mcpv1alpha1.BackendAuthTypeDiscovered, + expectedSource: mcpv1beta1.BackendAuthTypeDiscovered, hasDefault: false, backendCount: 0, }, { name: "with default auth", - outgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + outgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "inline", - Default: &mcpv1alpha1.BackendAuthConfig{ - Type: mcpv1alpha1.BackendAuthTypeDiscovered, + Default: &mcpv1beta1.BackendAuthConfig{ + Type: mcpv1beta1.BackendAuthTypeDiscovered, }, }, expectedSource: "inline", @@ -128,11 +128,11 @@ func TestConvertOutgoingAuth(t *testing.T) { }, { name: "with per-backend auth", - outgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + outgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ "backend-1": { - Type: mcpv1alpha1.BackendAuthTypeDiscovered, + Type: mcpv1beta1.BackendAuthTypeDiscovered, }, }, }, @@ -147,9 +147,9 @@ func TestConvertOutgoingAuth(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + vmcpServer := &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: tt.outgoingAuth, }, } @@ -176,22 +176,22 @@ func TestConvertBackendAuthConfig(t *testing.T) { tests := []struct { name string - authConfig *mcpv1alpha1.BackendAuthConfig + authConfig *mcpv1beta1.BackendAuthConfig expectedType string }{ { name: "discovered", - authConfig: &mcpv1alpha1.BackendAuthConfig{ - Type: mcpv1alpha1.BackendAuthTypeDiscovered, + authConfig: &mcpv1beta1.BackendAuthConfig{ + Type: mcpv1beta1.BackendAuthTypeDiscovered, }, // "discovered" type is converted to "unauthenticated" by the converter expectedType: "unauthenticated", }, { name: "external auth config ref", - authConfig: &mcpv1alpha1.BackendAuthConfig{ - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + authConfig: &mcpv1beta1.BackendAuthConfig{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-config", }, }, @@ -205,14 +205,14 @@ func TestConvertBackendAuthConfig(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Default: tt.authConfig, }, }, @@ -220,21 +220,21 @@ func TestConvertBackendAuthConfig(t *testing.T) { // For externalAuthConfigRef test, create the referenced MCPExternalAuthConfig var converter *vmcpconfigconv.Converter - if tt.authConfig.Type == mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef { + if tt.authConfig.Type == mcpv1beta1.BackendAuthTypeExternalAuthConfigRef { // Create a fake MCPExternalAuthConfig - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUnauthenticated, }, } // Create converter with fake client that has the external auth config scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(externalAuthConfig). @@ -330,9 +330,9 @@ func TestConvertAggregation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + vmcpServer := &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ Aggregation: tt.aggregation, }, @@ -382,7 +382,7 @@ func TestConvertCompositeTools(t *testing.T) { Steps: []vmcpconfig.WorkflowStepConfig{ { ID: "deploy", - Type: mcpv1alpha1.WorkflowStepTypeToolCall, + Type: mcpv1beta1.WorkflowStepTypeToolCall, Tool: "kubectl.apply", }, }, @@ -399,7 +399,7 @@ func TestConvertCompositeTools(t *testing.T) { Steps: []vmcpconfig.WorkflowStepConfig{ { ID: "step1", - Type: mcpv1alpha1.WorkflowStepTypeToolCall, + Type: mcpv1beta1.WorkflowStepTypeToolCall, }, }, }, @@ -409,7 +409,7 @@ func TestConvertCompositeTools(t *testing.T) { Steps: []vmcpconfig.WorkflowStepConfig{ { ID: "step1", - Type: mcpv1alpha1.WorkflowStepTypeElicitation, + Type: mcpv1beta1.WorkflowStepTypeElicitation, }, }, }, @@ -423,9 +423,9 @@ func TestConvertCompositeTools(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + vmcpServer := &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ CompositeTools: tt.compositeTools, }, @@ -452,27 +452,27 @@ func TestConvertCompositeTools(t *testing.T) { func TestEnsureVmcpConfigConfigMap(t *testing.T) { t.Parallel() - testVmcp := &mcpv1alpha1.VirtualMCPServer{ + testVmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } // Create MCPGroup for workload discovery - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, } scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) fakeClient := fake.NewClientBuilder(). @@ -1005,13 +1005,13 @@ func TestYAMLMarshalingDeterminism(t *testing.T) { t.Parallel() // Create a VirtualMCPServer with multiple map fields to test determinism - testVmcp := &mcpv1alpha1.VirtualMCPServer{ + testVmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ // Aggregation with tool overrides (map) Aggregation: &vmcpconfig.AggregationConfig{ @@ -1049,17 +1049,17 @@ func TestYAMLMarshalingDeterminism(t *testing.T) { }, }, // OutgoingAuth with Backends map - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ "backend-zebra": { - Type: mcpv1alpha1.BackendAuthTypeDiscovered, + Type: mcpv1beta1.BackendAuthTypeDiscovered, }, "backend-alpha": { - Type: mcpv1alpha1.BackendAuthTypeDiscovered, + Type: mcpv1beta1.BackendAuthTypeDiscovered, }, "backend-middle": { - Type: mcpv1alpha1.BackendAuthTypeDiscovered, + Type: mcpv1beta1.BackendAuthTypeDiscovered, }, }, }, @@ -1115,12 +1115,12 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_EndToEnd(t *testing.T) { testScheme := createRunConfigTestScheme() // Create a VirtualMCPCompositeToolDefinition - compositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeToolDef := &mcpv1beta1.VirtualMCPCompositeToolDefinition{ ObjectMeta: metav1.ObjectMeta{ Name: "test-composite-tool", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "test-composite-tool", Description: "A test composite tool definition", @@ -1144,31 +1144,31 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_EndToEnd(t *testing.T) { } // Create MCPGroup - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Spec: mcpv1beta1.MCPGroupSpec{}, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, } // Create VirtualMCPServer that references the composite tool - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "test-composite-tool"}, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -1237,12 +1237,12 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_MergeInlineAndReferenced(t testScheme := createRunConfigTestScheme() // Create a referenced VirtualMCPCompositeToolDefinition - referencedTool := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + referencedTool := &mcpv1beta1.VirtualMCPCompositeToolDefinition{ ObjectMeta: metav1.ObjectMeta{ Name: "referenced-tool", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "referenced-tool", Description: "A referenced composite tool", @@ -1258,25 +1258,25 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_MergeInlineAndReferenced(t } // Create MCPGroup - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Spec: mcpv1beta1.MCPGroupSpec{}, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, } // Create VirtualMCPServer with both inline and referenced tools - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ CompositeTools: []vmcpconfig.CompositeToolConfig{ { @@ -1295,7 +1295,7 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_MergeInlineAndReferenced(t {Name: "referenced-tool"}, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -1355,31 +1355,31 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_NotFound(t *testing.T) { testScheme := createRunConfigTestScheme() // Create MCPGroup - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Spec: mcpv1beta1.MCPGroupSpec{}, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, } // Create VirtualMCPServer that references a non-existent composite tool - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "non-existent-tool"}, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -1418,29 +1418,29 @@ func TestConfigMapContent_DynamicMode(t *testing.T) { testScheme := createRunConfigTestScheme() // Create MCPGroup for workload discovery - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Spec: mcpv1beta1.MCPGroupSpec{}, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, } // Create VirtualMCPServer in dynamic mode (source: discovered) - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", // Dynamic mode }, }, @@ -1502,50 +1502,50 @@ func TestConfigMapContent_StaticMode_InlineOverrides(t *testing.T) { testScheme := createRunConfigTestScheme() // Create MCPGroup for workload discovery - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Spec: mcpv1beta1.MCPGroupSpec{}, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, } // Create MCPServer in the group so static mode has something to discover // This is needed because static mode validates that at least one backend exists - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-backend", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Transport: "sse", // Required for backend discovery }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, URL: "http://test-backend.default.svc.cluster.local:8080", }, } // Create VirtualMCPServer in static mode (source: inline) - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "inline", // Static mode - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ "test-backend": { - Type: mcpv1alpha1.BackendAuthTypeDiscovered, + Type: mcpv1beta1.BackendAuthTypeDiscovered, }, }, }, @@ -1609,59 +1609,59 @@ func TestConfigMapContent_StaticModeWithDiscovery(t *testing.T) { testScheme := createRunConfigTestScheme() // Create MCPGroup for workload discovery - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, - Status: mcpv1alpha1.MCPGroupStatus{ - Phase: mcpv1alpha1.MCPGroupPhaseReady, + Spec: mcpv1beta1.MCPGroupSpec{}, + Status: mcpv1beta1.MCPGroupStatus{ + Phase: mcpv1beta1.MCPGroupPhaseReady, }, } // Create MCPExternalAuthConfig that will be referenced by MCPServer - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUnauthenticated, }, } // Create MCPServer with ExternalAuthConfigRef and Status - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "discovered-backend", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Transport: "sse", // Required for static mode backend discovery - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth-config", }, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, URL: "http://discovered-backend.default.svc.cluster.local:8080", }, } // Create VirtualMCPServer in static mode (source: inline) WITHOUT inline backends - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "inline", // Static mode - should discover backends }, }, @@ -1821,24 +1821,24 @@ func TestOptimizerEmbeddingServiceURL(t *testing.T) { tests := []struct { name string - vmcp *mcpv1alpha1.VirtualMCPServer + vmcp *mcpv1beta1.VirtualMCPServer esName string esPort int32 expectedURL string }{ { name: "referenced embedding server populates full URL", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "my-vmcp", Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroup}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroup}, Config: vmcpconfig.Config{ Optimizer: &vmcpconfig.OptimizerConfig{}, }, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{ Name: "shared-embedding", }, }, @@ -1849,15 +1849,15 @@ func TestOptimizerEmbeddingServiceURL(t *testing.T) { }, { name: "ref without optimizer auto-populates optimizer with defaults", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "my-vmcp", Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroup}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroup}, // No Optimizer — validation auto-populates it when ref is set - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{ Name: "shared-embedding", }, }, @@ -1875,31 +1875,31 @@ func TestOptimizerEmbeddingServiceURL(t *testing.T) { ctx := context.Background() testScheme := createRunConfigTestScheme() - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: testGroup, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{}, - Status: mcpv1alpha1.MCPGroupStatus{Phase: mcpv1alpha1.MCPGroupPhaseReady}, + Spec: mcpv1beta1.MCPGroupSpec{}, + Status: mcpv1beta1.MCPGroupStatus{Phase: mcpv1beta1.MCPGroupPhaseReady}, } objects := []runtime.Object{tt.vmcp, mcpGroup} // Create the EmbeddingServer with Status.URL if one is expected if tt.esName != "" { - es := &mcpv1alpha1.EmbeddingServer{ + es := &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: tt.esName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Image: "ghcr.io/huggingface/text-embeddings-inference:cpu-1.5", Model: "BAAI/bge-small-en-v1.5", Port: tt.esPort, }, - Status: mcpv1alpha1.EmbeddingServerStatus{ - Phase: mcpv1alpha1.EmbeddingServerPhaseReady, + Status: mcpv1beta1.EmbeddingServerStatus{ + Phase: mcpv1beta1.EmbeddingServerPhaseReady, ReadyReplicas: 1, URL: fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", tt.esName, testNamespace, tt.esPort), @@ -1964,15 +1964,15 @@ func TestConfigMapContent_SessionStorage(t *testing.T) { tests := []struct { name string - sessionStorage *mcpv1alpha1.SessionStorageConfig + sessionStorage *mcpv1beta1.SessionStorageConfig expectedStorage *vmcpconfig.SessionStorageConfig // noLeakStrings are substrings that must NOT appear in config.yaml (secret leakage check). noLeakStrings []string }{ { name: "redis provider populates sessionStorage in ConfigMap YAML", - sessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + sessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis.default.svc:6379", DB: 1, KeyPrefix: "thv:", @@ -1991,7 +1991,7 @@ func TestConfigMapContent_SessionStorage(t *testing.T) { }, { name: "memory provider produces no sessionStorage section", - sessionStorage: &mcpv1alpha1.SessionStorageConfig{Provider: "memory"}, + sessionStorage: &mcpv1beta1.SessionStorageConfig{Provider: "memory"}, expectedStorage: nil, }, { @@ -1999,12 +1999,12 @@ func TestConfigMapContent_SessionStorage(t *testing.T) { // the password via THV_SESSION_REDIS_PASSWORD env var; it must never appear in // the ConfigMap YAML where any reader of the ConfigMap could see it. name: "redis provider with passwordRef — secret name and key not in ConfigMap YAML", - sessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + sessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis.default.svc:6379", DB: 1, KeyPrefix: "thv:", - PasswordRef: &mcpv1alpha1.SecretKeyRef{ + PasswordRef: &mcpv1beta1.SecretKeyRef{ Name: "redis-secret", Key: "redis-password", }, @@ -2026,16 +2026,16 @@ func TestConfigMapContent_SessionStorage(t *testing.T) { ctx := context.Background() testScheme := createRunConfigTestScheme() - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: testGroup, Namespace: testNamespace}, - Spec: mcpv1alpha1.MCPGroupSpec{}, - Status: mcpv1alpha1.MCPGroupStatus{Phase: mcpv1alpha1.MCPGroupPhaseReady}, + Spec: mcpv1beta1.MCPGroupSpec{}, + Status: mcpv1beta1.MCPGroupStatus{Phase: mcpv1beta1.MCPGroupPhaseReady}, } - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp-session", Namespace: testNamespace}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroup}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroup}, SessionStorage: tt.sessionStorage, }, } @@ -2092,28 +2092,28 @@ func TestEnsureVmcpConfigConfigMap_AuthServerIntegrationValidationError(t *testi upstreamIssuerURL = "https://upstream-idp.example.com" ) - testVmcp := &mcpv1alpha1.VirtualMCPServer{ + testVmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", Generation: 3, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: audience}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "test-oidc", Audience: audience}, }, - AuthServerConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + AuthServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: authServerIssuer, - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key-secret", Key: "key.pem"}, }, - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "corporate-idp", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: upstreamIssuerURL, ClientID: "upstream-client-id", }, @@ -2123,19 +2123,19 @@ func TestEnsureVmcpConfigConfigMap_AuthServerIntegrationValidationError(t *testi }, } - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, } - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: incomingIssuer, ClientID: clientID, }, @@ -2143,7 +2143,7 @@ func TestEnsureVmcpConfigConfigMap_AuthServerIntegrationValidationError(t *testi } scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) fakeClient := fake.NewClientBuilder(). @@ -2171,12 +2171,12 @@ func TestEnsureVmcpConfigConfigMap_AuthServerIntegrationValidationError(t *testi mockStatus.EXPECT().RemoveConditionsWithPrefix("BackendAuthConfig-", []string{}).Times(1) // ValidateAuthServerIntegration failure: issuer mismatch sets Failed phase and condition. - mockStatus.EXPECT().SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed).Times(1) + mockStatus.EXPECT().SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed).Times(1) mockStatus.EXPECT().SetMessage(gomock.Any()).Times(1).Do(func(message string) { assert.Contains(t, message, "invalid auth server integration") }) mockStatus.EXPECT().SetAuthServerConfigValidatedCondition( - mcpv1alpha1.ConditionReasonAuthServerConfigInvalid, + mcpv1beta1.ConditionReasonAuthServerConfigInvalid, gomock.Any(), metav1.ConditionFalse, ).Times(1) @@ -2308,7 +2308,7 @@ func TestBuildCABundlePathMap(t *testing.T) { tests := []struct { name string - entries []mcpv1alpha1.MCPServerEntry + entries []mcpv1beta1.MCPServerEntry typedWorkloads []workloads.TypedWorkload expectedMap map[string]string }{ @@ -2322,13 +2322,13 @@ func TestBuildCABundlePathMap(t *testing.T) { }, { name: "entry without caBundleRef is not in map", - entries: []mcpv1alpha1.MCPServerEntry{ + entries: []mcpv1beta1.MCPServerEntry{ { ObjectMeta: metav1.ObjectMeta{Name: "entry-no-ca", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -2339,14 +2339,14 @@ func TestBuildCABundlePathMap(t *testing.T) { }, { name: "entry with caBundleRef using default key", - entries: []mcpv1alpha1.MCPServerEntry{ + entries: []mcpv1beta1.MCPServerEntry{ { ObjectMeta: metav1.ObjectMeta{Name: "entry-with-ca", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - CABundleRef: &mcpv1alpha1.CABundleSource{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "ca-cm"}, }, @@ -2363,14 +2363,14 @@ func TestBuildCABundlePathMap(t *testing.T) { }, { name: "entry with caBundleRef using custom key", - entries: []mcpv1alpha1.MCPServerEntry{ + entries: []mcpv1beta1.MCPServerEntry{ { ObjectMeta: metav1.ObjectMeta{Name: "custom-entry", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - CABundleRef: &mcpv1alpha1.CABundleSource{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "ca-cm"}, Key: "custom-cert.pem", @@ -2388,14 +2388,14 @@ func TestBuildCABundlePathMap(t *testing.T) { }, { name: "mixed workloads only includes entries with caBundleRef", - entries: []mcpv1alpha1.MCPServerEntry{ + entries: []mcpv1beta1.MCPServerEntry{ { ObjectMeta: metav1.ObjectMeta{Name: "entry-with-ca", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - CABundleRef: &mcpv1alpha1.CABundleSource{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "ca-cm"}, }, @@ -2404,10 +2404,10 @@ func TestBuildCABundlePathMap(t *testing.T) { }, { ObjectMeta: metav1.ObjectMeta{Name: "entry-no-ca", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp2.example.com", Transport: "sse", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -2427,7 +2427,7 @@ func TestBuildCABundlePathMap(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) objs := make([]client.Object, 0, len(tt.entries)) diff --git a/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go b/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go index a73330772b..004a53e8fd 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_watch_test.go @@ -25,7 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -35,27 +35,27 @@ func TestMapMCPGroupToVirtualMCPServer(t *testing.T) { tests := []struct { name string - mcpGroup *mcpv1alpha1.MCPGroup - virtualMCPServers []mcpv1alpha1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + virtualMCPServers []mcpv1beta1.VirtualMCPServer expectedRequests int expectedNames []string }{ { name: "single VirtualMCPServer references MCPGroup", - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -64,20 +64,20 @@ func TestMapMCPGroupToVirtualMCPServer(t *testing.T) { }, { name: "multiple VirtualMCPServers reference MCPGroup", - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, { @@ -85,8 +85,8 @@ func TestMapMCPGroupToVirtualMCPServer(t *testing.T) { Name: "vmcp-2", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -95,20 +95,20 @@ func TestMapMCPGroupToVirtualMCPServer(t *testing.T) { }, { name: "no VirtualMCPServers reference MCPGroup", - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}, }, }, }, @@ -117,20 +117,20 @@ func TestMapMCPGroupToVirtualMCPServer(t *testing.T) { }, { name: "mixed VirtualMCPServers some reference MCPGroup", - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, { @@ -138,8 +138,8 @@ func TestMapMCPGroupToVirtualMCPServer(t *testing.T) { Name: "vmcp-2", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}, }, }, }, @@ -154,7 +154,7 @@ func TestMapMCPGroupToVirtualMCPServer(t *testing.T) { // Create scheme scheme := runtime.NewScheme() - err := mcpv1alpha1.AddToScheme(scheme) + err := mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) // Create objects slice @@ -198,7 +198,7 @@ func TestMapMCPGroupToVirtualMCPServer_InvalidObject(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - err := mcpv1alpha1.AddToScheme(scheme) + err := mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() @@ -208,7 +208,7 @@ func TestMapMCPGroupToVirtualMCPServer_InvalidObject(t *testing.T) { } // Pass wrong object type - wrongObj := &mcpv1alpha1.MCPServer{ + wrongObj := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", @@ -225,39 +225,39 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer - mcpGroups []mcpv1alpha1.MCPGroup - virtualMCPServers []mcpv1alpha1.VirtualMCPServer + mcpServer *mcpv1beta1.MCPServer + mcpGroups []mcpv1beta1.MCPGroup + virtualMCPServers []mcpv1beta1.VirtualMCPServer expectedRequests int expectedNames []string }{ { name: "MCPServer is member of MCPGroup referenced by VirtualMCPServer", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ + Status: mcpv1beta1.MCPGroupStatus{ Servers: []string{"test-server", "other-server"}, }, }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -266,31 +266,31 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { }, { name: "MCPServer is not member of any MCPGroup", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ + Status: mcpv1beta1.MCPGroupStatus{ Servers: []string{"other-server"}, }, }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -299,31 +299,31 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { }, { name: "MCPServer is member of MCPGroup but no VirtualMCPServers reference it", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ + Status: mcpv1beta1.MCPGroupStatus{ Servers: []string{"test-server"}, }, }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}, }, }, }, @@ -332,19 +332,19 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { }, { name: "MCPServer is member of multiple MCPGroups with multiple VirtualMCPServers", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "group-1", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ + Status: mcpv1beta1.MCPGroupStatus{ Servers: []string{"test-server"}, }, }, @@ -353,19 +353,19 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { Name: "group-2", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ + Status: mcpv1beta1.MCPGroupStatus{ Servers: []string{"test-server", "other-server"}, }, }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-1"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-1"}, }, }, { @@ -373,8 +373,8 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { Name: "vmcp-2", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-2"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-2"}, }, }, { @@ -382,8 +382,8 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { Name: "vmcp-3", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-3"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-3"}, }, }, }, @@ -398,7 +398,7 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { // Create scheme scheme := runtime.NewScheme() - err := mcpv1alpha1.AddToScheme(scheme) + err := mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) // Create objects slice @@ -415,7 +415,7 @@ func TestMapMCPServerToVirtualMCPServer(t *testing.T) { WithScheme(scheme). WithObjects(objs...). WithStatusSubresource( - &mcpv1alpha1.MCPGroup{}, + &mcpv1beta1.MCPGroup{}, ). Build() @@ -448,7 +448,7 @@ func TestMapMCPServerToVirtualMCPServer_InvalidObject(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - err := mcpv1alpha1.AddToScheme(scheme) + err := mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() @@ -458,7 +458,7 @@ func TestMapMCPServerToVirtualMCPServer_InvalidObject(t *testing.T) { } // Pass wrong object type - wrongObj := &mcpv1alpha1.MCPGroup{ + wrongObj := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", @@ -475,39 +475,39 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { tests := []struct { name string - mcpRemoteProxy *mcpv1alpha1.MCPRemoteProxy - mcpGroups []mcpv1alpha1.MCPGroup - virtualMCPServers []mcpv1alpha1.VirtualMCPServer + mcpRemoteProxy *mcpv1beta1.MCPRemoteProxy + mcpGroups []mcpv1beta1.MCPGroup + virtualMCPServers []mcpv1beta1.VirtualMCPServer expectedRequests int expectedNames []string }{ { name: "MCPRemoteProxy is member of MCPGroup referenced by VirtualMCPServer", - mcpRemoteProxy: &mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ + Status: mcpv1beta1.MCPGroupStatus{ RemoteProxies: []string{"test-proxy", "other-proxy"}, }, }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -516,31 +516,31 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { }, { name: "MCPRemoteProxy is not member of any MCPGroup", - mcpRemoteProxy: &mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ + Status: mcpv1beta1.MCPGroupStatus{ RemoteProxies: []string{"other-proxy"}, }, }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -549,31 +549,31 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { }, { name: "MCPRemoteProxy is member of MCPGroup but no VirtualMCPServers reference it", - mcpRemoteProxy: &mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ + Status: mcpv1beta1.MCPGroupStatus{ RemoteProxies: []string{"test-proxy"}, }, }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}, }, }, }, @@ -582,19 +582,19 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { }, { name: "MCPRemoteProxy is member of multiple MCPGroups with multiple VirtualMCPServers", - mcpRemoteProxy: &mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "group-1", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ + Status: mcpv1beta1.MCPGroupStatus{ RemoteProxies: []string{"test-proxy"}, }, }, @@ -603,19 +603,19 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { Name: "group-2", Namespace: "default", }, - Status: mcpv1alpha1.MCPGroupStatus{ + Status: mcpv1beta1.MCPGroupStatus{ RemoteProxies: []string{"test-proxy", "other-proxy"}, }, }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-1"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-1"}, }, }, { @@ -623,8 +623,8 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { Name: "vmcp-2", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-2"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-2"}, }, }, { @@ -632,8 +632,8 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { Name: "vmcp-3", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-3"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-3"}, }, }, }, @@ -648,7 +648,7 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { // Create scheme scheme := runtime.NewScheme() - err := mcpv1alpha1.AddToScheme(scheme) + err := mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) // Create objects slice @@ -665,7 +665,7 @@ func TestMapMCPRemoteProxyToVirtualMCPServer(t *testing.T) { WithScheme(scheme). WithObjects(objs...). WithStatusSubresource( - &mcpv1alpha1.MCPGroup{}, + &mcpv1beta1.MCPGroup{}, ). Build() @@ -698,7 +698,7 @@ func TestMapMCPRemoteProxyToVirtualMCPServer_InvalidObject(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - err := mcpv1alpha1.AddToScheme(scheme) + err := mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() @@ -708,7 +708,7 @@ func TestMapMCPRemoteProxyToVirtualMCPServer_InvalidObject(t *testing.T) { } // Pass wrong object type - wrongObj := &mcpv1alpha1.MCPGroup{ + wrongObj := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group", Namespace: "default", @@ -726,33 +726,33 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { tests := []struct { name string - authConfig *mcpv1alpha1.MCPExternalAuthConfig - virtualMCPServers []mcpv1alpha1.VirtualMCPServer - mcpGroups []mcpv1alpha1.MCPGroup - mcpServers []mcpv1alpha1.MCPServer - mcpRemoteProxies []mcpv1alpha1.MCPRemoteProxy + authConfig *mcpv1beta1.MCPExternalAuthConfig + virtualMCPServers []mcpv1beta1.VirtualMCPServer + mcpGroups []mcpv1beta1.MCPGroup + mcpServers []mcpv1beta1.MCPServer + mcpRemoteProxies []mcpv1beta1.MCPRemoteProxy expectedRequests int expectedNames []string }{ { name: "VirtualMCPServer references ExternalAuthConfig in default backend auth", - authConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ - Default: &mcpv1alpha1.BackendAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ + Default: &mcpv1beta1.BackendAuthConfig{ Type: "externalAuthConfigRef", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, @@ -765,24 +765,24 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { }, { name: "VirtualMCPServer references ExternalAuthConfig in per-backend auth", - authConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ "backend1": { Type: "externalAuthConfigRef", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, @@ -796,19 +796,19 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { }, { name: "VirtualMCPServer does not reference ExternalAuthConfig", - authConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{}, + Spec: mcpv1beta1.VirtualMCPServerSpec{}, }, }, expectedRequests: 0, @@ -816,23 +816,23 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { }, { name: "multiple VirtualMCPServers, only one references ExternalAuthConfig", - authConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ - Default: &mcpv1alpha1.BackendAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ + Default: &mcpv1beta1.BackendAuthConfig{ Type: "externalAuthConfigRef", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, @@ -844,7 +844,7 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { Name: "vmcp-2", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{}, + Spec: mcpv1beta1.VirtualMCPServerSpec{}, }, }, expectedRequests: 1, @@ -852,39 +852,39 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { }, { name: "no VirtualMCPServers in namespace", - authConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{}, + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{}, expectedRequests: 0, expectedNames: []string{}, }, { name: "VirtualMCPServer with discovered mode - MCPServer references auth config", - authConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-discovered", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", @@ -892,15 +892,15 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { }, }, }, - mcpServers: []mcpv1alpha1.MCPServer{ + mcpServers: []mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "backend-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, @@ -911,27 +911,27 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { }, { name: "VirtualMCPServer with discovered mode - no MCPServer references auth config", - authConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-discovered", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", @@ -939,15 +939,15 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { }, }, }, - mcpServers: []mcpv1alpha1.MCPServer{ + mcpServers: []mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "backend-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "other-auth", }, }, @@ -958,27 +958,27 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { }, { name: "VirtualMCPServer with discovered mode - MCPRemoteProxy references auth config", - authConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-discovered", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", @@ -986,15 +986,15 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { }, }, }, - mcpRemoteProxies: []mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxies: []mcpv1beta1.MCPRemoteProxy{ { ObjectMeta: metav1.ObjectMeta{ Name: "backend-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, @@ -1005,27 +1005,27 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { }, { name: "VirtualMCPServer with discovered mode - no MCPRemoteProxy references auth config", - authConfig: &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-discovered", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", @@ -1033,15 +1033,15 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { }, }, }, - mcpRemoteProxies: []mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxies: []mcpv1beta1.MCPRemoteProxy{ { ObjectMeta: metav1.ObjectMeta{ Name: "backend-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "other-auth", }, }, @@ -1058,7 +1058,7 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { // Create scheme scheme := runtime.NewScheme() - err := mcpv1alpha1.AddToScheme(scheme) + err := mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) // Create objects slice @@ -1080,16 +1080,16 @@ func TestMapExternalAuthConfigToVirtualMCPServer(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + WithIndex(&mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) name := mcpServer.Spec.GroupRef.GetName() if name == "" { return nil } return []string{name} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) name := mcpRemoteProxy.Spec.GroupRef.GetName() if name == "" { return nil @@ -1128,26 +1128,26 @@ func TestMapToolConfigToVirtualMCPServer(t *testing.T) { tests := []struct { name string - toolConfig *mcpv1alpha1.MCPToolConfig - virtualMCPServers []mcpv1alpha1.VirtualMCPServer + toolConfig *mcpv1beta1.MCPToolConfig + virtualMCPServers []mcpv1beta1.VirtualMCPServer expectedRequests int expectedNames []string }{ { name: "VirtualMCPServer references ToolConfig in Aggregation.Tools", - toolConfig: &mcpv1alpha1.MCPToolConfig{ + toolConfig: &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-tool-config", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{ Aggregation: &vmcpconfig.AggregationConfig{ Tools: []*vmcpconfig.WorkloadToolConfig{ @@ -1167,19 +1167,19 @@ func TestMapToolConfigToVirtualMCPServer(t *testing.T) { }, { name: "no VirtualMCPServers reference ToolConfig", - toolConfig: &mcpv1alpha1.MCPToolConfig{ + toolConfig: &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-tool-config", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{}, + Spec: mcpv1beta1.VirtualMCPServerSpec{}, }, }, expectedRequests: 0, @@ -1187,19 +1187,19 @@ func TestMapToolConfigToVirtualMCPServer(t *testing.T) { }, { name: "multiple VirtualMCPServers reference same ToolConfig", - toolConfig: &mcpv1alpha1.MCPToolConfig{ + toolConfig: &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-tool-config", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{ Aggregation: &vmcpconfig.AggregationConfig{ Tools: []*vmcpconfig.WorkloadToolConfig{ @@ -1218,7 +1218,7 @@ func TestMapToolConfigToVirtualMCPServer(t *testing.T) { Name: "vmcp-2", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{ Aggregation: &vmcpconfig.AggregationConfig{ Tools: []*vmcpconfig.WorkloadToolConfig{ @@ -1249,7 +1249,7 @@ func TestMapToolConfigToVirtualMCPServer(t *testing.T) { // Create scheme scheme := runtime.NewScheme() - err := mcpv1alpha1.AddToScheme(scheme) + err := mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) // Create objects slice @@ -1294,14 +1294,14 @@ func TestVmcpReferencesToolConfig(t *testing.T) { tests := []struct { name string - vmcp *mcpv1alpha1.VirtualMCPServer + vmcp *mcpv1beta1.VirtualMCPServer configName string expected bool }{ { name: "VirtualMCPServer references ToolConfig", - vmcp: &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + vmcp: &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{ Aggregation: &vmcpconfig.AggregationConfig{ Tools: []*vmcpconfig.WorkloadToolConfig{ @@ -1320,8 +1320,8 @@ func TestVmcpReferencesToolConfig(t *testing.T) { }, { name: "VirtualMCPServer does not reference ToolConfig", - vmcp: &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + vmcp: &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{ Aggregation: &vmcpconfig.AggregationConfig{ Tools: []*vmcpconfig.WorkloadToolConfig{ @@ -1340,16 +1340,16 @@ func TestVmcpReferencesToolConfig(t *testing.T) { }, { name: "VirtualMCPServer has no Aggregation", - vmcp: &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{}, + vmcp: &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{}, }, configName: "test-config", expected: false, }, { name: "VirtualMCPServer references ToolConfig among multiple tools", - vmcp: &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + vmcp: &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{ Aggregation: &vmcpconfig.AggregationConfig{ Tools: []*vmcpconfig.WorkloadToolConfig{ @@ -1395,21 +1395,21 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { tests := []struct { name string - vmcp *mcpv1alpha1.VirtualMCPServer - mcpGroups []mcpv1alpha1.MCPGroup - mcpServers []mcpv1alpha1.MCPServer - mcpRemoteProxies []mcpv1alpha1.MCPRemoteProxy + vmcp *mcpv1beta1.VirtualMCPServer + mcpGroups []mcpv1beta1.MCPGroup + mcpServers []mcpv1beta1.MCPServer + mcpRemoteProxies []mcpv1beta1.MCPRemoteProxy authConfigName string expected bool }{ { name: "VirtualMCPServer references ExternalAuthConfig in default backend auth", - vmcp: &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ - Default: &mcpv1alpha1.BackendAuthConfig{ + vmcp: &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ + Default: &mcpv1beta1.BackendAuthConfig{ Type: "externalAuthConfigRef", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, @@ -1421,13 +1421,13 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, { name: "VirtualMCPServer references ExternalAuthConfig in per-backend auth", - vmcp: &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + vmcp: &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ "backend1": { Type: "externalAuthConfigRef", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, @@ -1440,16 +1440,16 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, { name: "VirtualMCPServer does not reference ExternalAuthConfig", - vmcp: &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{}, + vmcp: &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{}, }, authConfigName: "test-auth", expected: false, }, { name: "VirtualMCPServer has no OutgoingAuth", - vmcp: &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + vmcp: &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ OutgoingAuth: nil, }, }, @@ -1458,12 +1458,12 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, { name: "VirtualMCPServer references different ExternalAuthConfig", - vmcp: &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ - Default: &mcpv1alpha1.BackendAuthConfig{ + vmcp: &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ + Default: &mcpv1beta1.BackendAuthConfig{ Type: "externalAuthConfigRef", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "other-auth", }, }, @@ -1475,19 +1475,19 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, { name: "VirtualMCPServer references ExternalAuthConfig in multiple backends", - vmcp: &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + vmcp: &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ "backend1": { Type: "externalAuthConfigRef", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "other-auth", }, }, "backend2": { Type: "externalAuthConfigRef", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, @@ -1503,19 +1503,19 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, { name: "VirtualMCPServer with discovered mode - MCPServer references auth config", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-discovered", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", @@ -1523,15 +1523,15 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, }, }, - mcpServers: []mcpv1alpha1.MCPServer{ + mcpServers: []mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "backend-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, @@ -1542,19 +1542,19 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, { name: "VirtualMCPServer with discovered mode - no MCPServer references auth config", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-discovered", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", @@ -1562,15 +1562,15 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, }, }, - mcpServers: []mcpv1alpha1.MCPServer{ + mcpServers: []mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "backend-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "other-auth", }, }, @@ -1581,14 +1581,14 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, { name: "VirtualMCPServer with discovered mode - MCPGroup does not exist", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-discovered", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "nonexistent-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "nonexistent-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, @@ -1598,19 +1598,19 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, { name: "VirtualMCPServer with discovered mode - multiple MCPServers, one references auth config", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-discovered", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", @@ -1618,15 +1618,15 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, }, }, - mcpServers: []mcpv1alpha1.MCPServer{ + mcpServers: []mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "backend-server-1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "other-auth", }, }, @@ -1636,9 +1636,9 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Name: "backend-server-2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, @@ -1648,8 +1648,8 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Name: "backend-server-3", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -1658,19 +1658,19 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, { name: "VirtualMCPServer with discovered mode - MCPRemoteProxy references auth config", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-discovered", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", @@ -1678,15 +1678,15 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, }, }, - mcpRemoteProxies: []mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxies: []mcpv1beta1.MCPRemoteProxy{ { ObjectMeta: metav1.ObjectMeta{ Name: "backend-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, @@ -1697,19 +1697,19 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, { name: "VirtualMCPServer with discovered mode - MCPRemoteProxy does not reference auth config", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-discovered", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, }, - mcpGroups: []mcpv1alpha1.MCPGroup{ + mcpGroups: []mcpv1beta1.MCPGroup{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-group", @@ -1717,15 +1717,15 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { }, }, }, - mcpRemoteProxies: []mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxies: []mcpv1beta1.MCPRemoteProxy{ { ObjectMeta: metav1.ObjectMeta{ Name: "backend-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "other-auth", }, }, @@ -1742,7 +1742,7 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { // Create scheme scheme := runtime.NewScheme() - err := mcpv1alpha1.AddToScheme(scheme) + err := mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) // Create objects slice @@ -1764,16 +1764,16 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithIndex(&mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + WithIndex(&mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) name := mcpServer.Spec.GroupRef.GetName() if name == "" { return nil } return []string{name} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) name := mcpRemoteProxy.Spec.GroupRef.GetName() if name == "" { return nil @@ -1798,28 +1798,28 @@ func TestMapEmbeddingServerToVirtualMCPServer(t *testing.T) { tests := []struct { name string - embeddingServer *mcpv1alpha1.EmbeddingServer - virtualMCPServers []mcpv1alpha1.VirtualMCPServer + embeddingServer *mcpv1beta1.EmbeddingServer + virtualMCPServers []mcpv1beta1.VirtualMCPServer expectedRequests int expectedNames []string }{ { name: "single VirtualMCPServer references EmbeddingServer", - embeddingServer: &mcpv1alpha1.EmbeddingServer{ + embeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "shared-embedding", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{Name: "shared-embedding"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{Name: "shared-embedding"}, }, }, }, @@ -1828,21 +1828,21 @@ func TestMapEmbeddingServerToVirtualMCPServer(t *testing.T) { }, { name: "multiple VirtualMCPServers share EmbeddingServer", - embeddingServer: &mcpv1alpha1.EmbeddingServer{ + embeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "shared-embedding", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{Name: "shared-embedding"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{Name: "shared-embedding"}, }, }, { @@ -1850,9 +1850,9 @@ func TestMapEmbeddingServerToVirtualMCPServer(t *testing.T) { Name: "vmcp-2", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{Name: "shared-embedding"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{Name: "shared-embedding"}, }, }, }, @@ -1861,21 +1861,21 @@ func TestMapEmbeddingServerToVirtualMCPServer(t *testing.T) { }, { name: "no VirtualMCPServers reference EmbeddingServer", - embeddingServer: &mcpv1alpha1.EmbeddingServer{ + embeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "shared-embedding", Namespace: "default", }, }, - virtualMCPServers: []mcpv1alpha1.VirtualMCPServer{ + virtualMCPServers: []mcpv1beta1.VirtualMCPServer{ { ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{Name: "other-embedding"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{Name: "other-embedding"}, }, }, }, @@ -1890,7 +1890,7 @@ func TestMapEmbeddingServerToVirtualMCPServer(t *testing.T) { // Create scheme scheme := runtime.NewScheme() - err := mcpv1alpha1.AddToScheme(scheme) + err := mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) // Create objects slice @@ -1934,7 +1934,7 @@ func TestMapEmbeddingServerToVirtualMCPServer_InvalidObject(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - err := mcpv1alpha1.AddToScheme(scheme) + err := mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() @@ -1944,7 +1944,7 @@ func TestMapEmbeddingServerToVirtualMCPServer_InvalidObject(t *testing.T) { } // Pass wrong object type - wrongObj := &mcpv1alpha1.MCPServer{ + wrongObj := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", diff --git a/cmd/thv-operator/main.go b/cmd/thv-operator/main.go index 1068eb604b..85073a385b 100644 --- a/cmd/thv-operator/main.go +++ b/cmd/thv-operator/main.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" // Import for webhook mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/controllers" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/pkg/operator/telemetry" @@ -55,6 +56,7 @@ var controllerDependencies = map[string][]string{ func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(mcpv1alpha1.AddToScheme(scheme)) + utilruntime.Must(mcpv1beta1.AddToScheme(scheme)) //+kubebuilder:scaffold:scheme } @@ -194,10 +196,10 @@ func setupGroupRefFieldIndexes(mgr ctrl.Manager) error { // MCPServer.Spec.GroupRef if err := mgr.GetFieldIndexer().IndexField( context.Background(), - &mcpv1alpha1.MCPServer{}, + &mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + mcpServer := obj.(*mcpv1beta1.MCPServer) name := mcpServer.Spec.GroupRef.GetName() if name == "" { return nil @@ -211,10 +213,10 @@ func setupGroupRefFieldIndexes(mgr ctrl.Manager) error { // MCPRemoteProxy.Spec.GroupRef if err := mgr.GetFieldIndexer().IndexField( context.Background(), - &mcpv1alpha1.MCPRemoteProxy{}, + &mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) name := mcpRemoteProxy.Spec.GroupRef.GetName() if name == "" { return nil @@ -228,10 +230,10 @@ func setupGroupRefFieldIndexes(mgr ctrl.Manager) error { // MCPServerEntry.Spec.GroupRef if err := mgr.GetFieldIndexer().IndexField( context.Background(), - &mcpv1alpha1.MCPServerEntry{}, + &mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) name := mcpServerEntry.Spec.GroupRef.GetName() if name == "" { return nil diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go index a8e86fb412..486b7cb4a2 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver.go @@ -13,7 +13,7 @@ import ( k8sptr "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" "github.com/stacklok/toolhive/pkg/authserver" authrunner "github.com/stacklok/toolhive/pkg/authserver/runner" @@ -68,7 +68,7 @@ const ( // buildUpstreamRunConfig (runtime config) MUST use these bindings so the // env var names stay consistent. type upstreamSecretBinding struct { - Provider *mcpv1alpha1.UpstreamProviderConfig + Provider *mcpv1beta1.UpstreamProviderConfig EnvVarName string } @@ -77,7 +77,7 @@ type upstreamSecretBinding struct { // provider's Name field (uppercased, hyphens replaced with underscores) to // keep bindings stable across provider reordering in the CRD. func buildUpstreamSecretBindings( - providers []mcpv1alpha1.UpstreamProviderConfig, + providers []mcpv1beta1.UpstreamProviderConfig, ) []upstreamSecretBinding { bindings := make([]upstreamSecretBinding, len(providers)) for i := range providers { @@ -94,8 +94,8 @@ func buildUpstreamSecretBindings( // embedded auth server volume/env generation, or empty string if neither ref applies. // AuthServerRef takes precedence; externalAuthConfigRef is used as a fallback. func EmbeddedAuthServerConfigName( - extAuthRef *mcpv1alpha1.ExternalAuthConfigRef, - authServerRef *mcpv1alpha1.AuthServerRef, + extAuthRef *mcpv1beta1.ExternalAuthConfigRef, + authServerRef *mcpv1beta1.AuthServerRef, ) string { if authServerRef != nil { return authServerRef.Name @@ -124,7 +124,7 @@ func GenerateAuthServerConfigByName( return nil, nil, nil, fmt.Errorf("failed to get MCPExternalAuthConfig: %w", err) } - if externalAuthConfig.Spec.Type != mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer { + if externalAuthConfig.Spec.Type != mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer { return nil, nil, nil, nil } @@ -148,7 +148,7 @@ func GenerateAuthServerConfigByName( // // Returns nil slices if authConfig is nil. func GenerateAuthServerVolumes( - authConfig *mcpv1alpha1.EmbeddedAuthServerConfig, + authConfig *mcpv1beta1.EmbeddedAuthServerConfig, ) ([]corev1.Volume, []corev1.VolumeMount) { if authConfig == nil { return nil, nil @@ -272,7 +272,7 @@ func GenerateAuthServerVolumes( // // Returns nil slice if authConfig is nil or if no client secrets are configured. func GenerateAuthServerEnvVars( - authConfig *mcpv1alpha1.EmbeddedAuthServerConfig, + authConfig *mcpv1beta1.EmbeddedAuthServerConfig, ) []corev1.EnvVar { if authConfig == nil { return nil @@ -283,14 +283,14 @@ func GenerateAuthServerEnvVars( // Generate env vars for upstream client secrets using shared bindings for _, b := range buildUpstreamSecretBindings(authConfig.UpstreamProviders) { // Extract client secret reference based on provider type - var clientSecretRef *mcpv1alpha1.SecretKeyRef + var clientSecretRef *mcpv1beta1.SecretKeyRef switch b.Provider.Type { - case mcpv1alpha1.UpstreamProviderTypeOIDC: + case mcpv1beta1.UpstreamProviderTypeOIDC: if b.Provider.OIDCConfig != nil { clientSecretRef = b.Provider.OIDCConfig.ClientSecretRef } - case mcpv1alpha1.UpstreamProviderTypeOAuth2: + case mcpv1beta1.UpstreamProviderTypeOAuth2: if b.Provider.OAuth2Config != nil { clientSecretRef = b.Provider.OAuth2Config.ClientSecretRef } @@ -313,7 +313,7 @@ func GenerateAuthServerEnvVars( // Generate env vars for Redis ACL credentials if configured if authConfig.Storage != nil && - authConfig.Storage.Type == mcpv1alpha1.AuthServerStorageTypeRedis && + authConfig.Storage.Type == mcpv1beta1.AuthServerStorageTypeRedis && authConfig.Storage.Redis != nil && authConfig.Storage.Redis.ACLUserConfig != nil { aclConfig := authConfig.Storage.Redis.ACLUserConfig @@ -371,7 +371,7 @@ func AddEmbeddedAuthServerConfigOptions( c client.Client, namespace string, mcpServerName string, - externalAuthConfigRef *mcpv1alpha1.ExternalAuthConfigRef, + externalAuthConfigRef *mcpv1beta1.ExternalAuthConfigRef, oidcConfig *oidc.OIDCConfig, options *[]runner.RunConfigBuilderOption, ) error { @@ -386,7 +386,7 @@ func AddEmbeddedAuthServerConfigOptions( } // Only process embeddedAuthServer type - if externalAuthConfig.Spec.Type != mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer { + if externalAuthConfig.Spec.Type != mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer { return nil } @@ -463,7 +463,7 @@ func validateOIDCConfigForEmbeddedAuthServer(oidcConfig *oidc.OIDCConfig) error func BuildAuthServerRunConfig( namespace string, name string, - authConfig *mcpv1alpha1.EmbeddedAuthServerConfig, + authConfig *mcpv1beta1.EmbeddedAuthServerConfig, allowedAudiences []string, scopesSupported []string, resourceURL string, @@ -529,13 +529,13 @@ func BuildAuthServerRunConfig( func buildStorageRunConfig( namespace string, mcpServerName string, - authConfig *mcpv1alpha1.EmbeddedAuthServerConfig, + authConfig *mcpv1beta1.EmbeddedAuthServerConfig, ) (*storage.RunConfig, error) { - if authConfig.Storage == nil || authConfig.Storage.Type == mcpv1alpha1.AuthServerStorageTypeMemory { + if authConfig.Storage == nil || authConfig.Storage.Type == mcpv1beta1.AuthServerStorageTypeMemory { return nil, nil } - if authConfig.Storage.Type != mcpv1alpha1.AuthServerStorageTypeRedis { + if authConfig.Storage.Type != mcpv1beta1.AuthServerStorageTypeRedis { return nil, fmt.Errorf("unsupported storage type: %s", authConfig.Storage.Type) } @@ -588,7 +588,7 @@ func buildStorageRunConfig( // convertRedisTLSConfig converts CRD RedisTLSConfig to RunConfig. // isSentinel determines which mount path to use for the CA cert file. -func convertRedisTLSConfig(cfg *mcpv1alpha1.RedisTLSConfig, isSentinel bool) *storage.RedisTLSRunConfig { +func convertRedisTLSConfig(cfg *mcpv1beta1.RedisTLSConfig, isSentinel bool) *storage.RedisTLSRunConfig { if cfg == nil { return nil } @@ -607,7 +607,7 @@ func convertRedisTLSConfig(cfg *mcpv1alpha1.RedisTLSConfig, isSentinel bool) *st // resolveSentinelAddrs resolves Sentinel addresses from static config or Kubernetes Service DNS. func resolveSentinelAddrs( - sentinelConfig *mcpv1alpha1.RedisSentinelConfig, + sentinelConfig *mcpv1beta1.RedisSentinelConfig, defaultNamespace string, ) ([]string, error) { // If static addresses are provided, use them directly @@ -649,7 +649,7 @@ func defaultRedirectURI(resourceURL string) string { // and runtime config in sync. When a provider's RedirectURI is empty, it is // defaulted to {resourceURL}/oauth/callback. func buildUpstreamRunConfig( - provider *mcpv1alpha1.UpstreamProviderConfig, + provider *mcpv1beta1.UpstreamProviderConfig, envVarName string, resourceURL string, ) *authserver.UpstreamRunConfig { @@ -659,7 +659,7 @@ func buildUpstreamRunConfig( } switch provider.Type { - case mcpv1alpha1.UpstreamProviderTypeOIDC: + case mcpv1beta1.UpstreamProviderTypeOIDC: if provider.OIDCConfig != nil { redirectURI := provider.OIDCConfig.RedirectURI if redirectURI == "" && resourceURL != "" { @@ -680,7 +680,7 @@ func buildUpstreamRunConfig( config.OIDCConfig.UserInfoOverride = buildUserInfoRunConfig(provider.OIDCConfig.UserInfoOverride) } } - case mcpv1alpha1.UpstreamProviderTypeOAuth2: + case mcpv1beta1.UpstreamProviderTypeOAuth2: if provider.OAuth2Config != nil { redirectURI := provider.OAuth2Config.RedirectURI if redirectURI == "" && resourceURL != "" { @@ -718,7 +718,7 @@ func buildUpstreamRunConfig( // buildUserInfoRunConfig converts CRD UserInfoConfig to authserver.UserInfoRunConfig. func buildUserInfoRunConfig( - userInfo *mcpv1alpha1.UserInfoConfig, + userInfo *mcpv1beta1.UserInfoConfig, ) *authserver.UserInfoRunConfig { config := &authserver.UserInfoRunConfig{ EndpointURL: userInfo.EndpointURL, @@ -745,8 +745,8 @@ func ValidateAndAddAuthServerRefOptions( c client.Client, namespace string, mcpServerName string, - authServerRef *mcpv1alpha1.AuthServerRef, - externalAuthConfigRef *mcpv1alpha1.ExternalAuthConfigRef, + authServerRef *mcpv1beta1.AuthServerRef, + externalAuthConfigRef *mcpv1beta1.ExternalAuthConfigRef, oidcConfig *oidc.OIDCConfig, options *[]runner.RunConfigBuilderOption, ) error { @@ -759,7 +759,7 @@ func ValidateAndAddAuthServerRefOptions( return fmt.Errorf("failed to fetch externalAuthConfigRef for conflict validation: %w", err) } // Not found - skip conflict check, will be caught by AddExternalAuthConfigOptions - } else if extConfig.Spec.Type == mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer { + } else if extConfig.Spec.Type == mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer { return fmt.Errorf( "conflict: both authServerRef and externalAuthConfigRef reference an embedded auth server; " + "use authServerRef for the embedded auth server and externalAuthConfigRef for outgoing auth only", @@ -781,7 +781,7 @@ func AddAuthServerRefOptions( c client.Client, namespace string, mcpServerName string, - authServerRef *mcpv1alpha1.AuthServerRef, + authServerRef *mcpv1beta1.AuthServerRef, oidcConfig *oidc.OIDCConfig, options *[]runner.RunConfigBuilderOption, ) error { @@ -801,10 +801,10 @@ func AddAuthServerRefOptions( } // Validate the type is embeddedAuthServer - if externalAuthConfig.Spec.Type != mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer { + if externalAuthConfig.Spec.Type != mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer { return fmt.Errorf( "authServerRef must reference a MCPExternalAuthConfig with type %q, got %q", - mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, externalAuthConfig.Spec.Type, + mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, externalAuthConfig.Spec.Type, ) } diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_test.go index 310be8ff1e..3c3f1ac443 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" "github.com/stacklok/toolhive/pkg/authserver" authrunner "github.com/stacklok/toolhive/pkg/authserver/runner" @@ -30,7 +30,7 @@ func TestGenerateAuthServerVolumes(t *testing.T) { tests := []struct { name string - authConfig *mcpv1alpha1.EmbeddedAuthServerConfig + authConfig *mcpv1beta1.EmbeddedAuthServerConfig wantVolumes int wantMounts int wantSigningKeys int @@ -46,12 +46,12 @@ func TestGenerateAuthServerVolumes(t *testing.T) { }, { name: "single signing key and single HMAC secret", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key-secret", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -64,14 +64,14 @@ func TestGenerateAuthServerVolumes(t *testing.T) { }, { name: "multiple signing keys for rotation", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key-1", Key: "private.pem"}, {Name: "signing-key-2", Key: "private.pem"}, {Name: "signing-key-3", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -84,12 +84,12 @@ func TestGenerateAuthServerVolumes(t *testing.T) { }, { name: "multiple HMAC secrets for rotation", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret-1", Key: "hmac"}, {Name: "hmac-secret-2", Key: "hmac"}, }, @@ -103,10 +103,10 @@ func TestGenerateAuthServerVolumes(t *testing.T) { }, { name: "empty signing keys list", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{}, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{}, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -117,12 +117,12 @@ func TestGenerateAuthServerVolumes(t *testing.T) { }, { name: "empty HMAC secrets list", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{}, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{}, }, wantVolumes: 1, // 1 signing key + 0 HMAC wantMounts: 1, @@ -193,13 +193,13 @@ func TestGenerateAuthServerVolumes(t *testing.T) { func TestGenerateAuthServerVolumes_RedisTLS(t *testing.T) { t.Parallel() - baseAuthConfig := func(storageCfg *mcpv1alpha1.AuthServerStorageConfig) *mcpv1alpha1.EmbeddedAuthServerConfig { - return &mcpv1alpha1.EmbeddedAuthServerConfig{ + baseAuthConfig := func(storageCfg *mcpv1beta1.AuthServerStorageConfig) *mcpv1beta1.EmbeddedAuthServerConfig { + return &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, Storage: storageCfg, @@ -208,7 +208,7 @@ func TestGenerateAuthServerVolumes_RedisTLS(t *testing.T) { tests := []struct { name string - authConfig *mcpv1alpha1.EmbeddedAuthServerConfig + authConfig *mcpv1beta1.EmbeddedAuthServerConfig wantTLSVolumes int wantTLSMounts int wantMasterVol bool @@ -216,11 +216,11 @@ func TestGenerateAuthServerVolumes_RedisTLS(t *testing.T) { }{ { name: "TLS enabled with CA cert creates volume", - authConfig: baseAuthConfig(&mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ - TLS: &mcpv1alpha1.RedisTLSConfig{ - CACertSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "redis-ca", Key: "ca.crt"}, + authConfig: baseAuthConfig(&mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ + TLS: &mcpv1beta1.RedisTLSConfig{ + CACertSecretRef: &mcpv1beta1.SecretKeyRef{Name: "redis-ca", Key: "ca.crt"}, }, }, }), @@ -230,9 +230,9 @@ func TestGenerateAuthServerVolumes_RedisTLS(t *testing.T) { }, { name: "nil TLS produces no TLS volumes", - authConfig: baseAuthConfig(&mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ + authConfig: baseAuthConfig(&mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ TLS: nil, }, }), @@ -241,10 +241,10 @@ func TestGenerateAuthServerVolumes_RedisTLS(t *testing.T) { }, { name: "TLS enabled without CA cert does NOT create volume", - authConfig: baseAuthConfig(&mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ - TLS: &mcpv1alpha1.RedisTLSConfig{}, + authConfig: baseAuthConfig(&mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ + TLS: &mcpv1beta1.RedisTLSConfig{}, }, }), wantTLSVolumes: 0, @@ -252,14 +252,14 @@ func TestGenerateAuthServerVolumes_RedisTLS(t *testing.T) { }, { name: "both master and sentinel TLS with CA certs create separate volumes", - authConfig: baseAuthConfig(&mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ - TLS: &mcpv1alpha1.RedisTLSConfig{ - CACertSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "master-ca", Key: "ca.crt"}, + authConfig: baseAuthConfig(&mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ + TLS: &mcpv1beta1.RedisTLSConfig{ + CACertSecretRef: &mcpv1beta1.SecretKeyRef{Name: "master-ca", Key: "ca.crt"}, }, - SentinelTLS: &mcpv1alpha1.RedisTLSConfig{ - CACertSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "sentinel-ca", Key: "ca.crt"}, + SentinelTLS: &mcpv1beta1.RedisTLSConfig{ + CACertSecretRef: &mcpv1beta1.SecretKeyRef{Name: "sentinel-ca", Key: "ca.crt"}, }, }, }), @@ -270,12 +270,12 @@ func TestGenerateAuthServerVolumes_RedisTLS(t *testing.T) { }, { name: "sentinel TLS only, master plaintext", - authConfig: baseAuthConfig(&mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ + authConfig: baseAuthConfig(&mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ TLS: nil, - SentinelTLS: &mcpv1alpha1.RedisTLSConfig{ - CACertSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "sentinel-ca", Key: "ca.crt"}, + SentinelTLS: &mcpv1beta1.RedisTLSConfig{ + CACertSecretRef: &mcpv1beta1.SecretKeyRef{Name: "sentinel-ca", Key: "ca.crt"}, }, }, }), @@ -344,7 +344,7 @@ func TestGenerateAuthServerEnvVars(t *testing.T) { tests := []struct { name string - authConfig *mcpv1alpha1.EmbeddedAuthServerConfig + authConfig *mcpv1beta1.EmbeddedAuthServerConfig wantEnvNames []string wantSecretNames []string // parallel to wantEnvNames; asserts SecretKeyRef.Name }{ @@ -355,25 +355,25 @@ func TestGenerateAuthServerEnvVars(t *testing.T) { }, { name: "no upstream providers returns empty slice", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{}, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{}, }, wantEnvNames: nil, }, { name: "OIDC provider with client secret ref", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "okta", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://okta.example.com", ClientID: "client-id", RedirectURI: "https://auth.example.com/callback", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "oidc-client-secret", Key: "client-secret", }, @@ -385,13 +385,13 @@ func TestGenerateAuthServerEnvVars(t *testing.T) { }, { name: "OIDC provider without client secret ref (public client)", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "okta", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://okta.example.com", ClientID: "client-id", RedirectURI: "https://auth.example.com/callback", @@ -404,18 +404,18 @@ func TestGenerateAuthServerEnvVars(t *testing.T) { }, { name: "OAuth2 provider with client secret ref", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "github", - Type: mcpv1alpha1.UpstreamProviderTypeOAuth2, - OAuth2Config: &mcpv1alpha1.OAuth2UpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{ AuthorizationEndpoint: "https://github.com/login/oauth/authorize", TokenEndpoint: "https://github.com/login/oauth/access_token", ClientID: "client-id", RedirectURI: "https://auth.example.com/callback", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "github-client-secret", Key: "client-secret", }, @@ -427,13 +427,13 @@ func TestGenerateAuthServerEnvVars(t *testing.T) { }, { name: "OAuth2 provider without client secret ref", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "github", - Type: mcpv1alpha1.UpstreamProviderTypeOAuth2, - OAuth2Config: &mcpv1alpha1.OAuth2UpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{ AuthorizationEndpoint: "https://github.com/login/oauth/authorize", TokenEndpoint: "https://github.com/login/oauth/access_token", ClientID: "client-id", @@ -447,12 +447,12 @@ func TestGenerateAuthServerEnvVars(t *testing.T) { }, { name: "upstream provider with nil OIDCConfig", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "test", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, + Type: mcpv1beta1.UpstreamProviderTypeOIDC, OIDCConfig: nil, // Nil config }, }, @@ -461,16 +461,16 @@ func TestGenerateAuthServerEnvVars(t *testing.T) { }, { name: "multiple upstream providers with client secrets get indexed env vars", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "okta", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://okta.example.com", ClientID: "client-id-0", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "okta-secret", Key: "client-secret", }, @@ -478,12 +478,12 @@ func TestGenerateAuthServerEnvVars(t *testing.T) { }, { Name: "github", - Type: mcpv1alpha1.UpstreamProviderTypeOAuth2, - OAuth2Config: &mcpv1alpha1.OAuth2UpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{ AuthorizationEndpoint: "https://github.com/login/oauth/authorize", TokenEndpoint: "https://github.com/login/oauth/access_token", ClientID: "client-id-1", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "github-secret", Key: "client-secret", }, @@ -527,13 +527,13 @@ func TestGenerateAuthServerConfigByName(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - err := mcpv1alpha1.AddToScheme(scheme) + err := mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) tests := []struct { name string configName string - externalAuthCfg *mcpv1alpha1.MCPExternalAuthConfig + externalAuthCfg *mcpv1beta1.MCPExternalAuthConfig wantVolumes bool wantMounts bool wantEnvVars bool @@ -543,14 +543,14 @@ func TestGenerateAuthServerConfigByName(t *testing.T) { { name: "non-embeddedAuthServer type returns empty slices", configName: "token-exchange-config", - externalAuthCfg: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthCfg: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "token-exchange-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://token.example.com/exchange", Audience: "my-audience", }, @@ -564,30 +564,30 @@ func TestGenerateAuthServerConfigByName(t *testing.T) { { name: "embeddedAuthServer type with valid config", configName: "embedded-auth-config", - externalAuthCfg: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthCfg: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "embedded-auth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "okta", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://okta.example.com", ClientID: "client-id", RedirectURI: "https://auth.example.com/callback", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "oidc-client-secret", Key: "client-secret", }, @@ -605,13 +605,13 @@ func TestGenerateAuthServerConfigByName(t *testing.T) { { name: "embeddedAuthServer type with nil embedded config", configName: "bad-auth-config", - externalAuthCfg: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthCfg: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "bad-auth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, EmbeddedAuthServer: nil, // Missing embedded config }, }, @@ -694,7 +694,7 @@ func TestBuildAuthServerRunConfig(t *testing.T) { tests := []struct { name string - authConfig *mcpv1alpha1.EmbeddedAuthServerConfig + authConfig *mcpv1beta1.EmbeddedAuthServerConfig allowedAudiences []string scopesSupported []string resourceURL string @@ -702,12 +702,12 @@ func TestBuildAuthServerRunConfig(t *testing.T) { }{ { name: "basic config with allowed audiences and scopes from OIDC config", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -728,14 +728,14 @@ func TestBuildAuthServerRunConfig(t *testing.T) { }, { name: "multiple signing keys for rotation", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key-1", Key: "private.pem"}, {Name: "signing-key-2", Key: "private.pem"}, {Name: "signing-key-3", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -752,15 +752,15 @@ func TestBuildAuthServerRunConfig(t *testing.T) { }, { name: "with token lifespans", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, - TokenLifespans: &mcpv1alpha1.TokenLifespanConfig{ + TokenLifespans: &mcpv1beta1.TokenLifespanConfig{ AccessTokenLifespan: "30m", RefreshTokenLifespan: "168h", AuthCodeLifespan: "5m", @@ -779,19 +779,19 @@ func TestBuildAuthServerRunConfig(t *testing.T) { { name: "with OIDC upstream provider", resourceURL: defaultResourceURL, - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "okta", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://okta.example.com", ClientID: "client-id", RedirectURI: "https://auth.example.com/callback", @@ -817,30 +817,30 @@ func TestBuildAuthServerRunConfig(t *testing.T) { { name: "with OAuth2 upstream provider with userinfo config", resourceURL: defaultResourceURL, - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "github", - Type: mcpv1alpha1.UpstreamProviderTypeOAuth2, - OAuth2Config: &mcpv1alpha1.OAuth2UpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{ AuthorizationEndpoint: "https://github.com/login/oauth/authorize", TokenEndpoint: "https://github.com/login/oauth/access_token", ClientID: "client-id", RedirectURI: "https://auth.example.com/callback", - UserInfo: &mcpv1alpha1.UserInfoConfig{ + UserInfo: &mcpv1beta1.UserInfoConfig{ EndpointURL: "https://api.github.com/user", HTTPMethod: "GET", AdditionalHeaders: map[string]string{ "Accept": "application/vnd.github.v3+json", }, - FieldMapping: &mcpv1alpha1.UserInfoFieldMapping{ + FieldMapping: &mcpv1beta1.UserInfoFieldMapping{ SubjectFields: []string{"id", "login"}, NameFields: []string{"name", "login"}, EmailFields: []string{"email"}, @@ -872,12 +872,12 @@ func TestBuildAuthServerRunConfig(t *testing.T) { }, { name: "with nil scopes uses auth server defaults", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -891,12 +891,12 @@ func TestBuildAuthServerRunConfig(t *testing.T) { }, { name: "with custom scopes from OIDC config", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -911,24 +911,24 @@ func TestBuildAuthServerRunConfig(t *testing.T) { { name: "with multiple upstream providers all are included", resourceURL: defaultResourceURL, - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "okta", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://okta.example.com", ClientID: "okta-client-id", RedirectURI: "https://auth.example.com/callback", Scopes: []string{"openid", "profile"}, - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "okta-secret", Key: "client-secret", }, @@ -936,13 +936,13 @@ func TestBuildAuthServerRunConfig(t *testing.T) { }, { Name: "github", - Type: mcpv1alpha1.UpstreamProviderTypeOAuth2, - OAuth2Config: &mcpv1alpha1.OAuth2UpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{ AuthorizationEndpoint: "https://github.com/login/oauth/authorize", TokenEndpoint: "https://github.com/login/oauth/access_token", ClientID: "github-client-id", RedirectURI: "https://auth.example.com/callback", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "github-secret", Key: "client-secret", }, @@ -975,19 +975,19 @@ func TestBuildAuthServerRunConfig(t *testing.T) { }, { name: "OIDC upstream propagates AdditionalAuthorizationParams", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "okta", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://okta.example.com", ClientID: "okta-client-id", RedirectURI: "https://auth.example.com/callback", @@ -1012,19 +1012,19 @@ func TestBuildAuthServerRunConfig(t *testing.T) { }, { name: "OAuth2 upstream propagates AdditionalAuthorizationParams", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "github", - Type: mcpv1alpha1.UpstreamProviderTypeOAuth2, - OAuth2Config: &mcpv1alpha1.OAuth2UpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{ AuthorizationEndpoint: "https://github.com/login/oauth/authorize", TokenEndpoint: "https://github.com/login/oauth/access_token", ClientID: "github-client-id", @@ -1050,19 +1050,19 @@ func TestBuildAuthServerRunConfig(t *testing.T) { { name: "OIDC upstream with empty redirectUri defaults to resourceURL/oauth/callback", resourceURL: "https://mcp.example.com", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "okta", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://okta.example.com", ClientID: "client-id", // RedirectURI intentionally omitted @@ -1082,19 +1082,19 @@ func TestBuildAuthServerRunConfig(t *testing.T) { { name: "OAuth2 upstream with empty redirectUri defaults to resourceURL/oauth/callback", resourceURL: "https://mcp.example.com", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "github", - Type: mcpv1alpha1.UpstreamProviderTypeOAuth2, - OAuth2Config: &mcpv1alpha1.OAuth2UpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{ AuthorizationEndpoint: "https://github.com/login/oauth/authorize", TokenEndpoint: "https://github.com/login/oauth/access_token", ClientID: "client-id", @@ -1115,19 +1115,19 @@ func TestBuildAuthServerRunConfig(t *testing.T) { { name: "explicit redirectUri is preserved when resourceURL is also set", resourceURL: "https://mcp.example.com", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "okta", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://okta.example.com", ClientID: "client-id", RedirectURI: "https://custom.example.com/callback", @@ -1147,19 +1147,19 @@ func TestBuildAuthServerRunConfig(t *testing.T) { { name: "resourceURL with trailing slash produces correct default redirectUri", resourceURL: "https://mcp.example.com/", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "okta", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://okta.example.com", ClientID: "client-id", }, @@ -1194,25 +1194,25 @@ func TestAddEmbeddedAuthServerConfigOptions_Validation(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - err := mcpv1alpha1.AddToScheme(scheme) + err := mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) // Helper function to create a fresh external auth config for each test // This avoids data races when running subtests in parallel - newExternalAuthConfig := func() *mcpv1alpha1.MCPExternalAuthConfig { - return &mcpv1alpha1.MCPExternalAuthConfig{ + newExternalAuthConfig := func() *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "embedded-auth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -1295,7 +1295,7 @@ func TestAddEmbeddedAuthServerConfigOptions_Validation(t *testing.T) { err := AddEmbeddedAuthServerConfigOptions( ctx, fakeClient, "default", "test-server", - &mcpv1alpha1.ExternalAuthConfigRef{Name: "embedded-auth-config"}, + &mcpv1beta1.ExternalAuthConfigRef{Name: "embedded-auth-config"}, tt.oidcConfig, &options, ) @@ -1314,13 +1314,13 @@ func TestAddEmbeddedAuthServerConfigOptions_Validation(t *testing.T) { func TestVolumePathPatterns(t *testing.T) { t.Parallel() - authConfig := &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig := &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "key-0", Key: "private.pem"}, {Name: "key-1", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-0", Key: "hmac"}, {Name: "hmac-1", Key: "hmac"}, }, @@ -1345,7 +1345,7 @@ func TestGenerateAuthServerEnvVars_RedisCredentials(t *testing.T) { tests := []struct { name string - authConfig *mcpv1alpha1.EmbeddedAuthServerConfig + authConfig *mcpv1beta1.EmbeddedAuthServerConfig wantEnvVarLen int wantRedisUser bool wantRedisPass bool @@ -1353,22 +1353,22 @@ func TestGenerateAuthServerEnvVars_RedisCredentials(t *testing.T) { }{ { name: "Redis storage with ACL credentials generates env vars", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{}, - Storage: &mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ - SentinelConfig: &mcpv1alpha1.RedisSentinelConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{}, + Storage: &mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ + SentinelConfig: &mcpv1beta1.RedisSentinelConfig{ MasterName: "mymaster", SentinelAddrs: []string{"sentinel:26379"}, }, - ACLUserConfig: &mcpv1alpha1.RedisACLUserConfig{ - UsernameSecretRef: &mcpv1alpha1.SecretKeyRef{ + ACLUserConfig: &mcpv1beta1.RedisACLUserConfig{ + UsernameSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "redis-creds", Key: "username", }, - PasswordSecretRef: &mcpv1alpha1.SecretKeyRef{ + PasswordSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "redis-creds", Key: "password", }, @@ -1382,35 +1382,35 @@ func TestGenerateAuthServerEnvVars_RedisCredentials(t *testing.T) { }, { name: "Redis storage with upstream client secret generates all env vars", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "okta", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://okta.example.com", ClientID: "client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "oidc-secret", Key: "client-secret", }, }, }, }, - Storage: &mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ - SentinelConfig: &mcpv1alpha1.RedisSentinelConfig{ + Storage: &mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ + SentinelConfig: &mcpv1beta1.RedisSentinelConfig{ MasterName: "mymaster", SentinelAddrs: []string{"sentinel:26379"}, }, - ACLUserConfig: &mcpv1alpha1.RedisACLUserConfig{ - UsernameSecretRef: &mcpv1alpha1.SecretKeyRef{ + ACLUserConfig: &mcpv1beta1.RedisACLUserConfig{ + UsernameSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "redis-creds", Key: "username", }, - PasswordSecretRef: &mcpv1alpha1.SecretKeyRef{ + PasswordSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "redis-creds", Key: "password", }, @@ -1425,20 +1425,20 @@ func TestGenerateAuthServerEnvVars_RedisCredentials(t *testing.T) { }, { name: "memory storage does not generate Redis env vars", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{}, - Storage: &mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeMemory, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{}, + Storage: &mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeMemory, }, }, wantEnvVarLen: 0, }, { name: "nil storage does not generate Redis env vars", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{}, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{}, }, wantEnvVarLen: 0, }, @@ -1491,14 +1491,14 @@ func TestResolveSentinelAddrs(t *testing.T) { tests := []struct { name string - sentinel *mcpv1alpha1.RedisSentinelConfig + sentinel *mcpv1beta1.RedisSentinelConfig wantAddrs []string wantErr bool errMsg string }{ { name: "static addresses returned directly", - sentinel: &mcpv1alpha1.RedisSentinelConfig{ + sentinel: &mcpv1beta1.RedisSentinelConfig{ MasterName: "mymaster", SentinelAddrs: []string{"10.0.0.1:26379", "10.0.0.2:26379"}, }, @@ -1506,9 +1506,9 @@ func TestResolveSentinelAddrs(t *testing.T) { }, { name: "service ref constructs DNS name with explicit port", - sentinel: &mcpv1alpha1.RedisSentinelConfig{ + sentinel: &mcpv1beta1.RedisSentinelConfig{ MasterName: "mymaster", - SentinelService: &mcpv1alpha1.SentinelServiceRef{ + SentinelService: &mcpv1beta1.SentinelServiceRef{ Name: "redis-sentinel", Port: 26379, }, @@ -1517,9 +1517,9 @@ func TestResolveSentinelAddrs(t *testing.T) { }, { name: "service ref with default port", - sentinel: &mcpv1alpha1.RedisSentinelConfig{ + sentinel: &mcpv1beta1.RedisSentinelConfig{ MasterName: "mymaster", - SentinelService: &mcpv1alpha1.SentinelServiceRef{ + SentinelService: &mcpv1beta1.SentinelServiceRef{ Name: "redis-sentinel", }, }, @@ -1527,9 +1527,9 @@ func TestResolveSentinelAddrs(t *testing.T) { }, { name: "service ref with custom namespace", - sentinel: &mcpv1alpha1.RedisSentinelConfig{ + sentinel: &mcpv1beta1.RedisSentinelConfig{ MasterName: "mymaster", - SentinelService: &mcpv1alpha1.SentinelServiceRef{ + SentinelService: &mcpv1beta1.SentinelServiceRef{ Name: "redis-sentinel", Namespace: "redis-ns", Port: 26379, @@ -1539,7 +1539,7 @@ func TestResolveSentinelAddrs(t *testing.T) { }, { name: "neither addrs nor service returns error", - sentinel: &mcpv1alpha1.RedisSentinelConfig{ + sentinel: &mcpv1beta1.RedisSentinelConfig{ MasterName: "mymaster", }, wantErr: true, @@ -1572,7 +1572,7 @@ func TestBuildStorageRunConfig(t *testing.T) { tests := []struct { name string - authConfig *mcpv1alpha1.EmbeddedAuthServerConfig + authConfig *mcpv1beta1.EmbeddedAuthServerConfig wantNil bool wantErr bool errContains string @@ -1580,36 +1580,36 @@ func TestBuildStorageRunConfig(t *testing.T) { }{ { name: "nil storage returns nil (memory default)", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", }, wantNil: true, }, { name: "memory storage returns nil", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - Storage: &mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeMemory, + Storage: &mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeMemory, }, }, wantNil: true, }, { name: "Redis storage with static addrs builds correctly", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - Storage: &mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ - SentinelConfig: &mcpv1alpha1.RedisSentinelConfig{ + Storage: &mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ + SentinelConfig: &mcpv1beta1.RedisSentinelConfig{ MasterName: "mymaster", SentinelAddrs: []string{"10.0.0.1:26379"}, DB: 2, }, - ACLUserConfig: &mcpv1alpha1.RedisACLUserConfig{ - UsernameSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "s", Key: "u"}, - PasswordSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "s", Key: "p"}, + ACLUserConfig: &mcpv1beta1.RedisACLUserConfig{ + UsernameSecretRef: &mcpv1beta1.SecretKeyRef{Name: "s", Key: "u"}, + PasswordSecretRef: &mcpv1beta1.SecretKeyRef{Name: "s", Key: "p"}, }, DialTimeout: "10s", ReadTimeout: "5s", @@ -1637,21 +1637,21 @@ func TestBuildStorageRunConfig(t *testing.T) { }, { name: "Redis storage with service discovery via DNS", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - Storage: &mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ - SentinelConfig: &mcpv1alpha1.RedisSentinelConfig{ + Storage: &mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ + SentinelConfig: &mcpv1beta1.RedisSentinelConfig{ MasterName: "mymaster", - SentinelService: &mcpv1alpha1.SentinelServiceRef{ + SentinelService: &mcpv1beta1.SentinelServiceRef{ Name: "redis-sentinel", Port: 26379, }, }, - ACLUserConfig: &mcpv1alpha1.RedisACLUserConfig{ - UsernameSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "s", Key: "u"}, - PasswordSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "s", Key: "p"}, + ACLUserConfig: &mcpv1beta1.RedisACLUserConfig{ + UsernameSecretRef: &mcpv1beta1.SecretKeyRef{Name: "s", Key: "u"}, + PasswordSecretRef: &mcpv1beta1.SecretKeyRef{Name: "s", Key: "p"}, }, }, }, @@ -1664,10 +1664,10 @@ func TestBuildStorageRunConfig(t *testing.T) { }, { name: "Redis storage without redis config returns error", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - Storage: &mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, + Storage: &mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, }, }, wantErr: true, @@ -1675,14 +1675,14 @@ func TestBuildStorageRunConfig(t *testing.T) { }, { name: "Redis storage without sentinel config returns error", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - Storage: &mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ - ACLUserConfig: &mcpv1alpha1.RedisACLUserConfig{ - UsernameSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "s", Key: "u"}, - PasswordSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "s", Key: "p"}, + Storage: &mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ + ACLUserConfig: &mcpv1beta1.RedisACLUserConfig{ + UsernameSecretRef: &mcpv1beta1.SecretKeyRef{Name: "s", Key: "u"}, + PasswordSecretRef: &mcpv1beta1.SecretKeyRef{Name: "s", Key: "p"}, }, }, }, @@ -1692,12 +1692,12 @@ func TestBuildStorageRunConfig(t *testing.T) { }, { name: "Redis storage without ACL user config returns error", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - Storage: &mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ - SentinelConfig: &mcpv1alpha1.RedisSentinelConfig{ + Storage: &mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ + SentinelConfig: &mcpv1beta1.RedisSentinelConfig{ MasterName: "mymaster", SentinelAddrs: []string{"10.0.0.1:26379"}, }, @@ -1741,24 +1741,24 @@ func TestBuildStorageRunConfig(t *testing.T) { func TestBuildAuthServerRunConfig_WithRedisStorage(t *testing.T) { t.Parallel() - authConfig := &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig := &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, - Storage: &mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ - SentinelConfig: &mcpv1alpha1.RedisSentinelConfig{ + Storage: &mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ + SentinelConfig: &mcpv1beta1.RedisSentinelConfig{ MasterName: "mymaster", SentinelAddrs: []string{"10.0.0.1:26379"}, }, - ACLUserConfig: &mcpv1alpha1.RedisACLUserConfig{ - UsernameSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "redis-creds", Key: "username"}, - PasswordSecretRef: &mcpv1alpha1.SecretKeyRef{Name: "redis-creds", Key: "password"}, + ACLUserConfig: &mcpv1beta1.RedisACLUserConfig{ + UsernameSecretRef: &mcpv1beta1.SecretKeyRef{Name: "redis-creds", Key: "username"}, + PasswordSecretRef: &mcpv1beta1.SecretKeyRef{Name: "redis-creds", Key: "password"}, }, }, }, @@ -1784,24 +1784,24 @@ func TestAddAuthServerRefOptions(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - newValidEmbeddedAuthConfig := func() *mcpv1alpha1.MCPExternalAuthConfig { - return &mcpv1alpha1.MCPExternalAuthConfig{ + newValidEmbeddedAuthConfig := func() *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-server-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", AuthorizationEndpointBaseURL: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -1809,14 +1809,14 @@ func TestAddAuthServerRefOptions(t *testing.T) { } } - newUnauthenticatedConfig := func() *mcpv1alpha1.MCPExternalAuthConfig { - return &mcpv1alpha1.MCPExternalAuthConfig{ + newUnauthenticatedConfig := func() *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "unauth-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUnauthenticated, }, } } @@ -1829,7 +1829,7 @@ func TestAddAuthServerRefOptions(t *testing.T) { tests := []struct { name string - authServerRef *mcpv1alpha1.AuthServerRef + authServerRef *mcpv1beta1.AuthServerRef oidcConfig *oidc.OIDCConfig objects func() []runtime.Object wantErr bool @@ -1845,7 +1845,7 @@ func TestAddAuthServerRefOptions(t *testing.T) { }, { name: "unsupported kind returns error", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "Foo", Name: "some-config", }, @@ -1855,7 +1855,7 @@ func TestAddAuthServerRefOptions(t *testing.T) { }, { name: "non-existent config returns error", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "non-existent", }, @@ -1865,7 +1865,7 @@ func TestAddAuthServerRefOptions(t *testing.T) { }, { name: "wrong type returns error", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "unauth-config", }, @@ -1876,7 +1876,7 @@ func TestAddAuthServerRefOptions(t *testing.T) { }, { name: "valid ref appends option", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "auth-server-config", }, @@ -1887,7 +1887,7 @@ func TestAddAuthServerRefOptions(t *testing.T) { }, { name: "nil OIDC config returns error for valid ref", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "auth-server-config", }, @@ -1898,7 +1898,7 @@ func TestAddAuthServerRefOptions(t *testing.T) { }, { name: "audience mismatch with resourceUrl returns error", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "auth-server-config", }, @@ -1913,7 +1913,7 @@ func TestAddAuthServerRefOptions(t *testing.T) { }, { name: "audience matching resourceUrl succeeds", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "auth-server-config", }, @@ -1960,24 +1960,24 @@ func TestValidateAndAddAuthServerRefOptions(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - newEmbeddedAuthConfig := func() *mcpv1alpha1.MCPExternalAuthConfig { - return &mcpv1alpha1.MCPExternalAuthConfig{ + newEmbeddedAuthConfig := func() *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "embedded-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", AuthorizationEndpointBaseURL: "https://auth.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - HMACSecretRefs: []mcpv1alpha1.SecretKeyRef{ + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "hmac-secret", Key: "hmac"}, }, }, @@ -1985,15 +1985,15 @@ func TestValidateAndAddAuthServerRefOptions(t *testing.T) { } } - newAWSStsConfig := func() *mcpv1alpha1.MCPExternalAuthConfig { - return &mcpv1alpha1.MCPExternalAuthConfig{ + newAWSStsConfig := func() *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "aws-sts-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeAWSSts, - AWSSts: &mcpv1alpha1.AWSStsConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeAWSSts, + AWSSts: &mcpv1beta1.AWSStsConfig{ Region: "us-east-1", }, }, @@ -2008,8 +2008,8 @@ func TestValidateAndAddAuthServerRefOptions(t *testing.T) { tests := []struct { name string - authServerRef *mcpv1alpha1.AuthServerRef - externalAuthConfigRef *mcpv1alpha1.ExternalAuthConfigRef + authServerRef *mcpv1beta1.AuthServerRef + externalAuthConfigRef *mcpv1beta1.ExternalAuthConfigRef oidcConfig *oidc.OIDCConfig objects func() []runtime.Object wantErr bool @@ -2026,7 +2026,7 @@ func TestValidateAndAddAuthServerRefOptions(t *testing.T) { }, { name: "authServerRef set with nil externalAuthConfigRef succeeds", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "embedded-config", }, @@ -2038,11 +2038,11 @@ func TestValidateAndAddAuthServerRefOptions(t *testing.T) { }, { name: "both refs pointing to embeddedAuthServer returns conflict error", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "embedded-config", }, - externalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + externalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "embedded-config", }, oidcConfig: validOIDC, @@ -2052,11 +2052,11 @@ func TestValidateAndAddAuthServerRefOptions(t *testing.T) { }, { name: "authServerRef embedded + externalAuthConfigRef awsSts succeeds", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "embedded-config", }, - externalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + externalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "aws-sts-config", }, oidcConfig: validOIDC, @@ -2066,11 +2066,11 @@ func TestValidateAndAddAuthServerRefOptions(t *testing.T) { }, { name: "non-NotFound fetch error for externalAuthConfigRef is returned", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "embedded-config", }, - externalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + externalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "will-error", }, oidcConfig: validOIDC, diff --git a/cmd/thv-operator/pkg/controllerutil/authz.go b/cmd/thv-operator/pkg/controllerutil/authz.go index 71958d2812..ccb5e5caaa 100644 --- a/cmd/thv-operator/pkg/controllerutil/authz.go +++ b/cmd/thv-operator/pkg/controllerutil/authz.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/kubernetes/configmaps" "github.com/stacklok/toolhive/pkg/authz" "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" @@ -31,7 +31,7 @@ const ( // GenerateAuthzVolumeConfig generates volume mount and volume for authorization policies func GenerateAuthzVolumeConfig( - authzConfig *mcpv1alpha1.AuthzConfigRef, + authzConfig *mcpv1beta1.AuthzConfigRef, resourceName string, ) (*corev1.VolumeMount, *corev1.Volume) { if authzConfig == nil { @@ -39,7 +39,7 @@ func GenerateAuthzVolumeConfig( } switch authzConfig.Type { - case mcpv1alpha1.AuthzConfigTypeConfigMap: + case mcpv1beta1.AuthzConfigTypeConfigMap: if authzConfig.ConfigMap == nil { return nil, nil } @@ -74,7 +74,7 @@ func GenerateAuthzVolumeConfig( return volumeMount, volume - case mcpv1alpha1.AuthzConfigTypeInline: + case mcpv1beta1.AuthzConfigTypeInline: if authzConfig.Inline == nil { return nil, nil } @@ -117,10 +117,10 @@ func EnsureAuthzConfigMap( owner client.Object, namespace string, resourceName string, - authzConfig *mcpv1alpha1.AuthzConfigRef, + authzConfig *mcpv1beta1.AuthzConfigRef, labels map[string]string, ) error { - if authzConfig == nil || authzConfig.Type != mcpv1alpha1.AuthzConfigTypeInline || + if authzConfig == nil || authzConfig.Type != mcpv1beta1.AuthzConfigTypeInline || authzConfig.Inline == nil { return nil } @@ -167,7 +167,7 @@ func EnsureAuthzConfigMap( } func addAuthzInlineConfigOptions( - authzRef *mcpv1alpha1.AuthzConfigRef, + authzRef *mcpv1beta1.AuthzConfigRef, options *[]runner.RunConfigBuilderOption, ) error { if authzRef.Inline == nil { @@ -201,7 +201,7 @@ func AddAuthzConfigOptions( ctx context.Context, c client.Client, namespace string, - authzRef *mcpv1alpha1.AuthzConfigRef, + authzRef *mcpv1beta1.AuthzConfigRef, options *[]runner.RunConfigBuilderOption, ) error { if authzRef == nil { @@ -209,10 +209,10 @@ func AddAuthzConfigOptions( } switch authzRef.Type { - case mcpv1alpha1.AuthzConfigTypeInline: + case mcpv1beta1.AuthzConfigTypeInline: return addAuthzInlineConfigOptions(authzRef, options) - case mcpv1alpha1.AuthzConfigTypeConfigMap: + case mcpv1beta1.AuthzConfigTypeConfigMap: // Validate reference if authzRef.ConfigMap == nil || authzRef.ConfigMap.Name == "" { return fmt.Errorf("configMap authz config type specified but reference is missing name") diff --git a/cmd/thv-operator/pkg/controllerutil/authz_test.go b/cmd/thv-operator/pkg/controllerutil/authz_test.go index fea65342ed..0485ebba3e 100644 --- a/cmd/thv-operator/pkg/controllerutil/authz_test.go +++ b/cmd/thv-operator/pkg/controllerutil/authz_test.go @@ -15,7 +15,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/runner" ) @@ -24,7 +24,7 @@ func TestGenerateAuthzVolumeConfig(t *testing.T) { testCases := []struct { name string - authzConfig *mcpv1alpha1.AuthzConfigRef + authzConfig *mcpv1beta1.AuthzConfigRef resourceName string expectVolumeMount bool expectVolume bool @@ -40,8 +40,8 @@ func TestGenerateAuthzVolumeConfig(t *testing.T) { }, { name: "ConfigMap type with nil ConfigMap ref", - authzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + authzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, ConfigMap: nil, }, resourceName: "test-resource", @@ -50,9 +50,9 @@ func TestGenerateAuthzVolumeConfig(t *testing.T) { }, { name: "ConfigMap type with default key", - authzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + authzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "my-authz-config", }, }, @@ -64,9 +64,9 @@ func TestGenerateAuthzVolumeConfig(t *testing.T) { }, { name: "ConfigMap type with custom key", - authzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + authzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "my-authz-config", Key: "custom-authz.json", }, @@ -79,8 +79,8 @@ func TestGenerateAuthzVolumeConfig(t *testing.T) { }, { name: "Inline type with nil inline config", - authzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, + authzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, Inline: nil, }, resourceName: "test-resource", @@ -89,9 +89,9 @@ func TestGenerateAuthzVolumeConfig(t *testing.T) { }, { name: "Inline type with valid config", - authzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + authzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{`permit(principal, action, resource);`}, }, }, @@ -103,7 +103,7 @@ func TestGenerateAuthzVolumeConfig(t *testing.T) { }, { name: "Unknown type returns nil", - authzConfig: &mcpv1alpha1.AuthzConfigRef{ + authzConfig: &mcpv1beta1.AuthzConfigRef{ Type: "unknown", }, resourceName: "test-resource", @@ -141,9 +141,9 @@ func TestGenerateAuthzVolumeConfigInlineConfigMapName(t *testing.T) { t.Parallel() // Test that inline config generates the correct ConfigMap name - authzConfig := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + authzConfig := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{`permit(principal, action, resource);`}, }, } @@ -159,7 +159,7 @@ func TestEnsureAuthzConfigMap(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(scheme)) - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) t.Run("Nil authz config returns nil", func(t *testing.T) { t.Parallel() @@ -169,7 +169,7 @@ func TestEnsureAuthzConfigMap(t *testing.T) { context.Background(), client, scheme, - &mcpv1alpha1.MCPServer{}, + &mcpv1beta1.MCPServer{}, "default", "test-resource", nil, @@ -182,9 +182,9 @@ func TestEnsureAuthzConfigMap(t *testing.T) { t.Parallel() client := fake.NewClientBuilder().WithScheme(scheme).Build() - authzConfig := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + authzConfig := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "my-config", }, } @@ -193,7 +193,7 @@ func TestEnsureAuthzConfigMap(t *testing.T) { context.Background(), client, scheme, - &mcpv1alpha1.MCPServer{}, + &mcpv1beta1.MCPServer{}, "default", "test-resource", authzConfig, @@ -206,8 +206,8 @@ func TestEnsureAuthzConfigMap(t *testing.T) { t.Parallel() client := fake.NewClientBuilder().WithScheme(scheme).Build() - authzConfig := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, + authzConfig := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, Inline: nil, } @@ -215,7 +215,7 @@ func TestEnsureAuthzConfigMap(t *testing.T) { context.Background(), client, scheme, - &mcpv1alpha1.MCPServer{}, + &mcpv1beta1.MCPServer{}, "default", "test-resource", authzConfig, @@ -228,16 +228,16 @@ func TestEnsureAuthzConfigMap(t *testing.T) { t.Parallel() client := fake.NewClientBuilder().WithScheme(scheme).Build() - owner := &mcpv1alpha1.MCPServer{ + owner := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", UID: "test-uid", }, } - authzConfig := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + authzConfig := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: `[]`, }, @@ -277,16 +277,16 @@ func TestEnsureAuthzConfigMap(t *testing.T) { t.Parallel() client := fake.NewClientBuilder().WithScheme(scheme).Build() - owner := &mcpv1alpha1.MCPServer{ + owner := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", UID: "test-uid-2", }, } - authzConfig := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + authzConfig := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{`permit(principal, action, resource);`}, // EntitiesJSON is empty }, @@ -324,7 +324,7 @@ func TestAddAuthzConfigOptions(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(scheme)) - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) t.Run("Nil authz ref returns nil", func(t *testing.T) { t.Parallel() @@ -344,9 +344,9 @@ func TestAddAuthzConfigOptions(t *testing.T) { t.Run("Inline type adds config", func(t *testing.T) { t.Parallel() - authzRef := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + authzRef := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: `[]`, }, @@ -367,8 +367,8 @@ func TestAddAuthzConfigOptions(t *testing.T) { t.Run("Inline type with nil inline config returns error", func(t *testing.T) { t.Parallel() - authzRef := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, + authzRef := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, Inline: nil, } @@ -388,8 +388,8 @@ func TestAddAuthzConfigOptions(t *testing.T) { t.Parallel() client := fake.NewClientBuilder().WithScheme(scheme).Build() - authzRef := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, + authzRef := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, ConfigMap: nil, } @@ -409,9 +409,9 @@ func TestAddAuthzConfigOptions(t *testing.T) { t.Parallel() client := fake.NewClientBuilder().WithScheme(scheme).Build() - authzRef := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + authzRef := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "", }, } @@ -431,9 +431,9 @@ func TestAddAuthzConfigOptions(t *testing.T) { t.Run("ConfigMap type with nil client returns error", func(t *testing.T) { t.Parallel() - authzRef := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + authzRef := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "my-config", }, } @@ -454,9 +454,9 @@ func TestAddAuthzConfigOptions(t *testing.T) { t.Parallel() client := fake.NewClientBuilder().WithScheme(scheme).Build() - authzRef := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + authzRef := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "non-existent", }, } @@ -487,9 +487,9 @@ func TestAddAuthzConfigOptions(t *testing.T) { } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() - authzRef := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + authzRef := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "authz-config", }, } @@ -520,9 +520,9 @@ func TestAddAuthzConfigOptions(t *testing.T) { } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() - authzRef := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + authzRef := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "authz-config-empty", }, } @@ -553,9 +553,9 @@ func TestAddAuthzConfigOptions(t *testing.T) { } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() - authzRef := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + authzRef := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "authz-config-invalid", }, } @@ -594,9 +594,9 @@ func TestAddAuthzConfigOptions(t *testing.T) { } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() - authzRef := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + authzRef := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "authz-config-valid", }, } @@ -635,9 +635,9 @@ func TestAddAuthzConfigOptions(t *testing.T) { } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build() - authzRef := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + authzRef := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "authz-config-custom-key", Key: "custom.json", }, @@ -658,7 +658,7 @@ func TestAddAuthzConfigOptions(t *testing.T) { t.Run("Unknown type returns error", func(t *testing.T) { t.Parallel() - authzRef := &mcpv1alpha1.AuthzConfigRef{ + authzRef := &mcpv1beta1.AuthzConfigRef{ Type: "unknown", } diff --git a/cmd/thv-operator/pkg/controllerutil/config.go b/cmd/thv-operator/pkg/controllerutil/config.go index 12ab9014f0..aad427dba3 100644 --- a/cmd/thv-operator/pkg/controllerutil/config.go +++ b/cmd/thv-operator/pkg/controllerutil/config.go @@ -15,7 +15,7 @@ import ( "k8s.io/apimachinery/pkg/util/dump" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // CalculateConfigHash calculates a hash of any configuration spec using Kubernetes utilities. @@ -45,7 +45,7 @@ func CalculateConfigHash[T any](spec T) string { // Example usage for ToolConfig: // // servers, err := FindReferencingMCPServers(ctx, client, namespace, configName, -// func(server *mcpv1alpha1.MCPServer) *string { +// func(server *mcpv1beta1.MCPServer) *string { // if server.Spec.ToolConfigRef != nil { // return &server.Spec.ToolConfigRef.Name // } @@ -56,16 +56,16 @@ func FindReferencingMCPServers( c client.Client, namespace string, configName string, - refExtractor func(*mcpv1alpha1.MCPServer) *string, -) ([]mcpv1alpha1.MCPServer, error) { + refExtractor func(*mcpv1beta1.MCPServer) *string, +) ([]mcpv1beta1.MCPServer, error) { // List all MCPServers in the same namespace - mcpServerList := &mcpv1alpha1.MCPServerList{} + mcpServerList := &mcpv1beta1.MCPServerList{} if err := c.List(ctx, mcpServerList, client.InNamespace(namespace)); err != nil { return nil, fmt.Errorf("failed to list MCPServers: %w", err) } // Filter MCPServers that reference this config - var referencingServers []mcpv1alpha1.MCPServer + var referencingServers []mcpv1beta1.MCPServer for _, server := range mcpServerList.Items { if refName := refExtractor(&server); refName != nil && *refName == configName { referencingServers = append(referencingServers, server) @@ -83,14 +83,14 @@ func FindReferencingMCPRemoteProxies( c client.Client, namespace string, configName string, - refExtractor func(*mcpv1alpha1.MCPRemoteProxy) *string, -) ([]mcpv1alpha1.MCPRemoteProxy, error) { - proxyList := &mcpv1alpha1.MCPRemoteProxyList{} + refExtractor func(*mcpv1beta1.MCPRemoteProxy) *string, +) ([]mcpv1beta1.MCPRemoteProxy, error) { + proxyList := &mcpv1beta1.MCPRemoteProxyList{} if err := c.List(ctx, proxyList, client.InNamespace(namespace)); err != nil { return nil, fmt.Errorf("failed to list MCPRemoteProxies: %w", err) } - var referencingProxies []mcpv1alpha1.MCPRemoteProxy + var referencingProxies []mcpv1beta1.MCPRemoteProxy for _, proxy := range proxyList.Items { if refName := refExtractor(&proxy); refName != nil && *refName == configName { referencingProxies = append(referencingProxies, proxy) @@ -102,7 +102,7 @@ func FindReferencingMCPRemoteProxies( // CompareWorkloadRefs compares two WorkloadReference values by Kind then Name. // Suitable for use with slices.SortFunc. -func CompareWorkloadRefs(a, b mcpv1alpha1.WorkloadReference) int { +func CompareWorkloadRefs(a, b mcpv1beta1.WorkloadReference) int { if a.Kind != b.Kind { return strings.Compare(a.Kind, b.Kind) } @@ -112,14 +112,14 @@ func CompareWorkloadRefs(a, b mcpv1alpha1.WorkloadReference) int { // SortWorkloadRefs sorts a WorkloadReference slice by Kind then Name for deterministic ordering. // This prevents unnecessary API server writes when the same set of workloads is discovered // in a different list order across reconcile runs. -func SortWorkloadRefs(refs []mcpv1alpha1.WorkloadReference) { +func SortWorkloadRefs(refs []mcpv1beta1.WorkloadReference) { slices.SortFunc(refs, CompareWorkloadRefs) } // WorkloadRefsEqual reports whether two WorkloadReference slices contain the same entries. // Both slices must already be sorted (use SortWorkloadRefs) for correct results. -func WorkloadRefsEqual(a, b []mcpv1alpha1.WorkloadReference) bool { - return slices.EqualFunc(a, b, func(x, y mcpv1alpha1.WorkloadReference) bool { +func WorkloadRefsEqual(a, b []mcpv1beta1.WorkloadReference) bool { + return slices.EqualFunc(a, b, func(x, y mcpv1beta1.WorkloadReference) bool { return x.Kind == y.Kind && x.Name == y.Name }) } @@ -132,15 +132,15 @@ func FindWorkloadRefsFromMCPServers( c client.Client, namespace string, configName string, - refExtractor func(*mcpv1alpha1.MCPServer) *string, -) ([]mcpv1alpha1.WorkloadReference, error) { + refExtractor func(*mcpv1beta1.MCPServer) *string, +) ([]mcpv1beta1.WorkloadReference, error) { servers, err := FindReferencingMCPServers(ctx, c, namespace, configName, refExtractor) if err != nil { return nil, err } - refs := make([]mcpv1alpha1.WorkloadReference, 0, len(servers)) + refs := make([]mcpv1beta1.WorkloadReference, 0, len(servers)) for _, server := range servers { - refs = append(refs, mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPServer, Name: server.Name}) + refs = append(refs, mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindMCPServer, Name: server.Name}) } SortWorkloadRefs(refs) return refs, nil @@ -150,13 +150,13 @@ func FindWorkloadRefsFromMCPServers( func GetToolConfigForMCPRemoteProxy( ctx context.Context, c client.Client, - proxy *mcpv1alpha1.MCPRemoteProxy, -) (*mcpv1alpha1.MCPToolConfig, error) { + proxy *mcpv1beta1.MCPRemoteProxy, +) (*mcpv1beta1.MCPToolConfig, error) { if proxy.Spec.ToolConfigRef == nil { return nil, fmt.Errorf("MCPRemoteProxy %s does not reference a MCPToolConfig", proxy.Name) } - toolConfig := &mcpv1alpha1.MCPToolConfig{} + toolConfig := &mcpv1beta1.MCPToolConfig{} err := c.Get(ctx, types.NamespacedName{ Name: proxy.Spec.ToolConfigRef.Name, Namespace: proxy.Namespace, @@ -173,13 +173,13 @@ func GetToolConfigForMCPRemoteProxy( func GetExternalAuthConfigForMCPRemoteProxy( ctx context.Context, c client.Client, - proxy *mcpv1alpha1.MCPRemoteProxy, -) (*mcpv1alpha1.MCPExternalAuthConfig, error) { + proxy *mcpv1beta1.MCPRemoteProxy, +) (*mcpv1beta1.MCPExternalAuthConfig, error) { if proxy.Spec.ExternalAuthConfigRef == nil { return nil, fmt.Errorf("MCPRemoteProxy %s does not reference a MCPExternalAuthConfig", proxy.Name) } - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{} err := c.Get(ctx, types.NamespacedName{ Name: proxy.Spec.ExternalAuthConfigRef.Name, Namespace: proxy.Namespace, @@ -199,13 +199,13 @@ func GetExternalAuthConfigForMCPRemoteProxy( func GetTelemetryConfigForMCPRemoteProxy( ctx context.Context, c client.Client, - proxy *mcpv1alpha1.MCPRemoteProxy, -) (*mcpv1alpha1.MCPTelemetryConfig, error) { + proxy *mcpv1beta1.MCPRemoteProxy, +) (*mcpv1beta1.MCPTelemetryConfig, error) { if proxy.Spec.TelemetryConfigRef == nil { return nil, nil } - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{} + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{} err := c.Get(ctx, types.NamespacedName{ Name: proxy.Spec.TelemetryConfigRef.Name, Namespace: proxy.Namespace, @@ -227,13 +227,13 @@ func GetTelemetryConfigForMCPRemoteProxy( func GetTelemetryConfigForVirtualMCPServer( ctx context.Context, c client.Client, - vmcp *mcpv1alpha1.VirtualMCPServer, -) (*mcpv1alpha1.MCPTelemetryConfig, error) { + vmcp *mcpv1beta1.VirtualMCPServer, +) (*mcpv1beta1.MCPTelemetryConfig, error) { if vmcp.Spec.TelemetryConfigRef == nil { return nil, nil } - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{} + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{} err := c.Get(ctx, types.NamespacedName{ Name: vmcp.Spec.TelemetryConfigRef.Name, Namespace: vmcp.Namespace, @@ -254,8 +254,8 @@ func GetExternalAuthConfigByName( c client.Client, namespace string, name string, -) (*mcpv1alpha1.MCPExternalAuthConfig, error) { - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} +) (*mcpv1beta1.MCPExternalAuthConfig, error) { + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{} err := c.Get(ctx, types.NamespacedName{ Name: name, Namespace: namespace, diff --git a/cmd/thv-operator/pkg/controllerutil/config_test.go b/cmd/thv-operator/pkg/controllerutil/config_test.go index 3b13e23dc6..61a8738bc9 100644 --- a/cmd/thv-operator/pkg/controllerutil/config_test.go +++ b/cmd/thv-operator/pkg/controllerutil/config_test.go @@ -12,7 +12,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestCalculateConfigHash(t *testing.T) { @@ -21,7 +21,7 @@ func TestCalculateConfigHash(t *testing.T) { t.Run("consistent hashing for same spec", func(t *testing.T) { t.Parallel() - spec := mcpv1alpha1.MCPToolConfigSpec{ + spec := mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1", "tool2"}, } @@ -35,10 +35,10 @@ func TestCalculateConfigHash(t *testing.T) { t.Run("different hashes for different specs", func(t *testing.T) { t.Parallel() - spec1 := mcpv1alpha1.MCPToolConfigSpec{ + spec1 := mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1"}, } - spec2 := mcpv1alpha1.MCPToolConfigSpec{ + spec2 := mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool2"}, } @@ -51,15 +51,15 @@ func TestCalculateConfigHash(t *testing.T) { t.Run("works with different config types", func(t *testing.T) { t.Parallel() - toolConfigSpec := mcpv1alpha1.MCPToolConfigSpec{ + toolConfigSpec := mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1"}, } - externalAuthSpec := mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + externalAuthSpec := mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "client-secret", }, @@ -79,7 +79,7 @@ func TestCalculateConfigHash(t *testing.T) { t.Run("empty spec produces consistent hash", func(t *testing.T) { t.Parallel() - spec := mcpv1alpha1.MCPToolConfigSpec{} + spec := mcpv1beta1.MCPToolConfigSpec{} hash1 := CalculateConfigHash(spec) hash2 := CalculateConfigHash(spec) @@ -93,58 +93,58 @@ func TestFindReferencingMCPServers(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) t.Run("finds servers referencing toolconfig", func(t *testing.T) { t.Parallel() ctx := t.Context() - server1 := &mcpv1alpha1.MCPServer{ + server1 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "test-config", }, }, } - server2 := &mcpv1alpha1.MCPServer{ + server2 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "test-config", }, }, } - server3 := &mcpv1alpha1.MCPServer{ + server3 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server3", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "other-config", }, }, } - server4 := &mcpv1alpha1.MCPServer{ + server4 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server4", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", // No ToolConfigRef }, @@ -156,7 +156,7 @@ func TestFindReferencingMCPServers(t *testing.T) { Build() servers, err := FindReferencingMCPServers(ctx, fakeClient, "default", "test-config", - func(server *mcpv1alpha1.MCPServer) *string { + func(server *mcpv1beta1.MCPServer) *string { if server.Spec.ToolConfigRef != nil { return &server.Spec.ToolConfigRef.Name } @@ -181,25 +181,25 @@ func TestFindReferencingMCPServers(t *testing.T) { ctx := t.Context() - server1 := &mcpv1alpha1.MCPServer{ + server1 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-config", }, }, } - server2 := &mcpv1alpha1.MCPServer{ + server2 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", // No ExternalAuthConfigRef }, @@ -211,7 +211,7 @@ func TestFindReferencingMCPServers(t *testing.T) { Build() servers, err := FindReferencingMCPServers(ctx, fakeClient, "default", "auth-config", - func(server *mcpv1alpha1.MCPServer) *string { + func(server *mcpv1beta1.MCPServer) *string { if server.Spec.ExternalAuthConfigRef != nil { return &server.Spec.ExternalAuthConfigRef.Name } @@ -228,12 +228,12 @@ func TestFindReferencingMCPServers(t *testing.T) { ctx := t.Context() - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", }, } @@ -244,7 +244,7 @@ func TestFindReferencingMCPServers(t *testing.T) { Build() servers, err := FindReferencingMCPServers(ctx, fakeClient, "default", "non-existent-config", - func(server *mcpv1alpha1.MCPServer) *string { + func(server *mcpv1beta1.MCPServer) *string { if server.Spec.ToolConfigRef != nil { return &server.Spec.ToolConfigRef.Name } @@ -260,27 +260,27 @@ func TestFindReferencingMCPServers(t *testing.T) { ctx := t.Context() - server1 := &mcpv1alpha1.MCPServer{ + server1 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: "namespace1", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "test-config", }, }, } - server2 := &mcpv1alpha1.MCPServer{ + server2 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server2", Namespace: "namespace2", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "test-config", }, }, @@ -292,7 +292,7 @@ func TestFindReferencingMCPServers(t *testing.T) { Build() servers, err := FindReferencingMCPServers(ctx, fakeClient, "namespace1", "test-config", - func(server *mcpv1alpha1.MCPServer) *string { + func(server *mcpv1beta1.MCPServer) *string { if server.Spec.ToolConfigRef != nil { return &server.Spec.ToolConfigRef.Name } @@ -312,7 +312,7 @@ func TestSortWorkloadRefs(t *testing.T) { t.Run("sorts by kind then name", func(t *testing.T) { t.Parallel() - refs := []mcpv1alpha1.WorkloadReference{ + refs := []mcpv1beta1.WorkloadReference{ {Kind: "VirtualMCPServer", Name: "beta"}, {Kind: "MCPServer", Name: "gamma"}, {Kind: "MCPServer", Name: "alpha"}, @@ -321,7 +321,7 @@ func TestSortWorkloadRefs(t *testing.T) { SortWorkloadRefs(refs) - assert.Equal(t, []mcpv1alpha1.WorkloadReference{ + assert.Equal(t, []mcpv1beta1.WorkloadReference{ {Kind: "MCPServer", Name: "alpha"}, {Kind: "MCPServer", Name: "gamma"}, {Kind: "VirtualMCPServer", Name: "alpha"}, @@ -331,16 +331,16 @@ func TestSortWorkloadRefs(t *testing.T) { t.Run("empty slice is a no-op", func(t *testing.T) { t.Parallel() - var refs []mcpv1alpha1.WorkloadReference + var refs []mcpv1beta1.WorkloadReference SortWorkloadRefs(refs) assert.Empty(t, refs) }) t.Run("single element is unchanged", func(t *testing.T) { t.Parallel() - refs := []mcpv1alpha1.WorkloadReference{{Kind: "MCPServer", Name: "only"}} + refs := []mcpv1beta1.WorkloadReference{{Kind: "MCPServer", Name: "only"}} SortWorkloadRefs(refs) - assert.Equal(t, []mcpv1alpha1.WorkloadReference{{Kind: "MCPServer", Name: "only"}}, refs) + assert.Equal(t, []mcpv1beta1.WorkloadReference{{Kind: "MCPServer", Name: "only"}}, refs) }) } @@ -349,11 +349,11 @@ func TestWorkloadRefsEqual(t *testing.T) { t.Run("equal slices", func(t *testing.T) { t.Parallel() - a := []mcpv1alpha1.WorkloadReference{ + a := []mcpv1beta1.WorkloadReference{ {Kind: "MCPServer", Name: "alpha"}, {Kind: "MCPServer", Name: "beta"}, } - b := []mcpv1alpha1.WorkloadReference{ + b := []mcpv1beta1.WorkloadReference{ {Kind: "MCPServer", Name: "alpha"}, {Kind: "MCPServer", Name: "beta"}, } @@ -362,11 +362,11 @@ func TestWorkloadRefsEqual(t *testing.T) { t.Run("different order is not equal", func(t *testing.T) { t.Parallel() - a := []mcpv1alpha1.WorkloadReference{ + a := []mcpv1beta1.WorkloadReference{ {Kind: "MCPServer", Name: "alpha"}, {Kind: "MCPServer", Name: "beta"}, } - b := []mcpv1alpha1.WorkloadReference{ + b := []mcpv1beta1.WorkloadReference{ {Kind: "MCPServer", Name: "beta"}, {Kind: "MCPServer", Name: "alpha"}, } @@ -375,8 +375,8 @@ func TestWorkloadRefsEqual(t *testing.T) { t.Run("different lengths", func(t *testing.T) { t.Parallel() - a := []mcpv1alpha1.WorkloadReference{{Kind: "MCPServer", Name: "alpha"}} - b := []mcpv1alpha1.WorkloadReference{ + a := []mcpv1beta1.WorkloadReference{{Kind: "MCPServer", Name: "alpha"}} + b := []mcpv1beta1.WorkloadReference{ {Kind: "MCPServer", Name: "alpha"}, {Kind: "MCPServer", Name: "beta"}, } @@ -390,7 +390,7 @@ func TestWorkloadRefsEqual(t *testing.T) { t.Run("nil vs empty", func(t *testing.T) { t.Parallel() - assert.True(t, WorkloadRefsEqual(nil, []mcpv1alpha1.WorkloadReference{})) + assert.True(t, WorkloadRefsEqual(nil, []mcpv1beta1.WorkloadReference{})) }) } @@ -398,25 +398,25 @@ func TestFindWorkloadRefsFromMCPServers(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) t.Run("returns sorted refs", func(t *testing.T) { t.Parallel() ctx := t.Context() // Create servers in reverse alphabetical order to verify sorting - servers := []mcpv1alpha1.MCPServer{ + servers := []mcpv1beta1.MCPServer{ { ObjectMeta: metav1.ObjectMeta{Name: "charlie", Namespace: "ns"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "img", ToolConfigRef: &mcpv1alpha1.ToolConfigRef{Name: "cfg"}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "img", ToolConfigRef: &mcpv1beta1.ToolConfigRef{Name: "cfg"}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "ns"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "img", ToolConfigRef: &mcpv1alpha1.ToolConfigRef{Name: "cfg"}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "img", ToolConfigRef: &mcpv1beta1.ToolConfigRef{Name: "cfg"}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "bravo", Namespace: "ns"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "img", ToolConfigRef: &mcpv1alpha1.ToolConfigRef{Name: "cfg"}}, + Spec: mcpv1beta1.MCPServerSpec{Image: "img", ToolConfigRef: &mcpv1beta1.ToolConfigRef{Name: "cfg"}}, }, } @@ -427,7 +427,7 @@ func TestFindWorkloadRefsFromMCPServers(t *testing.T) { fakeClient := builder.Build() refs, err := FindWorkloadRefsFromMCPServers(ctx, fakeClient, "ns", "cfg", - func(s *mcpv1alpha1.MCPServer) *string { + func(s *mcpv1beta1.MCPServer) *string { if s.Spec.ToolConfigRef != nil { return &s.Spec.ToolConfigRef.Name } @@ -451,7 +451,7 @@ func TestFindWorkloadRefsFromMCPServers(t *testing.T) { fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() refs, err := FindWorkloadRefsFromMCPServers(ctx, fakeClient, "ns", "cfg", - func(_ *mcpv1alpha1.MCPServer) *string { + func(_ *mcpv1beta1.MCPServer) *string { return nil }) @@ -464,34 +464,34 @@ func TestGetTelemetryConfigForMCPRemoteProxy(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) tests := []struct { name string - proxy *mcpv1alpha1.MCPRemoteProxy - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig + proxy *mcpv1beta1.MCPRemoteProxy + telemetryConfig *mcpv1beta1.MCPTelemetryConfig expectNil bool expectError bool expectedName string }{ { name: "nil ref returns nil without error", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "test-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{TelemetryConfigRef: nil}, + Spec: mcpv1beta1.MCPRemoteProxySpec{TelemetryConfigRef: nil}, }, expectNil: true, expectError: false, }, { name: "fetches referenced config", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "test-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "my-telemetry"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "my-telemetry"}, }, }, - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{Name: "my-telemetry", Namespace: "default"}, }, expectNil: false, @@ -500,10 +500,10 @@ func TestGetTelemetryConfigForMCPRemoteProxy(t *testing.T) { }, { name: "not found returns nil without error", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "test-proxy", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "missing"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "missing"}, }, }, expectNil: true, @@ -511,13 +511,13 @@ func TestGetTelemetryConfigForMCPRemoteProxy(t *testing.T) { }, { name: "cross-namespace returns nil (not found)", - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{Name: "test-proxy", Namespace: "namespace-b"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "shared-config"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "shared-config"}, }, }, - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{Name: "shared-config", Namespace: "namespace-a"}, }, expectNil: true, @@ -559,34 +559,34 @@ func TestGetTelemetryConfigForVirtualMCPServer(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) tests := []struct { name string - vmcp *mcpv1alpha1.VirtualMCPServer - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig + vmcp *mcpv1beta1.VirtualMCPServer + telemetryConfig *mcpv1beta1.MCPTelemetryConfig expectNil bool expectError bool expectedName string }{ { name: "nil ref returns nil without error", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{TelemetryConfigRef: nil}, + Spec: mcpv1beta1.VirtualMCPServerSpec{TelemetryConfigRef: nil}, }, expectNil: true, expectError: false, }, { name: "fetches referenced config", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "my-telemetry"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "my-telemetry"}, }, }, - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{Name: "my-telemetry", Namespace: "default"}, }, expectNil: false, @@ -595,10 +595,10 @@ func TestGetTelemetryConfigForVirtualMCPServer(t *testing.T) { }, { name: "not found returns nil without error", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "missing"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "missing"}, }, }, expectNil: true, @@ -606,13 +606,13 @@ func TestGetTelemetryConfigForVirtualMCPServer(t *testing.T) { }, { name: "cross-namespace returns nil (not found)", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "namespace-b"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "shared-config"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "shared-config"}, }, }, - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{Name: "shared-config", Namespace: "namespace-a"}, }, expectNil: true, diff --git a/cmd/thv-operator/pkg/controllerutil/oidc.go b/cmd/thv-operator/pkg/controllerutil/oidc.go index 6ada841fea..c2dbf05427 100644 --- a/cmd/thv-operator/pkg/controllerutil/oidc.go +++ b/cmd/thv-operator/pkg/controllerutil/oidc.go @@ -11,7 +11,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // GetOIDCConfigForServer fetches the MCPOIDCConfig referenced by an MCPServer. @@ -20,13 +20,13 @@ func GetOIDCConfigForServer( ctx context.Context, c client.Client, namespace string, - ref *mcpv1alpha1.MCPOIDCConfigReference, -) (*mcpv1alpha1.MCPOIDCConfig, error) { + ref *mcpv1beta1.MCPOIDCConfigReference, +) (*mcpv1beta1.MCPOIDCConfig, error) { if ref == nil { return nil, nil } - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{} + oidcConfig := &mcpv1beta1.MCPOIDCConfig{} if err := c.Get(ctx, types.NamespacedName{ Name: ref.Name, Namespace: namespace, @@ -44,7 +44,7 @@ func GenerateOIDCClientSecretEnvVar( ctx context.Context, c client.Client, namespace string, - clientSecretRef *mcpv1alpha1.SecretKeyRef, + clientSecretRef *mcpv1beta1.SecretKeyRef, ) (*corev1.EnvVar, error) { if clientSecretRef == nil { return nil, nil diff --git a/cmd/thv-operator/pkg/controllerutil/oidc_test.go b/cmd/thv-operator/pkg/controllerutil/oidc_test.go index 44f946a568..9af6ca5fc5 100644 --- a/cmd/thv-operator/pkg/controllerutil/oidc_test.go +++ b/cmd/thv-operator/pkg/controllerutil/oidc_test.go @@ -14,7 +14,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestGenerateOIDCClientSecretEnvVar(t *testing.T) { @@ -22,7 +22,7 @@ func TestGenerateOIDCClientSecretEnvVar(t *testing.T) { tests := []struct { name string - clientSecretRef *mcpv1alpha1.SecretKeyRef + clientSecretRef *mcpv1beta1.SecretKeyRef secret *corev1.Secret expectError bool errContains string @@ -39,7 +39,7 @@ func TestGenerateOIDCClientSecretEnvVar(t *testing.T) { }, { name: "valid secret ref generates env var", - clientSecretRef: &mcpv1alpha1.SecretKeyRef{ + clientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "oidc-secret", Key: "client-secret", }, @@ -65,7 +65,7 @@ func TestGenerateOIDCClientSecretEnvVar(t *testing.T) { }, { name: "missing secret returns error", - clientSecretRef: &mcpv1alpha1.SecretKeyRef{ + clientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "missing-secret", Key: "client-secret", }, @@ -74,7 +74,7 @@ func TestGenerateOIDCClientSecretEnvVar(t *testing.T) { }, { name: "missing key in secret returns error", - clientSecretRef: &mcpv1alpha1.SecretKeyRef{ + clientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "oidc-secret", Key: "wrong-key", }, @@ -99,7 +99,7 @@ func TestGenerateOIDCClientSecretEnvVar(t *testing.T) { scheme := runtime.NewScheme() err := corev1.AddToScheme(scheme) require.NoError(t, err) - err = mcpv1alpha1.AddToScheme(scheme) + err = mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) var fakeClient *fake.ClientBuilder diff --git a/cmd/thv-operator/pkg/controllerutil/oidc_volumes.go b/cmd/thv-operator/pkg/controllerutil/oidc_volumes.go index 60e692be14..abbc1d1d06 100644 --- a/cmd/thv-operator/pkg/controllerutil/oidc_volumes.go +++ b/cmd/thv-operator/pkg/controllerutil/oidc_volumes.go @@ -8,21 +8,21 @@ import ( corev1 "k8s.io/api/core/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/validation" ) // AddOIDCConfigRefCABundleVolumes returns volumes and volume mounts for OIDC CA bundle // from an MCPOIDCConfig's inline configuration. Returns nil slices if no CA bundle is configured. func AddOIDCConfigRefCABundleVolumes( - oidcConfig *mcpv1alpha1.MCPOIDCConfig, + oidcConfig *mcpv1beta1.MCPOIDCConfig, ) ([]corev1.Volume, []corev1.VolumeMount) { if oidcConfig == nil { return nil, nil } // Only inline type has CA bundle support - if oidcConfig.Spec.Type != mcpv1alpha1.MCPOIDCConfigTypeInline || oidcConfig.Spec.Inline == nil { + if oidcConfig.Spec.Type != mcpv1beta1.MCPOIDCConfigTypeInline || oidcConfig.Spec.Inline == nil { return nil, nil } diff --git a/cmd/thv-operator/pkg/controllerutil/podtemplatespec_builder.go b/cmd/thv-operator/pkg/controllerutil/podtemplatespec_builder.go index 942971f7be..e54792397f 100644 --- a/cmd/thv-operator/pkg/controllerutil/podtemplatespec_builder.go +++ b/cmd/thv-operator/pkg/controllerutil/podtemplatespec_builder.go @@ -11,7 +11,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // PodTemplateSpecBuilder provides an interface for building PodTemplateSpec patches. @@ -58,7 +58,7 @@ func (b *PodTemplateSpecBuilder) WithServiceAccount(serviceAccount *string) *Pod // WithSecrets adds secret environment variables to the target container. // The target container is specified by containerName in the constructor. -func (b *PodTemplateSpecBuilder) WithSecrets(secrets []mcpv1alpha1.SecretRef) *PodTemplateSpecBuilder { +func (b *PodTemplateSpecBuilder) WithSecrets(secrets []mcpv1beta1.SecretRef) *PodTemplateSpecBuilder { if len(secrets) == 0 { return b } diff --git a/cmd/thv-operator/pkg/controllerutil/podtemplatespec_builder_test.go b/cmd/thv-operator/pkg/controllerutil/podtemplatespec_builder_test.go index a2fdcb1d2a..8ca26afb40 100644 --- a/cmd/thv-operator/pkg/controllerutil/podtemplatespec_builder_test.go +++ b/cmd/thv-operator/pkg/controllerutil/podtemplatespec_builder_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) const testContainerName = "test-container" @@ -81,7 +81,7 @@ func TestPodTemplateSpecBuilder_Build(t *testing.T) { { name: "with secrets returns spec", setup: func(b *PodTemplateSpecBuilder) { - b.WithSecrets([]mcpv1alpha1.SecretRef{{Name: "secret", Key: "key"}}) + b.WithSecrets([]mcpv1beta1.SecretRef{{Name: "secret", Key: "key"}}) }, expectNil: false, }, @@ -144,7 +144,7 @@ func TestPodTemplateSpecBuilder_WithSecrets(t *testing.T) { require.NoError(t, err) builder.WithSecrets(nil) - builder.WithSecrets([]mcpv1alpha1.SecretRef{}) + builder.WithSecrets([]mcpv1beta1.SecretRef{}) assert.Empty(t, builder.spec.Spec.Containers) }) @@ -154,7 +154,7 @@ func TestPodTemplateSpecBuilder_WithSecrets(t *testing.T) { builder, err := NewPodTemplateSpecBuilder(nil, testContainerName) require.NoError(t, err) - secrets := []mcpv1alpha1.SecretRef{ + secrets := []mcpv1beta1.SecretRef{ {Name: "my-secret", Key: "API_KEY"}, {Name: "my-secret", Key: "password", TargetEnvName: "DB_PASSWORD"}, } @@ -183,7 +183,7 @@ func TestPodTemplateSpecBuilder_WithSecrets(t *testing.T) { builder, err := NewPodTemplateSpecBuilder(raw, testContainerName) require.NoError(t, err) - builder.WithSecrets([]mcpv1alpha1.SecretRef{{Name: "secret", Key: "NEW_KEY"}}) + builder.WithSecrets([]mcpv1beta1.SecretRef{{Name: "secret", Key: "NEW_KEY"}}) require.Len(t, builder.spec.Spec.Containers, 1) container := builder.spec.Spec.Containers[0] @@ -200,7 +200,7 @@ func TestPodTemplateSpecBuilder_WithSecrets(t *testing.T) { builder, err := NewPodTemplateSpecBuilder(raw, testContainerName) require.NoError(t, err) - builder.WithSecrets([]mcpv1alpha1.SecretRef{{Name: "secret", Key: "KEY"}}) + builder.WithSecrets([]mcpv1beta1.SecretRef{{Name: "secret", Key: "KEY"}}) require.Len(t, builder.spec.Spec.Containers, 2) assert.Equal(t, "other-container", builder.spec.Spec.Containers[0].Name) @@ -244,7 +244,7 @@ func TestPodTemplateSpecBuilder_Chaining(t *testing.T) { sa := "my-sa" result := builder. WithServiceAccount(&sa). - WithSecrets([]mcpv1alpha1.SecretRef{{Name: "secret", Key: "KEY"}}). + WithSecrets([]mcpv1beta1.SecretRef{{Name: "secret", Key: "KEY"}}). Build() require.NotNil(t, result) diff --git a/cmd/thv-operator/pkg/controllerutil/resources.go b/cmd/thv-operator/pkg/controllerutil/resources.go index 5fd165a00e..aa913e3ad4 100644 --- a/cmd/thv-operator/pkg/controllerutil/resources.go +++ b/cmd/thv-operator/pkg/controllerutil/resources.go @@ -13,13 +13,13 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/secrets" ) // BuildResourceRequirements builds Kubernetes resource requirements from CRD spec // Shared between MCPServer and MCPRemoteProxy -func BuildResourceRequirements(resourceSpec mcpv1alpha1.ResourceRequirements) corev1.ResourceRequirements { +func BuildResourceRequirements(resourceSpec mcpv1beta1.ResourceRequirements) corev1.ResourceRequirements { resources := corev1.ResourceRequirements{} if resourceSpec.Limits.CPU != "" || resourceSpec.Limits.Memory != "" { diff --git a/cmd/thv-operator/pkg/controllerutil/telemetry.go b/cmd/thv-operator/pkg/controllerutil/telemetry.go index cafcad18bc..0d11aa37ad 100644 --- a/cmd/thv-operator/pkg/controllerutil/telemetry.go +++ b/cmd/thv-operator/pkg/controllerutil/telemetry.go @@ -9,15 +9,15 @@ import ( corev1 "k8s.io/api/core/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // GenerateOpenTelemetryEnvVarsFromRef generates OpenTelemetry environment variables // from an MCPTelemetryConfig resource and its per-server reference overrides. // This includes OTEL_RESOURCE_ATTRIBUTES and secret-backed sensitive header env vars. func GenerateOpenTelemetryEnvVarsFromRef( - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig, - ref *mcpv1alpha1.MCPTelemetryConfigReference, + telemetryConfig *mcpv1beta1.MCPTelemetryConfig, + ref *mcpv1beta1.MCPTelemetryConfigReference, resourceName string, namespace string, ) []corev1.EnvVar { diff --git a/cmd/thv-operator/pkg/controllerutil/telemetry_test.go b/cmd/thv-operator/pkg/controllerutil/telemetry_test.go index 0f82e95533..3cbb56b668 100644 --- a/cmd/thv-operator/pkg/controllerutil/telemetry_test.go +++ b/cmd/thv-operator/pkg/controllerutil/telemetry_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestGenerateOpenTelemetryEnvVarsFromRef(t *testing.T) { @@ -18,8 +18,8 @@ func TestGenerateOpenTelemetryEnvVarsFromRef(t *testing.T) { tests := []struct { name string - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig - ref *mcpv1alpha1.MCPTelemetryConfigReference + telemetryConfig *mcpv1beta1.MCPTelemetryConfig + ref *mcpv1beta1.MCPTelemetryConfigReference resourceName string namespace string expectedEnvVars []corev1.EnvVar @@ -28,15 +28,15 @@ func TestGenerateOpenTelemetryEnvVarsFromRef(t *testing.T) { { name: "nil telemetryConfig returns nil", telemetryConfig: nil, - ref: &mcpv1alpha1.MCPTelemetryConfigReference{Name: "test-config"}, + ref: &mcpv1beta1.MCPTelemetryConfigReference{Name: "test-config"}, resourceName: "my-server", namespace: "default", expectedNilSlice: true, }, { name: "nil ref returns nil", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{}, + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{}, }, ref: nil, resourceName: "my-server", @@ -45,10 +45,10 @@ func TestGenerateOpenTelemetryEnvVarsFromRef(t *testing.T) { }, { name: "basic case with service name override", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{}, + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{}, }, - ref: &mcpv1alpha1.MCPTelemetryConfigReference{ + ref: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "test-config", ServiceName: "custom-service", }, @@ -63,10 +63,10 @@ func TestGenerateOpenTelemetryEnvVarsFromRef(t *testing.T) { }, { name: "empty ServiceName in ref falls back to resourceName", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{}, + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{}, }, - ref: &mcpv1alpha1.MCPTelemetryConfigReference{ + ref: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "test-config", ServiceName: "", }, @@ -81,20 +81,20 @@ func TestGenerateOpenTelemetryEnvVarsFromRef(t *testing.T) { }, { name: "sensitive headers produce env vars with SecretKeyRef", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ - SensitiveHeaders: []mcpv1alpha1.SensitiveHeader{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ + SensitiveHeaders: []mcpv1beta1.SensitiveHeader{ { Name: "Authorization", - SecretKeyRef: mcpv1alpha1.SecretKeyRef{ + SecretKeyRef: mcpv1beta1.SecretKeyRef{ Name: "otel-secret", Key: "auth-token", }, }, { Name: "X-API-Key", - SecretKeyRef: mcpv1alpha1.SecretKeyRef{ + SecretKeyRef: mcpv1beta1.SecretKeyRef{ Name: "api-secrets", Key: "api-key", }, @@ -103,7 +103,7 @@ func TestGenerateOpenTelemetryEnvVarsFromRef(t *testing.T) { }, }, }, - ref: &mcpv1alpha1.MCPTelemetryConfigReference{ + ref: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "test-config", ServiceName: "my-service", }, diff --git a/cmd/thv-operator/pkg/controllerutil/telemetry_volumes.go b/cmd/thv-operator/pkg/controllerutil/telemetry_volumes.go index 64e41d9579..63006865ce 100644 --- a/cmd/thv-operator/pkg/controllerutil/telemetry_volumes.go +++ b/cmd/thv-operator/pkg/controllerutil/telemetry_volumes.go @@ -8,7 +8,7 @@ import ( corev1 "k8s.io/api/core/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/validation" ) @@ -16,7 +16,7 @@ import ( // from an MCPTelemetryConfig's OpenTelemetry configuration. // Returns nil slices if no CA bundle is configured. func AddTelemetryCABundleVolumes( - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig, + telemetryConfig *mcpv1beta1.MCPTelemetryConfig, ) ([]corev1.Volume, []corev1.VolumeMount) { if telemetryConfig == nil || telemetryConfig.Spec.OpenTelemetry == nil || @@ -53,7 +53,7 @@ func AddTelemetryCABundleVolumes( // TelemetryCABundleFilePath returns the full file path where the CA bundle will be // mounted in the proxyrunner container, or empty string if no CA bundle is configured. func TelemetryCABundleFilePath( - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig, + telemetryConfig *mcpv1beta1.MCPTelemetryConfig, ) string { if telemetryConfig == nil || telemetryConfig.Spec.OpenTelemetry == nil || diff --git a/cmd/thv-operator/pkg/controllerutil/telemetry_volumes_test.go b/cmd/thv-operator/pkg/controllerutil/telemetry_volumes_test.go index 269af6821e..fd6a07f15a 100644 --- a/cmd/thv-operator/pkg/controllerutil/telemetry_volumes_test.go +++ b/cmd/thv-operator/pkg/controllerutil/telemetry_volumes_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestAddTelemetryCABundleVolumes(t *testing.T) { @@ -18,7 +18,7 @@ func TestAddTelemetryCABundleVolumes(t *testing.T) { tests := []struct { name string - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig + telemetryConfig *mcpv1beta1.MCPTelemetryConfig wantVolumeName string wantConfigMap string wantKey string @@ -30,15 +30,15 @@ func TestAddTelemetryCABundleVolumes(t *testing.T) { }, { name: "nil OpenTelemetry returns nil", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{}, + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{}, }, }, { name: "nil CABundleRef returns nil", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ Endpoint: "https://collector.example.com:4317", }, }, @@ -46,21 +46,21 @@ func TestAddTelemetryCABundleVolumes(t *testing.T) { }, { name: "nil ConfigMapRef returns nil", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ - CABundleRef: &mcpv1alpha1.CABundleSource{}, + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ + CABundleRef: &mcpv1beta1.CABundleSource{}, }, }, }, }, { name: "ConfigMapRef with default key", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ Endpoint: "https://collector.example.com:4317", - CABundleRef: &mcpv1alpha1.CABundleSource{ + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "my-ca-bundle"}, }, @@ -75,10 +75,10 @@ func TestAddTelemetryCABundleVolumes(t *testing.T) { }, { name: "ConfigMapRef with custom key", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ - CABundleRef: &mcpv1alpha1.CABundleSource{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "internal-ca"}, Key: "tls-ca.pem", @@ -130,7 +130,7 @@ func TestTelemetryCABundleFilePath(t *testing.T) { tests := []struct { name string - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig + telemetryConfig *mcpv1beta1.MCPTelemetryConfig wantPath string }{ { @@ -140,26 +140,26 @@ func TestTelemetryCABundleFilePath(t *testing.T) { }, { name: "nil OpenTelemetry returns empty", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{}, + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{}, }, wantPath: "", }, { name: "nil CABundleRef returns empty", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{}, + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{}, }, }, wantPath: "", }, { name: "nil ConfigMapRef returns empty", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ - CABundleRef: &mcpv1alpha1.CABundleSource{}, + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ + CABundleRef: &mcpv1beta1.CABundleSource{}, }, }, }, @@ -167,10 +167,10 @@ func TestTelemetryCABundleFilePath(t *testing.T) { }, { name: "default key produces correct path", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ - CABundleRef: &mcpv1alpha1.CABundleSource{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "my-ca-bundle"}, }, @@ -182,10 +182,10 @@ func TestTelemetryCABundleFilePath(t *testing.T) { }, { name: "custom key produces correct path", - telemetryConfig: &mcpv1alpha1.MCPTelemetryConfig{ - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ - CABundleRef: &mcpv1alpha1.CABundleSource{ + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "internal-ca"}, Key: "tls-ca.pem", diff --git a/cmd/thv-operator/pkg/controllerutil/tokenexchange.go b/cmd/thv-operator/pkg/controllerutil/tokenexchange.go index d0f715f132..9deff402e4 100644 --- a/cmd/thv-operator/pkg/controllerutil/tokenexchange.go +++ b/cmd/thv-operator/pkg/controllerutil/tokenexchange.go @@ -11,7 +11,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" "github.com/stacklok/toolhive/pkg/auth/awssts" "github.com/stacklok/toolhive/pkg/auth/remote" @@ -24,8 +24,8 @@ func GenerateTokenExchangeEnvVars( ctx context.Context, c client.Client, namespace string, - externalAuthConfigRef *mcpv1alpha1.ExternalAuthConfigRef, - getExternalAuthConfig func(context.Context, client.Client, string, string) (*mcpv1alpha1.MCPExternalAuthConfig, error), + externalAuthConfigRef *mcpv1beta1.ExternalAuthConfigRef, + getExternalAuthConfig func(context.Context, client.Client, string, string) (*mcpv1beta1.MCPExternalAuthConfig, error), ) ([]corev1.EnvVar, error) { var envVars []corev1.EnvVar @@ -42,7 +42,7 @@ func GenerateTokenExchangeEnvVars( return nil, fmt.Errorf("MCPExternalAuthConfig %s not found", externalAuthConfigRef.Name) } - if externalAuthConfig.Spec.Type != mcpv1alpha1.ExternalAuthTypeTokenExchange { + if externalAuthConfig.Spec.Type != mcpv1beta1.ExternalAuthTypeTokenExchange { return envVars, nil } @@ -83,7 +83,7 @@ func AddExternalAuthConfigOptions( c client.Client, namespace string, mcpServerName string, - externalAuthConfigRef *mcpv1alpha1.ExternalAuthConfigRef, + externalAuthConfigRef *mcpv1beta1.ExternalAuthConfigRef, oidcConfig *oidc.OIDCConfig, options *[]runner.RunConfigBuilderOption, ) error { @@ -99,20 +99,20 @@ func AddExternalAuthConfigOptions( // Handle different auth types switch externalAuthConfig.Spec.Type { - case mcpv1alpha1.ExternalAuthTypeTokenExchange: + case mcpv1beta1.ExternalAuthTypeTokenExchange: return addTokenExchangeConfig(ctx, c, namespace, externalAuthConfig, options) - case mcpv1alpha1.ExternalAuthTypeHeaderInjection: + case mcpv1beta1.ExternalAuthTypeHeaderInjection: return addHeaderInjectionConfig(ctx, c, namespace, externalAuthConfig, options) - case mcpv1alpha1.ExternalAuthTypeBearerToken: + case mcpv1beta1.ExternalAuthTypeBearerToken: return addBearerTokenConfig(ctx, c, namespace, externalAuthConfig, options) - case mcpv1alpha1.ExternalAuthTypeUnauthenticated: + case mcpv1beta1.ExternalAuthTypeUnauthenticated: // No config to add for unauthenticated return nil - case mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer: + case mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer: return AddEmbeddedAuthServerConfigOptions(ctx, c, namespace, mcpServerName, externalAuthConfigRef, oidcConfig, options) - case mcpv1alpha1.ExternalAuthTypeAWSSts: + case mcpv1beta1.ExternalAuthTypeAWSSts: return addAWSStsConfig(externalAuthConfig, options) - case mcpv1alpha1.ExternalAuthTypeUpstreamInject: + case mcpv1beta1.ExternalAuthTypeUpstreamInject: // Upstream inject is handled by the vMCP converter at runtime return nil default: @@ -124,7 +124,7 @@ func addTokenExchangeConfig( ctx context.Context, c client.Client, namespace string, - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig, + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig, options *[]runner.RunConfigBuilderOption, ) error { tokenExchangeSpec := externalAuthConfig.Spec.TokenExchange @@ -188,7 +188,7 @@ func addHeaderInjectionConfig( _ context.Context, _ client.Client, _ string, - _ *mcpv1alpha1.MCPExternalAuthConfig, + _ *mcpv1beta1.MCPExternalAuthConfig, _ *[]runner.RunConfigBuilderOption, ) error { // Header injection for MCPServer is not yet implemented @@ -203,7 +203,7 @@ func addBearerTokenConfig( ctx context.Context, c client.Client, namespace string, - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig, + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig, options *[]runner.RunConfigBuilderOption, ) error { bearerTokenSpec := externalAuthConfig.Spec.BearerToken @@ -250,8 +250,8 @@ func GenerateBearerTokenEnvVar( ctx context.Context, c client.Client, namespace string, - externalAuthConfigRef *mcpv1alpha1.ExternalAuthConfigRef, - getExternalAuthConfig func(context.Context, client.Client, string, string) (*mcpv1alpha1.MCPExternalAuthConfig, error), + externalAuthConfigRef *mcpv1beta1.ExternalAuthConfigRef, + getExternalAuthConfig func(context.Context, client.Client, string, string) (*mcpv1beta1.MCPExternalAuthConfig, error), ) ([]corev1.EnvVar, error) { var envVars []corev1.EnvVar @@ -268,7 +268,7 @@ func GenerateBearerTokenEnvVar( return nil, fmt.Errorf("MCPExternalAuthConfig %s not found", externalAuthConfigRef.Name) } - if externalAuthConfig.Spec.Type != mcpv1alpha1.ExternalAuthTypeBearerToken { + if externalAuthConfig.Spec.Type != mcpv1beta1.ExternalAuthTypeBearerToken { return envVars, nil } @@ -298,7 +298,7 @@ func GenerateBearerTokenEnvVar( // addAWSStsConfig adds AWS STS configuration to runner options // This enables OIDC token exchange for AWS credentials using AssumeRoleWithWebIdentity func addAWSStsConfig( - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig, + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig, options *[]runner.RunConfigBuilderOption, ) error { awsStsSpec := externalAuthConfig.Spec.AWSSts diff --git a/cmd/thv-operator/pkg/controllerutil/tools_config.go b/cmd/thv-operator/pkg/controllerutil/tools_config.go index 8148ddaa35..4aa686fb85 100644 --- a/cmd/thv-operator/pkg/controllerutil/tools_config.go +++ b/cmd/thv-operator/pkg/controllerutil/tools_config.go @@ -11,22 +11,22 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // GetToolConfigForMCPServer retrieves the MCPToolConfig referenced by an MCPServer func GetToolConfigForMCPServer( ctx context.Context, c client.Client, - mcpServer *mcpv1alpha1.MCPServer, -) (*mcpv1alpha1.MCPToolConfig, error) { + mcpServer *mcpv1beta1.MCPServer, +) (*mcpv1beta1.MCPToolConfig, error) { if mcpServer.Spec.ToolConfigRef == nil { // We throw an error because in this case you assume there is a ToolConfig // but there isn't one referenced. return nil, fmt.Errorf("MCPServer %s does not reference a MCPToolConfig", mcpServer.Name) } - toolConfig := &mcpv1alpha1.MCPToolConfig{} + toolConfig := &mcpv1beta1.MCPToolConfig{} err := c.Get(ctx, types.NamespacedName{ Name: mcpServer.Spec.ToolConfigRef.Name, Namespace: mcpServer.Namespace, // Same namespace as MCPServer diff --git a/cmd/thv-operator/pkg/controllerutil/tools_config_test.go b/cmd/thv-operator/pkg/controllerutil/tools_config_test.go index b9cbbce951..a4d2df1e9f 100644 --- a/cmd/thv-operator/pkg/controllerutil/tools_config_test.go +++ b/cmd/thv-operator/pkg/controllerutil/tools_config_test.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestGetToolConfigForMCPServer(t *testing.T) { @@ -25,19 +25,19 @@ func TestGetToolConfigForMCPServer(t *testing.T) { tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer - existingConfig *mcpv1alpha1.MCPToolConfig + mcpServer *mcpv1beta1.MCPServer + existingConfig *mcpv1beta1.MCPToolConfig expectConfig bool expectError bool }{ { name: "mcpserver without toolconfig ref", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", }, }, @@ -46,24 +46,24 @@ func TestGetToolConfigForMCPServer(t *testing.T) { }, { name: "mcpserver with existing toolconfig", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "test-config", }, }, }, - existingConfig: &mcpv1alpha1.MCPToolConfig{ + existingConfig: &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1"}, }, }, @@ -72,14 +72,14 @@ func TestGetToolConfigForMCPServer(t *testing.T) { }, { name: "mcpserver with non-existent toolconfig", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "non-existent", }, }, @@ -96,7 +96,7 @@ func TestGetToolConfigForMCPServer(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) objs := []client.Object{} if tt.existingConfig != nil { @@ -151,16 +151,16 @@ func TestGetToolConfigForMCPServer_ErrorScenarios(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "missing-config", }, }, @@ -183,16 +183,16 @@ func TestGetToolConfigForMCPServer_ErrorScenarios(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image", - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "test-config", }, }, diff --git a/cmd/thv-operator/pkg/oidc/mocks/mock_resolver.go b/cmd/thv-operator/pkg/oidc/mocks/mock_resolver.go index 1175c8155e..1ef494163d 100644 --- a/cmd/thv-operator/pkg/oidc/mocks/mock_resolver.go +++ b/cmd/thv-operator/pkg/oidc/mocks/mock_resolver.go @@ -13,7 +13,7 @@ import ( context "context" reflect "reflect" - v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" oidc "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" gomock "go.uber.org/mock/gomock" ) @@ -43,7 +43,7 @@ func (m *MockResolver) EXPECT() *MockResolverMockRecorder { } // ResolveFromConfigRef mocks base method. -func (m *MockResolver) ResolveFromConfigRef(ctx context.Context, oidcConfigRef *v1alpha1.MCPOIDCConfigReference, oidcConfig *v1alpha1.MCPOIDCConfig, serverName, namespace string, proxyPort int32) (*oidc.OIDCConfig, error) { +func (m *MockResolver) ResolveFromConfigRef(ctx context.Context, oidcConfigRef *v1beta1.MCPOIDCConfigReference, oidcConfig *v1beta1.MCPOIDCConfig, serverName, namespace string, proxyPort int32) (*oidc.OIDCConfig, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ResolveFromConfigRef", ctx, oidcConfigRef, oidcConfig, serverName, namespace, proxyPort) ret0, _ := ret[0].(*oidc.OIDCConfig) diff --git a/cmd/thv-operator/pkg/oidc/resolver.go b/cmd/thv-operator/pkg/oidc/resolver.go index 8cb045ff9e..4766889f60 100644 --- a/cmd/thv-operator/pkg/oidc/resolver.go +++ b/cmd/thv-operator/pkg/oidc/resolver.go @@ -11,7 +11,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/validation" ) @@ -48,8 +48,8 @@ type Resolver interface { // per-server overrides (audience, scopes) from the reference. ResolveFromConfigRef( ctx context.Context, - oidcConfigRef *mcpv1alpha1.MCPOIDCConfigReference, - oidcConfig *mcpv1alpha1.MCPOIDCConfig, + oidcConfigRef *mcpv1beta1.MCPOIDCConfigReference, + oidcConfig *mcpv1beta1.MCPOIDCConfig, serverName, namespace string, proxyPort int32, ) (*OIDCConfig, error) @@ -73,8 +73,8 @@ type resolver struct { // (audience, scopes) from the MCPOIDCConfigReference. func (r *resolver) ResolveFromConfigRef( ctx context.Context, - ref *mcpv1alpha1.MCPOIDCConfigReference, - oidcCfg *mcpv1alpha1.MCPOIDCConfig, + ref *mcpv1beta1.MCPOIDCConfigReference, + oidcCfg *mcpv1beta1.MCPOIDCConfig, serverName, namespace string, proxyPort int32, ) (*OIDCConfig, error) { @@ -88,9 +88,9 @@ func (r *resolver) ResolveFromConfigRef( } switch oidcCfg.Spec.Type { - case mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount: + case mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount: return r.resolveFromK8sServiceAccountConfig(ctx, oidcCfg.Spec.KubernetesServiceAccount, ref, resourceURL) - case mcpv1alpha1.MCPOIDCConfigTypeInline: + case mcpv1beta1.MCPOIDCConfigTypeInline: return r.resolveFromInlineSharedConfig(oidcCfg.Spec.Inline, ref, resourceURL) default: return nil, fmt.Errorf("unknown MCPOIDCConfig type: %s", oidcCfg.Spec.Type) @@ -101,15 +101,15 @@ func (r *resolver) ResolveFromConfigRef( // with per-server audience override from the MCPOIDCConfigReference. func (*resolver) resolveFromK8sServiceAccountConfig( ctx context.Context, - config *mcpv1alpha1.KubernetesServiceAccountOIDCConfig, - ref *mcpv1alpha1.MCPOIDCConfigReference, + config *mcpv1beta1.KubernetesServiceAccountOIDCConfig, + ref *mcpv1beta1.MCPOIDCConfigReference, resourceURL string, ) (*OIDCConfig, error) { if config == nil { ctxLogger := log.FromContext(ctx) ctxLogger.Info("KubernetesServiceAccount OIDCConfig is nil, using defaults") defaultUseClusterAuth := true - config = &mcpv1alpha1.KubernetesServiceAccountOIDCConfig{ + config = &mcpv1beta1.KubernetesServiceAccountOIDCConfig{ UseClusterAuth: &defaultUseClusterAuth, } } @@ -146,8 +146,8 @@ func (*resolver) resolveFromK8sServiceAccountConfig( // resolveFromInlineSharedConfig resolves OIDC config from a shared inline config // with per-server audience and scopes override from the MCPOIDCConfigReference. func (*resolver) resolveFromInlineSharedConfig( - config *mcpv1alpha1.InlineOIDCSharedConfig, - ref *mcpv1alpha1.MCPOIDCConfigReference, + config *mcpv1beta1.InlineOIDCSharedConfig, + ref *mcpv1beta1.MCPOIDCConfigReference, resourceURL string, ) (*OIDCConfig, error) { if config == nil { @@ -176,7 +176,7 @@ func (*resolver) resolveFromInlineSharedConfig( // computeCABundlePath computes the CA bundle mount path from a CABundleSource. // Returns empty string if caBundleRef is nil or has no ConfigMapRef. -func computeCABundlePath(caBundleRef *mcpv1alpha1.CABundleSource) string { +func computeCABundlePath(caBundleRef *mcpv1beta1.CABundleSource) string { if caBundleRef == nil || caBundleRef.ConfigMapRef == nil { return "" } diff --git a/cmd/thv-operator/pkg/oidc/resolver_configref_test.go b/cmd/thv-operator/pkg/oidc/resolver_configref_test.go index 8e4121b481..c03a5df58e 100644 --- a/cmd/thv-operator/pkg/oidc/resolver_configref_test.go +++ b/cmd/thv-operator/pkg/oidc/resolver_configref_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestResolveFromConfigRef_NilInputs(t *testing.T) { @@ -20,7 +20,7 @@ func TestResolveFromConfigRef_NilInputs(t *testing.T) { t.Run("nil ref", func(t *testing.T) { t.Parallel() result, err := resolver.ResolveFromConfigRef( - t.Context(), nil, &mcpv1alpha1.MCPOIDCConfig{}, + t.Context(), nil, &mcpv1beta1.MCPOIDCConfig{}, "s", "ns", 8080, ) require.NoError(t, err) @@ -31,7 +31,7 @@ func TestResolveFromConfigRef_NilInputs(t *testing.T) { t.Parallel() result, err := resolver.ResolveFromConfigRef( t.Context(), - &mcpv1alpha1.MCPOIDCConfigReference{Name: "x", Audience: "a"}, + &mcpv1beta1.MCPOIDCConfigReference{Name: "x", Audience: "a"}, nil, "s", "ns", 8080, ) require.NoError(t, err) @@ -44,19 +44,19 @@ func TestResolveFromConfigRef_KubernetesServiceAccountType(t *testing.T) { tests := []struct { name string - ref *mcpv1alpha1.MCPOIDCConfigReference - oidcCfg *mcpv1alpha1.MCPOIDCConfig + ref *mcpv1beta1.MCPOIDCConfigReference + oidcCfg *mcpv1beta1.MCPOIDCConfig expected *OIDCConfig }{ { name: "audience and scopes from ref with explicit issuer", - ref: &mcpv1alpha1.MCPOIDCConfigReference{ + ref: &mcpv1beta1.MCPOIDCConfigReference{ Name: "k", Audience: "my-aud", Scopes: []string{"openid"}, }, - oidcCfg: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount, - KubernetesServiceAccount: &mcpv1alpha1.KubernetesServiceAccountOIDCConfig{ + oidcCfg: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount, + KubernetesServiceAccount: &mcpv1beta1.KubernetesServiceAccountOIDCConfig{ Issuer: "https://kubernetes.default.svc", }, }, @@ -72,14 +72,14 @@ func TestResolveFromConfigRef_KubernetesServiceAccountType(t *testing.T) { }, { name: "empty resourceUrl falls back to derived service URL", - ref: &mcpv1alpha1.MCPOIDCConfigReference{ + ref: &mcpv1beta1.MCPOIDCConfigReference{ Name: "k", Audience: "my-aud", Scopes: []string{"openid"}, ResourceURL: "", }, - oidcCfg: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount, - KubernetesServiceAccount: &mcpv1alpha1.KubernetesServiceAccountOIDCConfig{ + oidcCfg: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount, + KubernetesServiceAccount: &mcpv1beta1.KubernetesServiceAccountOIDCConfig{ Issuer: "https://kubernetes.default.svc", }, }, @@ -95,12 +95,12 @@ func TestResolveFromConfigRef_KubernetesServiceAccountType(t *testing.T) { }, { name: "nil KSA config falls back to all defaults", - ref: &mcpv1alpha1.MCPOIDCConfigReference{ + ref: &mcpv1beta1.MCPOIDCConfigReference{ Name: "k", Audience: "aud", }, - oidcCfg: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount, + oidcCfg: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount, KubernetesServiceAccount: nil, }, }, @@ -114,14 +114,14 @@ func TestResolveFromConfigRef_KubernetesServiceAccountType(t *testing.T) { }, { name: "explicit resourceUrl overrides derived service URL", - ref: &mcpv1alpha1.MCPOIDCConfigReference{ + ref: &mcpv1beta1.MCPOIDCConfigReference{ Name: "k", Audience: "my-aud", Scopes: []string{"openid"}, ResourceURL: "https://mcp-gateway.example.com/mcp", }, - oidcCfg: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount, - KubernetesServiceAccount: &mcpv1alpha1.KubernetesServiceAccountOIDCConfig{ + oidcCfg: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount, + KubernetesServiceAccount: &mcpv1beta1.KubernetesServiceAccountOIDCConfig{ Issuer: "https://kubernetes.default.svc", }, }, @@ -137,13 +137,13 @@ func TestResolveFromConfigRef_KubernetesServiceAccountType(t *testing.T) { }, { name: "UseClusterAuth false omits CA and token paths", - ref: &mcpv1alpha1.MCPOIDCConfigReference{ + ref: &mcpv1beta1.MCPOIDCConfigReference{ Name: "k", Audience: "aud", }, - oidcCfg: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount, - KubernetesServiceAccount: &mcpv1alpha1.KubernetesServiceAccountOIDCConfig{ + oidcCfg: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount, + KubernetesServiceAccount: &mcpv1beta1.KubernetesServiceAccountOIDCConfig{ Issuer: "https://custom", UseClusterAuth: boolPtr(false), }, @@ -175,19 +175,19 @@ func TestResolveFromConfigRef_InlineType(t *testing.T) { tests := []struct { name string - ref *mcpv1alpha1.MCPOIDCConfigReference - oidcCfg *mcpv1alpha1.MCPOIDCConfig + ref *mcpv1beta1.MCPOIDCConfigReference + oidcCfg *mcpv1beta1.MCPOIDCConfig expected *OIDCConfig }{ { name: "audience and scopes from ref with shared inline config", - ref: &mcpv1alpha1.MCPOIDCConfigReference{ + ref: &mcpv1beta1.MCPOIDCConfigReference{ Name: "i", Audience: "inline-aud", Scopes: []string{"openid", "email"}, }, - oidcCfg: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + oidcCfg: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "gid", }, @@ -202,13 +202,13 @@ func TestResolveFromConfigRef_InlineType(t *testing.T) { }, { name: "protectedResourceAllowPrivateIP propagated from shared inline config", - ref: &mcpv1alpha1.MCPOIDCConfigReference{ + ref: &mcpv1beta1.MCPOIDCConfigReference{ Name: "i", Audience: "inline-aud", }, - oidcCfg: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + oidcCfg: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "gid", ProtectedResourceAllowPrivateIP: true, @@ -227,14 +227,14 @@ func TestResolveFromConfigRef_InlineType(t *testing.T) { }, { name: "explicit resourceUrl overrides derived service URL for inline config", - ref: &mcpv1alpha1.MCPOIDCConfigReference{ + ref: &mcpv1beta1.MCPOIDCConfigReference{ Name: "i", Audience: "inline-aud", Scopes: []string{"openid"}, ResourceURL: "https://mcp.corp.internal/tools", }, - oidcCfg: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + oidcCfg: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "gid", }, @@ -249,12 +249,12 @@ func TestResolveFromConfigRef_InlineType(t *testing.T) { }, { name: "nil inline config returns nil", - ref: &mcpv1alpha1.MCPOIDCConfigReference{ + ref: &mcpv1beta1.MCPOIDCConfigReference{ Name: "i", Audience: "aud", }, - oidcCfg: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, Inline: nil, + oidcCfg: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, Inline: nil, }, }, expected: nil, @@ -281,9 +281,9 @@ func TestResolveFromConfigRef_UnknownType(t *testing.T) { resolver := NewResolver(nil) result, err := resolver.ResolveFromConfigRef( t.Context(), - &mcpv1alpha1.MCPOIDCConfigReference{Name: "x", Audience: "a"}, - &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{Type: "bad"}, + &mcpv1beta1.MCPOIDCConfigReference{Name: "x", Audience: "a"}, + &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{Type: "bad"}, }, "srv", "default", 8080, ) diff --git a/cmd/thv-operator/pkg/registryapi/deployment.go b/cmd/thv-operator/pkg/registryapi/deployment.go index 7700e65b8b..0580191c9b 100644 --- a/cmd/thv-operator/pkg/registryapi/deployment.go +++ b/cmd/thv-operator/pkg/registryapi/deployment.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" ) @@ -86,7 +86,7 @@ func (*manager) CheckAPIReadiness(ctx context.Context, deployment *appsv1.Deploy // updates (preserving Spec.Replicas for HPA compatibility), or skips if already up-to-date. func (m *manager) upsertDeployment( ctx context.Context, - mcpRegistry *mcpv1alpha1.MCPRegistry, + mcpRegistry *mcpv1beta1.MCPRegistry, deployment *appsv1.Deployment, ) (*appsv1.Deployment, error) { ctxLogger := log.FromContext(ctx).WithValues("mcpregistry", mcpRegistry.Name) @@ -158,7 +158,7 @@ func (m *manager) upsertDeployment( // logic to upsertDeployment. func (m *manager) ensureDeployment( ctx context.Context, - mcpRegistry *mcpv1alpha1.MCPRegistry, + mcpRegistry *mcpv1beta1.MCPRegistry, configMapName string, ) (*appsv1.Deployment, error) { deployment, err := m.buildRegistryAPIDeployment(ctx, mcpRegistry, configMapName) @@ -174,7 +174,7 @@ func (m *manager) ensureDeployment( // and PGPassSecretRef. func (*manager) buildRegistryAPIDeployment( ctx context.Context, - mcpRegistry *mcpv1alpha1.MCPRegistry, + mcpRegistry *mcpv1beta1.MCPRegistry, configMapName string, ) (*appsv1.Deployment, error) { ctxLogger := log.FromContext(ctx).WithValues("mcpregistry", mcpRegistry.Name) diff --git a/cmd/thv-operator/pkg/registryapi/deployment_test.go b/cmd/thv-operator/pkg/registryapi/deployment_test.go index fe140e3eea..c3f3fe0f96 100644 --- a/cmd/thv-operator/pkg/registryapi/deployment_test.go +++ b/cmd/thv-operator/pkg/registryapi/deployment_test.go @@ -14,7 +14,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestGetRegistryAPIImage(t *testing.T) { @@ -534,12 +534,12 @@ func TestBuildRegistryAPIDeployment_PodTemplateSpecHash(t *testing.T) { t.Run("no podtemplatespec has no hash annotation", func(t *testing.T) { t.Parallel() mgr := &manager{} - mcpRegistry := &mcpv1alpha1.MCPRegistry{ + mcpRegistry := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-registry", Namespace: "test-namespace", }, - Spec: mcpv1alpha1.MCPRegistrySpec{ + Spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: baseConfigYAML, }, } @@ -554,12 +554,12 @@ func TestBuildRegistryAPIDeployment_PodTemplateSpecHash(t *testing.T) { t.Run("with podtemplatespec has hash annotation", func(t *testing.T) { t.Parallel() mgr := &manager{} - mcpRegistry := &mcpv1alpha1.MCPRegistry{ + mcpRegistry := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-registry", Namespace: "test-namespace", }, - Spec: mcpv1alpha1.MCPRegistrySpec{ + Spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: baseConfigYAML, PodTemplateSpec: &runtime.RawExtension{ Raw: []byte(`{"spec":{"imagePullSecrets":[{"name":"registry-creds"}]}}`), @@ -579,16 +579,16 @@ func TestBuildRegistryAPIDeployment_PodTemplateSpecHash(t *testing.T) { t.Parallel() mgr := &manager{} - registry1 := &mcpv1alpha1.MCPRegistry{ + registry1 := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"}, - Spec: mcpv1alpha1.MCPRegistrySpec{ + Spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: baseConfigYAML, PodTemplateSpec: &runtime.RawExtension{Raw: []byte(`{"spec":{"imagePullSecrets":[{"name":"creds-a"}]}}`)}, }, } - registry2 := &mcpv1alpha1.MCPRegistry{ + registry2 := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"}, - Spec: mcpv1alpha1.MCPRegistrySpec{ + Spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: baseConfigYAML, PodTemplateSpec: &runtime.RawExtension{Raw: []byte(`{"spec":{"imagePullSecrets":[{"name":"creds-b"}]}}`)}, }, diff --git a/cmd/thv-operator/pkg/registryapi/manager.go b/cmd/thv-operator/pkg/registryapi/manager.go index 9878496405..b3a92e924e 100644 --- a/cmd/thv-operator/pkg/registryapi/manager.go +++ b/cmd/thv-operator/pkg/registryapi/manager.go @@ -12,7 +12,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/kubernetes" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/kubernetes/configmaps" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/registryapi/config" @@ -44,7 +44,7 @@ func NewManager( // It creates a ConfigMap from the raw ConfigYAML string and mounts user-provided volumes directly, // without parsing or transforming config. func (m *manager) ReconcileAPIService( - ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry, + ctx context.Context, mcpRegistry *mcpv1beta1.MCPRegistry, ) *Error { ctxLogger := log.FromContext(ctx).WithValues("mcpregistry", mcpRegistry.Name) ctxLogger.Info("Reconciling API service") @@ -117,7 +117,7 @@ func (m *manager) ReconcileAPIService( } // IsAPIReady checks if the registry API deployment is ready and serving requests -func (m *manager) IsAPIReady(ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry) bool { +func (m *manager) IsAPIReady(ctx context.Context, mcpRegistry *mcpv1beta1.MCPRegistry) bool { ctxLogger := log.FromContext(ctx).WithValues("mcpregistry", mcpRegistry.Name) deploymentName := mcpRegistry.GetAPIResourceName() @@ -139,7 +139,7 @@ func (m *manager) IsAPIReady(ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRe // GetReadyReplicas returns the number of ready replicas for the registry API deployment. // Returns 0 if the deployment is not found or an error occurs. -func (m *manager) GetReadyReplicas(ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry) int32 { +func (m *manager) GetReadyReplicas(ctx context.Context, mcpRegistry *mcpv1beta1.MCPRegistry) int32 { ctxLogger := log.FromContext(ctx).WithValues("mcpregistry", mcpRegistry.Name) deploymentName := mcpRegistry.GetAPIResourceName() @@ -159,7 +159,7 @@ func (m *manager) GetReadyReplicas(ctx context.Context, mcpRegistry *mcpv1alpha1 } // GetAPIStatus returns the readiness state and ready replica count from a single Deployment fetch. -func (m *manager) GetAPIStatus(ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry) (bool, int32) { +func (m *manager) GetAPIStatus(ctx context.Context, mcpRegistry *mcpv1beta1.MCPRegistry) (bool, int32) { ctxLogger := log.FromContext(ctx).WithValues("mcpregistry", mcpRegistry.Name) deploymentName := mcpRegistry.GetAPIResourceName() @@ -178,7 +178,7 @@ func (m *manager) GetAPIStatus(ctx context.Context, mcpRegistry *mcpv1alpha1.MCP } // labelsForRegistryAPI generates standard labels for registry API resources -func labelsForRegistryAPI(mcpRegistry *mcpv1alpha1.MCPRegistry, resourceName string) map[string]string { +func labelsForRegistryAPI(mcpRegistry *mcpv1beta1.MCPRegistry, resourceName string) map[string]string { return map[string]string{ "app.kubernetes.io/name": resourceName, "app.kubernetes.io/component": "registry-api", diff --git a/cmd/thv-operator/pkg/registryapi/manager_test.go b/cmd/thv-operator/pkg/registryapi/manager_test.go index e3a2f2bc9e..8ac86b51a9 100644 --- a/cmd/thv-operator/pkg/registryapi/manager_test.go +++ b/cmd/thv-operator/pkg/registryapi/manager_test.go @@ -21,7 +21,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestNewManager(t *testing.T) { @@ -67,7 +67,7 @@ func TestReconcileAPIService(t *testing.T) { // Create scheme and fake client scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) @@ -77,12 +77,12 @@ func TestReconcileAPIService(t *testing.T) { Build() // Create test MCPRegistry with configYAML - mcpRegistry := &mcpv1alpha1.MCPRegistry{ + mcpRegistry := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-registry", Namespace: "test-namespace", }, - Spec: mcpv1alpha1.MCPRegistrySpec{ + Spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n format: toolhive\n syncPolicy:\n interval: 10m\nregistries:\n - name: default\n sources: [\"default\"]\n", }, } @@ -131,7 +131,7 @@ func TestReconcileAPIService(t *testing.T) { // Create scheme and a client that will fail on ConfigMap operations scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) @@ -148,12 +148,12 @@ func TestReconcileAPIService(t *testing.T) { Build() // Create test MCPRegistry with configYAML - mcpRegistry := &mcpv1alpha1.MCPRegistry{ + mcpRegistry := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-registry", Namespace: "test-namespace", }, - Spec: mcpv1alpha1.MCPRegistrySpec{ + Spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n format: toolhive\n", }, } diff --git a/cmd/thv-operator/pkg/registryapi/mocks/mock_manager.go b/cmd/thv-operator/pkg/registryapi/mocks/mock_manager.go index e041af5184..9e46d5f193 100644 --- a/cmd/thv-operator/pkg/registryapi/mocks/mock_manager.go +++ b/cmd/thv-operator/pkg/registryapi/mocks/mock_manager.go @@ -13,7 +13,7 @@ import ( context "context" reflect "reflect" - v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" registryapi "github.com/stacklok/toolhive/cmd/thv-operator/pkg/registryapi" gomock "go.uber.org/mock/gomock" v1 "k8s.io/api/apps/v1" @@ -58,7 +58,7 @@ func (mr *MockManagerMockRecorder) CheckAPIReadiness(ctx, deployment any) *gomoc } // GetAPIStatus mocks base method. -func (m *MockManager) GetAPIStatus(ctx context.Context, mcpRegistry *v1alpha1.MCPRegistry) (bool, int32) { +func (m *MockManager) GetAPIStatus(ctx context.Context, mcpRegistry *v1beta1.MCPRegistry) (bool, int32) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAPIStatus", ctx, mcpRegistry) ret0, _ := ret[0].(bool) @@ -73,7 +73,7 @@ func (mr *MockManagerMockRecorder) GetAPIStatus(ctx, mcpRegistry any) *gomock.Ca } // GetReadyReplicas mocks base method. -func (m *MockManager) GetReadyReplicas(ctx context.Context, mcpRegistry *v1alpha1.MCPRegistry) int32 { +func (m *MockManager) GetReadyReplicas(ctx context.Context, mcpRegistry *v1beta1.MCPRegistry) int32 { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetReadyReplicas", ctx, mcpRegistry) ret0, _ := ret[0].(int32) @@ -87,7 +87,7 @@ func (mr *MockManagerMockRecorder) GetReadyReplicas(ctx, mcpRegistry any) *gomoc } // IsAPIReady mocks base method. -func (m *MockManager) IsAPIReady(ctx context.Context, mcpRegistry *v1alpha1.MCPRegistry) bool { +func (m *MockManager) IsAPIReady(ctx context.Context, mcpRegistry *v1beta1.MCPRegistry) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "IsAPIReady", ctx, mcpRegistry) ret0, _ := ret[0].(bool) @@ -101,7 +101,7 @@ func (mr *MockManagerMockRecorder) IsAPIReady(ctx, mcpRegistry any) *gomock.Call } // ReconcileAPIService mocks base method. -func (m *MockManager) ReconcileAPIService(ctx context.Context, mcpRegistry *v1alpha1.MCPRegistry) *registryapi.Error { +func (m *MockManager) ReconcileAPIService(ctx context.Context, mcpRegistry *v1beta1.MCPRegistry) *registryapi.Error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ReconcileAPIService", ctx, mcpRegistry) ret0, _ := ret[0].(*registryapi.Error) diff --git a/cmd/thv-operator/pkg/registryapi/rbac.go b/cmd/thv-operator/pkg/registryapi/rbac.go index 31b0dd6b0a..c19c4aca9d 100644 --- a/cmd/thv-operator/pkg/registryapi/rbac.go +++ b/cmd/thv-operator/pkg/registryapi/rbac.go @@ -9,7 +9,7 @@ import ( rbacv1 "k8s.io/api/rbac/v1" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/kubernetes/rbac" ) @@ -68,7 +68,7 @@ var registryAPIRBACRules = []rbacv1.PolicyRule{ // when the MCPRegistry is deleted. func (m *manager) ensureRBACResources( ctx context.Context, - mcpRegistry *mcpv1alpha1.MCPRegistry, + mcpRegistry *mcpv1beta1.MCPRegistry, ) error { ctxLogger := log.FromContext(ctx).WithValues("mcpregistry", mcpRegistry.Name) ctxLogger.Info("Ensuring RBAC resources for registry API") diff --git a/cmd/thv-operator/pkg/registryapi/rbac_test.go b/cmd/thv-operator/pkg/registryapi/rbac_test.go index 4e65f371fd..1614c75989 100644 --- a/cmd/thv-operator/pkg/registryapi/rbac_test.go +++ b/cmd/thv-operator/pkg/registryapi/rbac_test.go @@ -19,17 +19,17 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) -func createTestMCPRegistry() *mcpv1alpha1.MCPRegistry { - return &mcpv1alpha1.MCPRegistry{ +func createTestMCPRegistry() *mcpv1beta1.MCPRegistry { + return &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-registry", Namespace: "test-namespace", UID: types.UID("test-uid"), }, - Spec: mcpv1alpha1.MCPRegistrySpec{ + Spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n format: toolhive\nregistries:\n - name: default\n sources: [\"default\"]\n", }, } @@ -37,7 +37,7 @@ func createTestMCPRegistry() *mcpv1alpha1.MCPRegistry { func createTestScheme() *runtime.Scheme { scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) return scheme @@ -48,10 +48,10 @@ func TestEnsureRBACResources(t *testing.T) { tests := []struct { name string - mcpRegistry *mcpv1alpha1.MCPRegistry + mcpRegistry *mcpv1beta1.MCPRegistry setupClient func(*testing.T) client.Client expectedError string - validate func(*testing.T, client.Client, *mcpv1alpha1.MCPRegistry) + validate func(*testing.T, client.Client, *mcpv1beta1.MCPRegistry) }{ { name: "creates all RBAC resources when none exist", @@ -60,7 +60,7 @@ func TestEnsureRBACResources(t *testing.T) { t.Helper() return fake.NewClientBuilder().WithScheme(createTestScheme()).Build() }, - validate: func(t *testing.T, c client.Client, mcpRegistry *mcpv1alpha1.MCPRegistry) { + validate: func(t *testing.T, c client.Client, mcpRegistry *mcpv1beta1.MCPRegistry) { t.Helper() ctx := context.Background() resourceName := mcpRegistry.Name + "-registry-api" @@ -107,7 +107,7 @@ func TestEnsureRBACResources(t *testing.T) { &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: resourceName, Namespace: mcpRegistry.Namespace}, RoleRef: rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: resourceName}}, ).Build() }, - validate: func(t *testing.T, c client.Client, mcpRegistry *mcpv1alpha1.MCPRegistry) { + validate: func(t *testing.T, c client.Client, mcpRegistry *mcpv1beta1.MCPRegistry) { t.Helper() ctx := context.Background() resourceName := mcpRegistry.Name + "-registry-api" diff --git a/cmd/thv-operator/pkg/registryapi/service.go b/cmd/thv-operator/pkg/registryapi/service.go index a6ecf0d2e1..207e555faf 100644 --- a/cmd/thv-operator/pkg/registryapi/service.go +++ b/cmd/thv-operator/pkg/registryapi/service.go @@ -16,7 +16,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // ensureService creates or updates the registry-api Service for the MCPRegistry. @@ -24,7 +24,7 @@ import ( // service configuration to buildRegistryAPIService. func (m *manager) ensureService( ctx context.Context, - mcpRegistry *mcpv1alpha1.MCPRegistry, + mcpRegistry *mcpv1beta1.MCPRegistry, ) error { ctxLogger := log.FromContext(ctx).WithValues("mcpregistry", mcpRegistry.Name) @@ -99,7 +99,7 @@ func (m *manager) ensureService( // buildRegistryAPIService creates and configures a Service object for the registry API. // This function handles all service configuration including labels, ports, and selector. // It returns a fully configured ClusterIP service ready for Kubernetes API operations. -func buildRegistryAPIService(mcpRegistry *mcpv1alpha1.MCPRegistry) *corev1.Service { +func buildRegistryAPIService(mcpRegistry *mcpv1beta1.MCPRegistry) *corev1.Service { // Generate service name using the established pattern serviceName := mcpRegistry.GetAPIResourceName() diff --git a/cmd/thv-operator/pkg/registryapi/service_test.go b/cmd/thv-operator/pkg/registryapi/service_test.go index 314283beea..bbb289b00b 100644 --- a/cmd/thv-operator/pkg/registryapi/service_test.go +++ b/cmd/thv-operator/pkg/registryapi/service_test.go @@ -12,7 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestBuildRegistryAPIService(t *testing.T) { @@ -20,12 +20,12 @@ func TestBuildRegistryAPIService(t *testing.T) { tests := []struct { name string - mcpRegistry *mcpv1alpha1.MCPRegistry + mcpRegistry *mcpv1beta1.MCPRegistry validateResult func(*testing.T, *corev1.Service) }{ { name: "basic service creation", - mcpRegistry: &mcpv1alpha1.MCPRegistry{ + mcpRegistry: &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-registry", Namespace: "test-namespace", diff --git a/cmd/thv-operator/pkg/registryapi/types.go b/cmd/thv-operator/pkg/registryapi/types.go index 608c1ef7ff..2410f8cce7 100644 --- a/cmd/thv-operator/pkg/registryapi/types.go +++ b/cmd/thv-operator/pkg/registryapi/types.go @@ -8,7 +8,7 @@ import ( appsv1 "k8s.io/api/apps/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) const ( @@ -102,23 +102,23 @@ func (e *Error) Unwrap() error { // Manager handles registry API deployment operations type Manager interface { // ReconcileAPIService orchestrates the deployment, service creation, and readiness checking for the registry API - ReconcileAPIService(ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry) *Error + ReconcileAPIService(ctx context.Context, mcpRegistry *mcpv1beta1.MCPRegistry) *Error // CheckAPIReadiness verifies that the deployed registry-API Deployment is ready CheckAPIReadiness(ctx context.Context, deployment *appsv1.Deployment) bool // IsAPIReady checks if the registry API deployment is ready and serving requests - IsAPIReady(ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry) bool + IsAPIReady(ctx context.Context, mcpRegistry *mcpv1beta1.MCPRegistry) bool // GetReadyReplicas returns the number of ready replicas for the registry API deployment - GetReadyReplicas(ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry) int32 + GetReadyReplicas(ctx context.Context, mcpRegistry *mcpv1beta1.MCPRegistry) int32 // GetAPIStatus returns the readiness state and ready replica count from a single Deployment fetch - GetAPIStatus(ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry) (ready bool, readyReplicas int32) + GetAPIStatus(ctx context.Context, mcpRegistry *mcpv1beta1.MCPRegistry) (ready bool, readyReplicas int32) } // GetServiceAccountName returns the service account name for a given MCPRegistry. // The name follows the pattern: {registry-name}-registry-api -func GetServiceAccountName(mcpRegistry *mcpv1alpha1.MCPRegistry) string { +func GetServiceAccountName(mcpRegistry *mcpv1beta1.MCPRegistry) string { return mcpRegistry.Name + registryAPIResourceSuffix } diff --git a/cmd/thv-operator/pkg/registryapi/types_test.go b/cmd/thv-operator/pkg/registryapi/types_test.go index 564fd9cc5d..c5406954c5 100644 --- a/cmd/thv-operator/pkg/registryapi/types_test.go +++ b/cmd/thv-operator/pkg/registryapi/types_test.go @@ -10,7 +10,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // TestLabelsForRegistryAPI tests the label generation function @@ -18,14 +18,14 @@ func TestLabelsForRegistryAPI(t *testing.T) { t.Parallel() tests := []struct { name string - mcpRegistry *mcpv1alpha1.MCPRegistry + mcpRegistry *mcpv1beta1.MCPRegistry resourceName string expected map[string]string description string }{ { name: "BasicLabels", - mcpRegistry: &mcpv1alpha1.MCPRegistry{ + mcpRegistry: &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-registry", }, @@ -41,7 +41,7 @@ func TestLabelsForRegistryAPI(t *testing.T) { }, { name: "LabelsWithSpecialCharacters", - mcpRegistry: &mcpv1alpha1.MCPRegistry{ + mcpRegistry: &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "my-special-registry-123", }, @@ -104,7 +104,7 @@ func TestMCPRegistryHelperMethods(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - mcpRegistry := &mcpv1alpha1.MCPRegistry{ + mcpRegistry := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: tt.registryName, }, diff --git a/cmd/thv-operator/pkg/runconfig/audit.go b/cmd/thv-operator/pkg/runconfig/audit.go index 48d345672f..518c326653 100644 --- a/cmd/thv-operator/pkg/runconfig/audit.go +++ b/cmd/thv-operator/pkg/runconfig/audit.go @@ -7,14 +7,14 @@ package runconfig import ( - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/runner" ) // AddAuditConfigOptions adds audit configuration options to the builder options func AddAuditConfigOptions( options *[]runner.RunConfigBuilderOption, - auditConfig *mcpv1alpha1.AuditConfig, + auditConfig *mcpv1beta1.AuditConfig, ) { if auditConfig == nil { return diff --git a/cmd/thv-operator/pkg/runconfig/audit_test.go b/cmd/thv-operator/pkg/runconfig/audit_test.go index 3265292088..bcfa6d0424 100644 --- a/cmd/thv-operator/pkg/runconfig/audit_test.go +++ b/cmd/thv-operator/pkg/runconfig/audit_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/runner" ) @@ -22,12 +22,12 @@ func TestAddAuditConfigOptions(t *testing.T) { t.Parallel() tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer expected func(t *testing.T, config *runner.RunConfig) }{ { name: "with empty audit configuration", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "empty-audit-server", Namespace: "test-ns", @@ -40,16 +40,16 @@ func TestAddAuditConfigOptions(t *testing.T) { }, { name: "with disabled audit configuration", - mcpServer: &mcpv1alpha1.MCPServer{ + mcpServer: &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "audit-server", Namespace: "test-ns", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, - Audit: &mcpv1alpha1.AuditConfig{ + Audit: &mcpv1beta1.AuditConfig{ Enabled: true, }, }, diff --git a/cmd/thv-operator/pkg/runconfig/telemetry.go b/cmd/thv-operator/pkg/runconfig/telemetry.go index 59ac283e1e..da9c6a89e3 100644 --- a/cmd/thv-operator/pkg/runconfig/telemetry.go +++ b/cmd/thv-operator/pkg/runconfig/telemetry.go @@ -5,7 +5,7 @@ package runconfig import ( - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/spectoconfig" "github.com/stacklok/toolhive/pkg/runner" ) @@ -15,7 +15,7 @@ import ( // caBundleFilePath is the computed mount path for the CA bundle (empty if none configured). func AddMCPTelemetryConfigRefOptions( options *[]runner.RunConfigBuilderOption, - telemetrySpec *mcpv1alpha1.MCPTelemetryConfigSpec, + telemetrySpec *mcpv1beta1.MCPTelemetryConfigSpec, serviceNameOverride string, defaultServiceName string, caBundleFilePath string, diff --git a/cmd/thv-operator/pkg/runconfig/telemetry_test.go b/cmd/thv-operator/pkg/runconfig/telemetry_test.go index 44faca6a9f..1d3d9b11f0 100644 --- a/cmd/thv-operator/pkg/runconfig/telemetry_test.go +++ b/cmd/thv-operator/pkg/runconfig/telemetry_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/runner" ) @@ -24,7 +24,7 @@ func TestAddMCPTelemetryConfigRefOptions(t *testing.T) { tests := []struct { name string - spec *mcpv1alpha1.MCPTelemetryConfigSpec + spec *mcpv1beta1.MCPTelemetryConfigSpec serviceNameOverride string defaultServiceName string caBundleFilePath string @@ -43,12 +43,12 @@ func TestAddMCPTelemetryConfigRefOptions(t *testing.T) { }, { name: "valid spec adds runner option", - spec: &mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + spec: &mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "https://otel-collector:4317", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true, SamplingRate: "0.1"}, - Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true, SamplingRate: "0.1"}, + Metrics: &mcpv1beta1.OpenTelemetryMetricsConfig{Enabled: true}, }, }, serviceNameOverride: "my-server-service", @@ -66,11 +66,11 @@ func TestAddMCPTelemetryConfigRefOptions(t *testing.T) { }, { name: "CA bundle file path is threaded through to config", - spec: &mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + spec: &mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "https://otel-collector:4317", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, }, }, serviceNameOverride: "my-server", @@ -105,11 +105,11 @@ func TestAddMCPTelemetryConfigRefOptions(t *testing.T) { func TestAddMCPTelemetryConfigRefOptions_NilOptions(t *testing.T) { t.Parallel() - spec := &mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + spec := &mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "otel-collector:4317", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, }, } diff --git a/cmd/thv-operator/pkg/spectoconfig/telemetry.go b/cmd/thv-operator/pkg/spectoconfig/telemetry.go index e5971598c8..3fd554e66c 100644 --- a/cmd/thv-operator/pkg/spectoconfig/telemetry.go +++ b/cmd/thv-operator/pkg/spectoconfig/telemetry.go @@ -8,7 +8,7 @@ import ( "strconv" "strings" - "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/telemetry" ) @@ -17,7 +17,7 @@ import ( // applies the per-server ServiceName override from the reference, then delegates to // NormalizeTelemetryConfig for endpoint normalization and service name defaulting. func NormalizeMCPTelemetryConfig( - spec *v1alpha1.MCPTelemetryConfigSpec, + spec *v1beta1.MCPTelemetryConfigSpec, serviceNameOverride string, defaultServiceName string, ) *telemetry.Config { diff --git a/cmd/thv-operator/pkg/spectoconfig/telemetry_test.go b/cmd/thv-operator/pkg/spectoconfig/telemetry_test.go index cd6d908efb..a1e52133a8 100644 --- a/cmd/thv-operator/pkg/spectoconfig/telemetry_test.go +++ b/cmd/thv-operator/pkg/spectoconfig/telemetry_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/telemetry" ) @@ -186,7 +186,7 @@ func TestNormalizeMCPTelemetryConfig(t *testing.T) { tests := []struct { name string - spec *v1alpha1.MCPTelemetryConfigSpec + spec *v1beta1.MCPTelemetryConfigSpec serviceNameOverride string defaultServiceName string expected *telemetry.Config @@ -200,8 +200,8 @@ func TestNormalizeMCPTelemetryConfig(t *testing.T) { }, { name: "service name override takes precedence", - spec: &v1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &v1alpha1.MCPTelemetryOTelConfig{ + spec: &v1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &v1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "https://otel-collector:4317", }, @@ -215,8 +215,8 @@ func TestNormalizeMCPTelemetryConfig(t *testing.T) { }, { name: "empty override falls through to defaultServiceName", - spec: &v1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &v1alpha1.MCPTelemetryOTelConfig{ + spec: &v1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &v1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "otel-collector:4317", }, @@ -230,11 +230,11 @@ func TestNormalizeMCPTelemetryConfig(t *testing.T) { }, { name: "endpoint normalization strips http:// prefix", - spec: &v1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &v1alpha1.MCPTelemetryOTelConfig{ + spec: &v1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &v1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "http://collector.monitoring:4317", - Tracing: &v1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + Tracing: &v1beta1.OpenTelemetryTracingConfig{Enabled: true}, }, }, serviceNameOverride: "my-service", @@ -247,8 +247,8 @@ func TestNormalizeMCPTelemetryConfig(t *testing.T) { }, { name: "endpoint normalization strips https:// prefix", - spec: &v1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &v1alpha1.MCPTelemetryOTelConfig{ + spec: &v1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &v1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "https://secure-collector:4317", }, @@ -262,8 +262,8 @@ func TestNormalizeMCPTelemetryConfig(t *testing.T) { }, { name: "default service name used when no override", - spec: &v1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &v1alpha1.MCPTelemetryOTelConfig{ + spec: &v1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &v1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "collector:4317", }, @@ -277,12 +277,12 @@ func TestNormalizeMCPTelemetryConfig(t *testing.T) { }, { name: "enabled false skips OTel config entirely", - spec: &v1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &v1alpha1.MCPTelemetryOTelConfig{ + spec: &v1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &v1beta1.MCPTelemetryOTelConfig{ Enabled: false, Endpoint: "https://otel-collector:4317", - Tracing: &v1alpha1.OpenTelemetryTracingConfig{Enabled: true}, - Metrics: &v1alpha1.OpenTelemetryMetricsConfig{Enabled: true}, + Tracing: &v1beta1.OpenTelemetryTracingConfig{Enabled: true}, + Metrics: &v1beta1.OpenTelemetryMetricsConfig{Enabled: true}, }, }, serviceNameOverride: "my-service", @@ -293,8 +293,8 @@ func TestNormalizeMCPTelemetryConfig(t *testing.T) { }, { name: "endpoint with nil tracing and metrics produces no tracing or metrics", - spec: &v1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &v1alpha1.MCPTelemetryOTelConfig{ + spec: &v1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &v1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "otel-collector:4317", // Tracing and Metrics are nil @@ -327,8 +327,8 @@ func TestNormalizeMCPTelemetryConfig(t *testing.T) { func TestNormalizeMCPTelemetryConfig_DoesNotModifyInput(t *testing.T) { t.Parallel() - spec := &v1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &v1alpha1.MCPTelemetryOTelConfig{ + spec := &v1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &v1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "https://otel-collector:4317", }, @@ -376,11 +376,11 @@ func TestNormalizeMCPTelemetryConfig_ClampsSamplingRate(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - spec := &v1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &v1alpha1.MCPTelemetryOTelConfig{ + spec := &v1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &v1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "otel-collector:4317", - Tracing: &v1alpha1.OpenTelemetryTracingConfig{ + Tracing: &v1beta1.OpenTelemetryTracingConfig{ Enabled: true, SamplingRate: tt.samplingRate, }, diff --git a/cmd/thv-operator/pkg/validation/oidc_validation.go b/cmd/thv-operator/pkg/validation/oidc_validation.go index 246ea5d92e..8ca6d6a32b 100644 --- a/cmd/thv-operator/pkg/validation/oidc_validation.go +++ b/cmd/thv-operator/pkg/validation/oidc_validation.go @@ -7,7 +7,7 @@ import ( "fmt" "net/url" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) const ( @@ -35,7 +35,7 @@ const ( // It ensures that configMapRef is specified when CABundleRef is provided, // and that the ConfigMap name is short enough to fit in a Kubernetes volume name. // Returns nil if ref is nil (no CA bundle configured). -func ValidateCABundleSource(ref *mcpv1alpha1.CABundleSource) error { +func ValidateCABundleSource(ref *mcpv1beta1.CABundleSource) error { if ref == nil { return nil } diff --git a/cmd/thv-operator/pkg/validation/oidc_validation_test.go b/cmd/thv-operator/pkg/validation/oidc_validation_test.go index 2ee6c6bb5c..393dacf4b9 100644 --- a/cmd/thv-operator/pkg/validation/oidc_validation_test.go +++ b/cmd/thv-operator/pkg/validation/oidc_validation_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/validation" ) @@ -24,7 +24,7 @@ func TestValidateCABundleSource(t *testing.T) { tests := []struct { name string - ref *mcpv1alpha1.CABundleSource + ref *mcpv1beta1.CABundleSource wantErr bool errContains string }{ @@ -45,7 +45,7 @@ func TestValidateCABundleSource(t *testing.T) { }, { name: "missing configMapRef", - ref: &mcpv1alpha1.CABundleSource{}, + ref: &mcpv1beta1.CABundleSource{}, wantErr: true, errContains: "configMapRef must be specified in caBundleRef", }, @@ -156,8 +156,8 @@ func TestValidateOIDCIssuerURL(t *testing.T) { } // makeCABundleSource creates a CABundleSource with the given name and optional key. -func makeCABundleSource(name, key string) *mcpv1alpha1.CABundleSource { - return &mcpv1alpha1.CABundleSource{ +func makeCABundleSource(name, key string) *mcpv1beta1.CABundleSource { + return &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: name}, Key: key, diff --git a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go index 5d3b9a29fd..448024ab0b 100644 --- a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go +++ b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go @@ -12,27 +12,27 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // StatusCollector collects status changes during reconciliation // and applies them in a single batch update at the end. // It implements the StatusManager interface. type StatusCollector struct { - vmcp *mcpv1alpha1.VirtualMCPServer + vmcp *mcpv1beta1.VirtualMCPServer hasChanges bool - phase *mcpv1alpha1.VirtualMCPServerPhase + phase *mcpv1beta1.VirtualMCPServerPhase message *string url *string observedGeneration *int64 oidcConfigHash *string telemetryConfigHash *string conditions map[string]metav1.Condition - discoveredBackends []mcpv1alpha1.DiscoveredBackend + discoveredBackends []mcpv1beta1.DiscoveredBackend } // NewStatusManager creates a new StatusManager for the given VirtualMCPServer resource. -func NewStatusManager(vmcp *mcpv1alpha1.VirtualMCPServer) StatusManager { +func NewStatusManager(vmcp *mcpv1beta1.VirtualMCPServer) StatusManager { return &StatusCollector{ vmcp: vmcp, conditions: make(map[string]metav1.Condition), @@ -40,7 +40,7 @@ func NewStatusManager(vmcp *mcpv1alpha1.VirtualMCPServer) StatusManager { } // SetPhase sets the phase to be updated. -func (s *StatusCollector) SetPhase(phase mcpv1alpha1.VirtualMCPServerPhase) { +func (s *StatusCollector) SetPhase(phase mcpv1beta1.VirtualMCPServerPhase) { s.phase = &phase s.hasChanges = true } @@ -88,22 +88,22 @@ func (s *StatusCollector) SetTelemetryConfigHash(hash string) { // SetTelemetryConfigRefValidatedCondition sets the TelemetryConfigRefValidated condition. func (s *StatusCollector) SetTelemetryConfigRefValidatedCondition(reason, message string, status metav1.ConditionStatus) { - s.SetCondition(mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, reason, message, status) + s.SetCondition(mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, reason, message, status) } // SetGroupRefValidatedCondition sets the GroupRef validation condition. func (s *StatusCollector) SetGroupRefValidatedCondition(reason, message string, status metav1.ConditionStatus) { - s.SetCondition(mcpv1alpha1.ConditionTypeVirtualMCPServerGroupRefValidated, reason, message, status) + s.SetCondition(mcpv1beta1.ConditionTypeVirtualMCPServerGroupRefValidated, reason, message, status) } // SetCompositeToolRefsValidatedCondition sets the CompositeToolRefs validation condition. func (s *StatusCollector) SetCompositeToolRefsValidatedCondition(reason, message string, status metav1.ConditionStatus) { - s.SetCondition(mcpv1alpha1.ConditionTypeCompositeToolRefsValidated, reason, message, status) + s.SetCondition(mcpv1beta1.ConditionTypeCompositeToolRefsValidated, reason, message, status) } // SetAuthConfiguredCondition sets the AuthConfigured condition. func (s *StatusCollector) SetAuthConfiguredCondition(reason, message string, status metav1.ConditionStatus) { - s.SetCondition(mcpv1alpha1.ConditionTypeAuthConfigured, reason, message, status) + s.SetCondition(mcpv1beta1.ConditionTypeAuthConfigured, reason, message, status) } // SetAuthConfigCondition sets a specific auth config condition with dynamic type. @@ -143,28 +143,28 @@ func (s *StatusCollector) RemoveConditionsWithPrefix(prefix string, exclude []st // SetReadyCondition sets the Ready condition. func (s *StatusCollector) SetReadyCondition(reason, message string, status metav1.ConditionStatus) { - s.SetCondition(mcpv1alpha1.ConditionTypeVirtualMCPServerReady, reason, message, status) + s.SetCondition(mcpv1beta1.ConditionTypeVirtualMCPServerReady, reason, message, status) } // SetEmbeddingServerReadyCondition sets the EmbeddingServerReady condition. func (s *StatusCollector) SetEmbeddingServerReadyCondition(reason, message string, status metav1.ConditionStatus) { - s.SetCondition(mcpv1alpha1.ConditionTypeEmbeddingServerReady, reason, message, status) + s.SetCondition(mcpv1beta1.ConditionTypeEmbeddingServerReady, reason, message, status) } // SetAuthServerConfigValidatedCondition sets the AuthServerConfigValidated condition. func (s *StatusCollector) SetAuthServerConfigValidatedCondition(reason, message string, status metav1.ConditionStatus) { - s.SetCondition(mcpv1alpha1.ConditionTypeAuthServerConfigValidated, reason, message, status) + s.SetCondition(mcpv1beta1.ConditionTypeAuthServerConfigValidated, reason, message, status) } // SetDiscoveredBackends sets the discovered backends list to be updated. -func (s *StatusCollector) SetDiscoveredBackends(backends []mcpv1alpha1.DiscoveredBackend) { +func (s *StatusCollector) SetDiscoveredBackends(backends []mcpv1beta1.DiscoveredBackend) { s.discoveredBackends = backends s.hasChanges = true } // UpdateStatus applies all collected status changes in a single batch update. // Expects vmcpStatus to be freshly fetched from the cluster to ensure the update operates on the latest resource version. -func (s *StatusCollector) UpdateStatus(ctx context.Context, vmcpStatus *mcpv1alpha1.VirtualMCPServerStatus) bool { +func (s *StatusCollector) UpdateStatus(ctx context.Context, vmcpStatus *mcpv1beta1.VirtualMCPServerStatus) bool { ctxLogger := log.FromContext(ctx) if s.hasChanges { @@ -215,8 +215,8 @@ func (s *StatusCollector) UpdateStatus(ctx context.Context, vmcpStatus *mcpv1alp // Unauthenticated backends are reachable but require per-request user auth. var routableCount int32 for _, backend := range s.discoveredBackends { - if backend.Status == mcpv1alpha1.BackendStatusReady || - backend.Status == mcpv1alpha1.BackendStatusUnauthenticated { + if backend.Status == mcpv1beta1.BackendStatusReady || + backend.Status == mcpv1beta1.BackendStatusUnauthenticated { routableCount++ } } diff --git a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector_test.go b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector_test.go index f1ab8fac6f..bd75b0cf48 100644 --- a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector_test.go +++ b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector_test.go @@ -10,33 +10,33 @@ import ( "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestStatusCollector_SetPhase(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) - collector.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseReady) + collector.SetPhase(mcpv1beta1.VirtualMCPServerPhaseReady) - status := &mcpv1alpha1.VirtualMCPServerStatus{} + status := &mcpv1beta1.VirtualMCPServerStatus{} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) - assert.Equal(t, mcpv1alpha1.VirtualMCPServerPhaseReady, status.Phase) + assert.Equal(t, mcpv1beta1.VirtualMCPServerPhaseReady, status.Phase) } func TestStatusCollector_SetMessage(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) collector.SetMessage("test message") - status := &mcpv1alpha1.VirtualMCPServerStatus{} + status := &mcpv1beta1.VirtualMCPServerStatus{} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) @@ -46,12 +46,12 @@ func TestStatusCollector_SetMessage(t *testing.T) { func TestStatusCollector_SetURL(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) collector.SetURL("http://test.example.com") - status := &mcpv1alpha1.VirtualMCPServerStatus{} + status := &mcpv1beta1.VirtualMCPServerStatus{} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) @@ -61,12 +61,12 @@ func TestStatusCollector_SetURL(t *testing.T) { func TestStatusCollector_SetObservedGeneration(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) collector.SetObservedGeneration(42) - status := &mcpv1alpha1.VirtualMCPServerStatus{} + status := &mcpv1beta1.VirtualMCPServerStatus{} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) @@ -76,12 +76,12 @@ func TestStatusCollector_SetObservedGeneration(t *testing.T) { func TestStatusCollector_SetOIDCConfigHash(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) collector.SetOIDCConfigHash("abc123hash") - status := &mcpv1alpha1.VirtualMCPServerStatus{} + status := &mcpv1beta1.VirtualMCPServerStatus{} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) @@ -91,12 +91,12 @@ func TestStatusCollector_SetOIDCConfigHash(t *testing.T) { func TestStatusCollector_SetOIDCConfigHash_Clear(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) collector.SetOIDCConfigHash("") - status := &mcpv1alpha1.VirtualMCPServerStatus{OIDCConfigHash: "old-hash"} + status := &mcpv1beta1.VirtualMCPServerStatus{OIDCConfigHash: "old-hash"} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) @@ -106,17 +106,17 @@ func TestStatusCollector_SetOIDCConfigHash_Clear(t *testing.T) { func TestStatusCollector_SetGroupRefValidatedCondition(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) collector.SetGroupRefValidatedCondition("TestReason", "test message", metav1.ConditionTrue) - status := &mcpv1alpha1.VirtualMCPServerStatus{} + status := &mcpv1beta1.VirtualMCPServerStatus{} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) assert.Len(t, status.Conditions, 1) - assert.Equal(t, mcpv1alpha1.ConditionTypeVirtualMCPServerGroupRefValidated, status.Conditions[0].Type) + assert.Equal(t, mcpv1beta1.ConditionTypeVirtualMCPServerGroupRefValidated, status.Conditions[0].Type) assert.Equal(t, metav1.ConditionTrue, status.Conditions[0].Status) assert.Equal(t, "TestReason", status.Conditions[0].Reason) assert.Equal(t, "test message", status.Conditions[0].Message) @@ -125,17 +125,17 @@ func TestStatusCollector_SetGroupRefValidatedCondition(t *testing.T) { func TestStatusCollector_SetReadyCondition(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) collector.SetReadyCondition("DeploymentReady", "deployment is ready", metav1.ConditionTrue) - status := &mcpv1alpha1.VirtualMCPServerStatus{} + status := &mcpv1beta1.VirtualMCPServerStatus{} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) assert.Len(t, status.Conditions, 1) - assert.Equal(t, mcpv1alpha1.ConditionTypeVirtualMCPServerReady, status.Conditions[0].Type) + assert.Equal(t, mcpv1beta1.ConditionTypeVirtualMCPServerReady, status.Conditions[0].Type) assert.Equal(t, metav1.ConditionTrue, status.Conditions[0].Status) assert.Equal(t, "DeploymentReady", status.Conditions[0].Reason) assert.Equal(t, "deployment is ready", status.Conditions[0].Message) @@ -144,11 +144,11 @@ func TestStatusCollector_SetReadyCondition(t *testing.T) { func TestStatusCollector_BatchedUpdates(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) // Collect multiple changes - collector.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseReady) + collector.SetPhase(mcpv1beta1.VirtualMCPServerPhaseReady) collector.SetMessage("test message") collector.SetURL("http://test.example.com") collector.SetObservedGeneration(42) @@ -157,11 +157,11 @@ func TestStatusCollector_BatchedUpdates(t *testing.T) { collector.SetReadyCondition("DeploymentReady", "deployment is ready", metav1.ConditionTrue) // Apply all at once - status := &mcpv1alpha1.VirtualMCPServerStatus{} + status := &mcpv1beta1.VirtualMCPServerStatus{} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) - assert.Equal(t, mcpv1alpha1.VirtualMCPServerPhaseReady, status.Phase) + assert.Equal(t, mcpv1beta1.VirtualMCPServerPhaseReady, status.Phase) assert.Equal(t, "test message", status.Message) assert.Equal(t, "http://test.example.com", status.URL) assert.Equal(t, int64(42), status.ObservedGeneration) @@ -172,11 +172,11 @@ func TestStatusCollector_BatchedUpdates(t *testing.T) { func TestStatusCollector_NoChanges(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) // Don't set any changes - status := &mcpv1alpha1.VirtualMCPServerStatus{} + status := &mcpv1beta1.VirtualMCPServerStatus{} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.False(t, hasUpdates) @@ -185,17 +185,17 @@ func TestStatusCollector_NoChanges(t *testing.T) { func TestStatusCollector_SetAuthConfiguredCondition(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) collector.SetAuthConfiguredCondition("AuthValid", "auth is configured", metav1.ConditionTrue) - status := &mcpv1alpha1.VirtualMCPServerStatus{} + status := &mcpv1beta1.VirtualMCPServerStatus{} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) assert.Len(t, status.Conditions, 1) - assert.Equal(t, mcpv1alpha1.ConditionTypeAuthConfigured, status.Conditions[0].Type) + assert.Equal(t, mcpv1beta1.ConditionTypeAuthConfigured, status.Conditions[0].Type) assert.Equal(t, metav1.ConditionTrue, status.Conditions[0].Status) assert.Equal(t, "AuthValid", status.Conditions[0].Reason) assert.Equal(t, "auth is configured", status.Conditions[0].Message) @@ -204,17 +204,17 @@ func TestStatusCollector_SetAuthConfiguredCondition(t *testing.T) { func TestStatusCollector_SetAuthServerConfigValidatedCondition(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) collector.SetAuthServerConfigValidatedCondition("AuthServerConfigValid", "AuthServerConfig is valid", metav1.ConditionTrue) - status := &mcpv1alpha1.VirtualMCPServerStatus{} + status := &mcpv1beta1.VirtualMCPServerStatus{} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) assert.Len(t, status.Conditions, 1) - assert.Equal(t, mcpv1alpha1.ConditionTypeAuthServerConfigValidated, status.Conditions[0].Type) + assert.Equal(t, mcpv1beta1.ConditionTypeAuthServerConfigValidated, status.Conditions[0].Type) assert.Equal(t, metav1.ConditionTrue, status.Conditions[0].Status) assert.Equal(t, "AuthServerConfigValid", status.Conditions[0].Reason) assert.Equal(t, "AuthServerConfig is valid", status.Conditions[0].Message) @@ -223,14 +223,14 @@ func TestStatusCollector_SetAuthServerConfigValidatedCondition(t *testing.T) { func TestStatusCollector_MultipleConditions(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) collector.SetGroupRefValidatedCondition("GroupValid", "group is valid", metav1.ConditionTrue) collector.SetAuthConfiguredCondition("AuthValid", "auth is configured", metav1.ConditionTrue) collector.SetReadyCondition("DeploymentReady", "deployment is ready", metav1.ConditionTrue) - status := &mcpv1alpha1.VirtualMCPServerStatus{} + status := &mcpv1beta1.VirtualMCPServerStatus{} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) @@ -241,17 +241,17 @@ func TestStatusCollector_MultipleConditions(t *testing.T) { for _, cond := range status.Conditions { conditionTypes[cond.Type] = true } - assert.True(t, conditionTypes[mcpv1alpha1.ConditionTypeVirtualMCPServerGroupRefValidated]) - assert.True(t, conditionTypes[mcpv1alpha1.ConditionTypeAuthConfigured]) - assert.True(t, conditionTypes[mcpv1alpha1.ConditionTypeVirtualMCPServerReady]) + assert.True(t, conditionTypes[mcpv1beta1.ConditionTypeVirtualMCPServerGroupRefValidated]) + assert.True(t, conditionTypes[mcpv1beta1.ConditionTypeAuthConfigured]) + assert.True(t, conditionTypes[mcpv1beta1.ConditionTypeVirtualMCPServerReady]) } func TestStatusCollector_RemoveConditionsWithPrefix(t *testing.T) { t.Parallel() // Create a VirtualMCPServer with existing conditions - vmcp := &mcpv1alpha1.VirtualMCPServer{ - Status: mcpv1alpha1.VirtualMCPServerStatus{ + vmcp := &mcpv1beta1.VirtualMCPServer{ + Status: mcpv1beta1.VirtualMCPServerStatus{ Conditions: []metav1.Condition{ { Type: "DiscoveredAuthConfig-backend-1", @@ -308,12 +308,12 @@ func TestStatusCollector_RemoveConditionsWithPrefix(t *testing.T) { func TestStatusCollector_SetTelemetryConfigHash(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) collector.SetTelemetryConfigHash("tel-hash-456") - status := &mcpv1alpha1.VirtualMCPServerStatus{} + status := &mcpv1beta1.VirtualMCPServerStatus{} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) @@ -323,12 +323,12 @@ func TestStatusCollector_SetTelemetryConfigHash(t *testing.T) { func TestStatusCollector_SetTelemetryConfigHash_Clear(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) collector.SetTelemetryConfigHash("") - status := &mcpv1alpha1.VirtualMCPServerStatus{TelemetryConfigHash: "old-hash"} + status := &mcpv1beta1.VirtualMCPServerStatus{TelemetryConfigHash: "old-hash"} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) @@ -338,18 +338,18 @@ func TestStatusCollector_SetTelemetryConfigHash_Clear(t *testing.T) { func TestStatusCollector_SetTelemetryConfigRefValidatedCondition(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} collector := NewStatusManager(vmcp) collector.SetTelemetryConfigRefValidatedCondition( "TelemetryConfigRefValid", "MCPTelemetryConfig is valid", metav1.ConditionTrue) - status := &mcpv1alpha1.VirtualMCPServerStatus{} + status := &mcpv1beta1.VirtualMCPServerStatus{} hasUpdates := collector.UpdateStatus(context.Background(), status) assert.True(t, hasUpdates) assert.Len(t, status.Conditions, 1) - assert.Equal(t, mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, status.Conditions[0].Type) + assert.Equal(t, mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, status.Conditions[0].Type) assert.Equal(t, metav1.ConditionTrue, status.Conditions[0].Status) assert.Equal(t, "TelemetryConfigRefValid", status.Conditions[0].Reason) assert.Equal(t, "MCPTelemetryConfig is valid", status.Conditions[0].Message) diff --git a/cmd/thv-operator/pkg/virtualmcpserverstatus/mocks/mock_collector.go b/cmd/thv-operator/pkg/virtualmcpserverstatus/mocks/mock_collector.go index 3f944a13d8..5ee55b4516 100644 --- a/cmd/thv-operator/pkg/virtualmcpserverstatus/mocks/mock_collector.go +++ b/cmd/thv-operator/pkg/virtualmcpserverstatus/mocks/mock_collector.go @@ -13,7 +13,7 @@ import ( context "context" reflect "reflect" - v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" gomock "go.uber.org/mock/gomock" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -115,7 +115,7 @@ func (mr *MockStatusManagerMockRecorder) SetCondition(conditionType, reason, mes } // SetDiscoveredBackends mocks base method. -func (m *MockStatusManager) SetDiscoveredBackends(backends []v1alpha1.DiscoveredBackend) { +func (m *MockStatusManager) SetDiscoveredBackends(backends []v1beta1.DiscoveredBackend) { m.ctrl.T.Helper() m.ctrl.Call(m, "SetDiscoveredBackends", backends) } @@ -187,7 +187,7 @@ func (mr *MockStatusManagerMockRecorder) SetObservedGeneration(generation any) * } // SetPhase mocks base method. -func (m *MockStatusManager) SetPhase(phase v1alpha1.VirtualMCPServerPhase) { +func (m *MockStatusManager) SetPhase(phase v1beta1.VirtualMCPServerPhase) { m.ctrl.T.Helper() m.ctrl.Call(m, "SetPhase", phase) } @@ -247,7 +247,7 @@ func (mr *MockStatusManagerMockRecorder) SetURL(url any) *gomock.Call { } // UpdateStatus mocks base method. -func (m *MockStatusManager) UpdateStatus(ctx context.Context, vmcpStatus *v1alpha1.VirtualMCPServerStatus) bool { +func (m *MockStatusManager) UpdateStatus(ctx context.Context, vmcpStatus *v1beta1.VirtualMCPServerStatus) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateStatus", ctx, vmcpStatus) ret0, _ := ret[0].(bool) diff --git a/cmd/thv-operator/pkg/virtualmcpserverstatus/types.go b/cmd/thv-operator/pkg/virtualmcpserverstatus/types.go index df519115c2..7aefa7f685 100644 --- a/cmd/thv-operator/pkg/virtualmcpserverstatus/types.go +++ b/cmd/thv-operator/pkg/virtualmcpserverstatus/types.go @@ -9,7 +9,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) //go:generate mockgen -destination=mocks/mock_collector.go -package=mocks -source=types.go StatusManager @@ -18,7 +18,7 @@ import ( // It collects status changes during reconciliation and applies them in a single batch update. type StatusManager interface { // SetPhase sets the VirtualMCPServer phase - SetPhase(phase mcpv1alpha1.VirtualMCPServerPhase) + SetPhase(phase mcpv1beta1.VirtualMCPServerPhase) // SetMessage sets the status message SetMessage(message string) @@ -72,9 +72,9 @@ type StatusManager interface { SetTelemetryConfigRefValidatedCondition(reason, message string, status metav1.ConditionStatus) // SetDiscoveredBackends sets the discovered backends list - SetDiscoveredBackends(backends []mcpv1alpha1.DiscoveredBackend) + SetDiscoveredBackends(backends []mcpv1beta1.DiscoveredBackend) // UpdateStatus applies all collected status changes in a single batch update. // Returns true if updates were applied, false if no changes were collected. - UpdateStatus(ctx context.Context, vmcpStatus *mcpv1alpha1.VirtualMCPServerStatus) bool + UpdateStatus(ctx context.Context, vmcpStatus *mcpv1beta1.VirtualMCPServerStatus) bool } diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter.go b/cmd/thv-operator/pkg/vmcpconfig/converter.go index 30e94fbdbf..18473d11f5 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go @@ -14,7 +14,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/spectoconfig" @@ -77,8 +77,8 @@ func NewConverter(oidcResolver oidc.Resolver, k8sClient client.Client) (*Convert // when AuthServerConfig is set on the VirtualMCPServer spec. func (c *Converter) Convert( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, - telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, + vmcp *mcpv1beta1.VirtualMCPServer, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, ) (*vmcpconfig.Config, *authserver.RunConfig, error) { // Start with a deep copy of the embedded config for automatic field passthrough. // This ensures new fields added to config.Config are automatically included @@ -160,7 +160,7 @@ func (c *Converter) Convert( // convertIncomingAuth converts IncomingAuthConfig from CRD to vmcp config. func (c *Converter) convertIncomingAuth( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) (*vmcpconfig.IncomingAuthConfig, error) { oidcConfig, err := c.resolveOIDCConfig(ctx, vmcp) if err != nil { @@ -201,7 +201,7 @@ func (c *Converter) convertIncomingAuth( // preventing deployment without authentication when OIDC is explicitly requested. func (c *Converter) resolveOIDCConfig( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) (*vmcpconfig.OIDCConfig, error) { if vmcp.Spec.IncomingAuth == nil { return nil, nil @@ -239,7 +239,7 @@ func (c *Converter) resolveOIDCConfig( // Client secret detection uses the MCPOIDCConfig's inline config rather than OIDCConfigRef. func mapResolvedOIDCToVmcpConfigFromRef( resolved *oidc.OIDCConfig, - oidcCfg *mcpv1alpha1.MCPOIDCConfig, + oidcCfg *mcpv1beta1.MCPOIDCConfig, ) *vmcpconfig.OIDCConfig { if resolved == nil { return nil @@ -260,7 +260,7 @@ func mapResolvedOIDCToVmcpConfigFromRef( // MCPOIDCConfig inline type may have a client secret if oidcCfg != nil && - oidcCfg.Spec.Type == mcpv1alpha1.MCPOIDCConfigTypeInline && + oidcCfg.Spec.Type == mcpv1beta1.MCPOIDCConfigTypeInline && oidcCfg.Spec.Inline != nil && oidcCfg.Spec.Inline.ClientSecretRef != nil { config.ClientSecretEnv = vmcpOIDCClientSecretEnvVar @@ -275,8 +275,8 @@ func mapResolvedOIDCToVmcpConfigFromRef( // no longer read by the operator — use TelemetryConfigRef instead. func (*Converter) normalizeTelemetry( _ context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, - telemetryCfg *mcpv1alpha1.MCPTelemetryConfig, + vmcp *mcpv1beta1.VirtualMCPServer, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, ) *telemetry.Config { if vmcp.Spec.TelemetryConfigRef != nil && telemetryCfg != nil { return spectoconfig.NormalizeMCPTelemetryConfig( @@ -290,9 +290,9 @@ func (*Converter) normalizeTelemetry( // the DeepCopy brought in from spec.config.sessionStorage. // PasswordRef is K8s-specific and is resolved separately; the password is injected // as the THV_SESSION_REDIS_PASSWORD environment variable by the deployment builder. -func convertSessionStorage(vmcp *mcpv1alpha1.VirtualMCPServer) *vmcpconfig.SessionStorageConfig { +func convertSessionStorage(vmcp *mcpv1beta1.VirtualMCPServer) *vmcpconfig.SessionStorageConfig { if vmcp.Spec.SessionStorage != nil && - vmcp.Spec.SessionStorage.Provider == mcpv1alpha1.SessionStorageProviderRedis { + vmcp.Spec.SessionStorage.Provider == mcpv1beta1.SessionStorageProviderRedis { return &vmcpconfig.SessionStorageConfig{ Provider: vmcp.Spec.SessionStorage.Provider, Address: vmcp.Spec.SessionStorage.Address, @@ -307,7 +307,7 @@ func convertSessionStorage(vmcp *mcpv1alpha1.VirtualMCPServer) *vmcpconfig.Sessi // VirtualMCPServer spec into an authserver.RunConfig using the shared builder in // controllerutil. AllowedAudiences is derived from the resolved incoming OIDC config. func (*Converter) convertAuthServerConfig( - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, config *vmcpconfig.Config, ) (*authserver.RunConfig, error) { if vmcp.Spec.AuthServerConfig == nil { @@ -371,7 +371,7 @@ func deriveScopesSupported(config *vmcpconfig.Config) []string { // convertOutgoingAuthWithDefaults converts OutgoingAuthConfig or returns defaults. func (c *Converter) convertOutgoingAuthWithDefaults( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) (*vmcpconfig.OutgoingAuthConfig, error) { if vmcp.Spec.OutgoingAuth != nil { return c.convertOutgoingAuth(ctx, vmcp) @@ -384,7 +384,7 @@ func (c *Converter) convertOutgoingAuthWithDefaults( // convertAggregationWithDefaults converts AggregationConfig or returns defaults. func (c *Converter) convertAggregationWithDefaults( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) (*vmcpconfig.AggregationConfig, error) { if vmcp.Spec.Config.Aggregation != nil { return c.convertAggregation(ctx, vmcp) @@ -400,7 +400,7 @@ func (c *Converter) convertAggregationWithDefaults( // convertOutgoingAuth converts OutgoingAuthConfig from CRD to vmcp config func (c *Converter) convertOutgoingAuth( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) (*vmcpconfig.OutgoingAuthConfig, error) { outgoing := &vmcpconfig.OutgoingAuthConfig{ Source: vmcp.Spec.OutgoingAuth.Source, @@ -431,25 +431,25 @@ func (c *Converter) convertOutgoingAuth( // convertBackendAuthConfig converts BackendAuthConfig from CRD to vmcp config func (c *Converter) convertBackendAuthConfig( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, backendName string, - crdConfig *mcpv1alpha1.BackendAuthConfig, + crdConfig *mcpv1beta1.BackendAuthConfig, ) (*authtypes.BackendAuthStrategy, error) { // If type is "discovered", return unauthenticated strategy - if crdConfig.Type == mcpv1alpha1.BackendAuthTypeDiscovered { + if crdConfig.Type == mcpv1beta1.BackendAuthTypeDiscovered { return &authtypes.BackendAuthStrategy{ Type: authtypes.StrategyTypeUnauthenticated, }, nil } // If type is "externalAuthConfigRef", resolve the MCPExternalAuthConfig - if crdConfig.Type == mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef { + if crdConfig.Type == mcpv1beta1.BackendAuthTypeExternalAuthConfigRef { if crdConfig.ExternalAuthConfigRef == nil { return nil, fmt.Errorf("backend %s: externalAuthConfigRef type requires externalAuthConfigRef field", backendName) } // Fetch the MCPExternalAuthConfig resource - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{} err := c.k8sClient.Get(ctx, types.NamespacedName{ Name: crdConfig.ExternalAuthConfigRef.Name, Namespace: vmcp.Namespace, @@ -472,7 +472,7 @@ func (c *Converter) convertBackendAuthConfig( // The registry pattern makes adding new auth types easier and ensures conversion happens in one place. func (*Converter) convertExternalAuthConfigToStrategy( _ context.Context, - externalAuthConfig *mcpv1alpha1.MCPExternalAuthConfig, + externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig, ) (*authtypes.BackendAuthStrategy, error) { // Use the converter registry to convert to typed strategy registry := converters.DefaultRegistry() @@ -506,7 +506,7 @@ func (*Converter) convertExternalAuthConfigToStrategy( // convertAggregation converts AggregationConfig from config.Config, resolving ToolConfigRef references func (c *Converter) convertAggregation( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) (*vmcpconfig.AggregationConfig, error) { // Start with a deep copy of the source config srcAgg := vmcp.Spec.Config.Aggregation @@ -557,7 +557,7 @@ func (*Converter) applyConflictResolutionDefaults( // resolveToolConfigRefs resolves ToolConfigRef references in tool configurations func (c *Converter) resolveToolConfigRefs( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, srcAgg *vmcpconfig.AggregationConfig, agg *vmcpconfig.AggregationConfig, ) error { @@ -630,7 +630,7 @@ func (c *Converter) resolveToolConfigRef( // mergeToolConfigFilter merges filter from MCPToolConfig func (*Converter) mergeToolConfigFilter( wtc *vmcpconfig.WorkloadToolConfig, - resolvedConfig *mcpv1alpha1.MCPToolConfig, + resolvedConfig *mcpv1beta1.MCPToolConfig, ) { if len(wtc.Filter) == 0 && len(resolvedConfig.Spec.ToolsFilter) > 0 { wtc.Filter = resolvedConfig.Spec.ToolsFilter @@ -640,7 +640,7 @@ func (*Converter) mergeToolConfigFilter( // mergeToolConfigOverrides merges overrides from MCPToolConfig func (*Converter) mergeToolConfigOverrides( wtc *vmcpconfig.WorkloadToolConfig, - resolvedConfig *mcpv1alpha1.MCPToolConfig, + resolvedConfig *mcpv1beta1.MCPToolConfig, ) { if len(resolvedConfig.Spec.ToolsOverride) == 0 { return @@ -658,7 +658,7 @@ func (*Converter) mergeToolConfigOverrides( } // convertCRDToolOverride converts a CRD ToolOverride to a config ToolOverride. -func convertCRDToolOverride(src *mcpv1alpha1.ToolOverride) *vmcpconfig.ToolOverride { +func convertCRDToolOverride(src *mcpv1beta1.ToolOverride) *vmcpconfig.ToolOverride { o := &vmcpconfig.ToolOverride{ Name: src.Name, Description: src.Description, @@ -680,8 +680,8 @@ func (c *Converter) resolveMCPToolConfig( ctx context.Context, namespace string, name string, -) (*mcpv1alpha1.MCPToolConfig, error) { - toolConfig := &mcpv1alpha1.MCPToolConfig{} +) (*mcpv1beta1.MCPToolConfig, error) { + toolConfig := &mcpv1beta1.MCPToolConfig{} err := c.k8sClient.Get(ctx, types.NamespacedName{ Name: name, Namespace: namespace, @@ -695,7 +695,7 @@ func (c *Converter) resolveMCPToolConfig( // convertAllCompositeTools resolves CompositeToolRefs and merges them with inline CompositeTools. func (c *Converter) convertAllCompositeTools( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) ([]vmcpconfig.CompositeToolConfig, error) { // Resolve referenced composite tools referencedTools, err := c.resolveCompositeToolRefs(ctx, vmcp) @@ -717,14 +717,14 @@ func (c *Converter) convertAllCompositeTools( // resolveCompositeToolRefs fetches and converts referenced VirtualMCPCompositeToolDefinition resources. func (c *Converter) resolveCompositeToolRefs( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) ([]vmcpconfig.CompositeToolConfig, error) { referencedTools := make([]vmcpconfig.CompositeToolConfig, 0, len(vmcp.Spec.Config.CompositeToolRefs)) for i := range vmcp.Spec.Config.CompositeToolRefs { ref := &vmcp.Spec.Config.CompositeToolRefs[i] // Fetch the referenced VirtualMCPCompositeToolDefinition - compositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{} + compositeToolDef := &mcpv1beta1.VirtualMCPCompositeToolDefinition{} key := types.NamespacedName{ Name: ref.Name, Namespace: vmcp.Namespace, @@ -750,7 +750,7 @@ func (c *Converter) resolveCompositeToolRefs( // Since VirtualMCPCompositeToolDefinitionSpec embeds config.CompositeToolConfig directly, // this is a simple copy operation. func (*Converter) convertCompositeToolDefinition( - def *mcpv1alpha1.VirtualMCPCompositeToolDefinition, + def *mcpv1beta1.VirtualMCPCompositeToolDefinition, ) vmcpconfig.CompositeToolConfig { // The spec directly embeds CompositeToolConfig, so we can return it directly return def.Spec.CompositeToolConfig diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go index 8bf59a912c..f0f47d245b 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go @@ -21,7 +21,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/log" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" oidcmocks "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc/mocks" @@ -43,7 +43,7 @@ func newNoOpMockResolver(t *testing.T) *oidcmocks.MockResolver { func newTestK8sClient(t *testing.T, objects ...client.Object) client.Client { t.Helper() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() } @@ -57,32 +57,32 @@ func newTestConverter(t *testing.T, resolver oidc.Resolver) *Converter { } // newTestVMCPServer creates a VirtualMCPServer with an MCPOIDCConfigReference for testing. -func newTestVMCPServer(oidcConfigRef *mcpv1alpha1.MCPOIDCConfigReference) *mcpv1alpha1.VirtualMCPServer { - return &mcpv1alpha1.VirtualMCPServer{ +func newTestVMCPServer(oidcConfigRef *mcpv1beta1.MCPOIDCConfigReference) *mcpv1beta1.VirtualMCPServer { + return &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "oidc", OIDCConfigRef: oidcConfigRef}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "oidc", OIDCConfigRef: oidcConfigRef}, }, } } // newTestMCPOIDCConfig creates an MCPOIDCConfig resource for testing with the given spec type. -func newTestMCPOIDCConfig(specType mcpv1alpha1.MCPOIDCConfigSourceType) *mcpv1alpha1.MCPOIDCConfig { - return &mcpv1alpha1.MCPOIDCConfig{ +func newTestMCPOIDCConfig(specType mcpv1beta1.MCPOIDCConfigSourceType) *mcpv1beta1.MCPOIDCConfig { + return &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ Type: specType, }, } } // newTestMCPOIDCConfigInline creates an MCPOIDCConfig resource with inline config for testing. -func newTestMCPOIDCConfigInline(inline *mcpv1alpha1.InlineOIDCSharedConfig) *mcpv1alpha1.MCPOIDCConfig { - return &mcpv1alpha1.MCPOIDCConfig{ +func newTestMCPOIDCConfigInline(inline *mcpv1beta1.InlineOIDCSharedConfig) *mcpv1beta1.MCPOIDCConfig { + return &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, Inline: inline, }, } @@ -104,16 +104,16 @@ func TestConverter_OIDCResolution(t *testing.T) { tests := []struct { name string - oidcConfigRef *mcpv1alpha1.MCPOIDCConfigReference - oidcConfig *mcpv1alpha1.MCPOIDCConfig // MCPOIDCConfig object to add to fake client + oidcConfigRef *mcpv1beta1.MCPOIDCConfigReference + oidcConfig *mcpv1beta1.MCPOIDCConfig // MCPOIDCConfig object to add to fake client mockReturn *oidc.OIDCConfig mockErr error validate func(t *testing.T, config *vmcpconfig.Config, err error) }{ { name: "successful resolution maps all fields", - oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "my-audience"}, - oidcConfig: newTestMCPOIDCConfig(mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount), + oidcConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "my-audience"}, + oidcConfig: newTestMCPOIDCConfig(mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount), mockReturn: &oidc.OIDCConfig{ Issuer: "https://issuer.example.com", Audience: "my-audience", ResourceURL: "https://resource.example.com", @@ -135,8 +135,8 @@ func TestConverter_OIDCResolution(t *testing.T) { }, { name: "fields mapped independently - jwksAllowPrivateIP true, protectedResourceAllowPrivateIP false", - oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "my-audience"}, - oidcConfig: newTestMCPOIDCConfig(mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount), + oidcConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "my-audience"}, + oidcConfig: newTestMCPOIDCConfig(mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount), mockReturn: &oidc.OIDCConfig{ Issuer: "https://issuer.example.com", Audience: "my-audience", JWKSAllowPrivateIP: true, ProtectedResourceAllowPrivateIP: false, @@ -151,8 +151,8 @@ func TestConverter_OIDCResolution(t *testing.T) { }, { name: "resolution error returns error (fail-closed)", - oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, - oidcConfig: newTestMCPOIDCConfig(mcpv1alpha1.MCPOIDCConfigTypeInline), + oidcConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + oidcConfig: newTestMCPOIDCConfig(mcpv1beta1.MCPOIDCConfigTypeInline), mockErr: errors.New("configmap not found"), validate: func(t *testing.T, _ *vmcpconfig.Config, err error) { t.Helper() @@ -162,8 +162,8 @@ func TestConverter_OIDCResolution(t *testing.T) { }, { name: "nil resolved config results in nil OIDC", - oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, - oidcConfig: newTestMCPOIDCConfig(mcpv1alpha1.MCPOIDCConfigTypeInline), + oidcConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + oidcConfig: newTestMCPOIDCConfig(mcpv1beta1.MCPOIDCConfigTypeInline), mockReturn: nil, validate: func(t *testing.T, config *vmcpconfig.Config, err error) { t.Helper() @@ -173,10 +173,10 @@ func TestConverter_OIDCResolution(t *testing.T) { }, { name: "inline with client secret sets ClientSecretEnv", - oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, - oidcConfig: newTestMCPOIDCConfigInline(&mcpv1alpha1.InlineOIDCSharedConfig{ + oidcConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + oidcConfig: newTestMCPOIDCConfigInline(&mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://issuer.example.com", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "oidc-secret", Key: "client-secret", }, @@ -190,8 +190,8 @@ func TestConverter_OIDCResolution(t *testing.T) { }, { name: "non-inline type does not set ClientSecretEnv", - oidcConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, - oidcConfig: newTestMCPOIDCConfig(mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount), + oidcConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + oidcConfig: newTestMCPOIDCConfig(mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount), mockReturn: &oidc.OIDCConfig{Issuer: "https://kubernetes.default.svc"}, validate: func(t *testing.T, config *vmcpconfig.Config, err error) { t.Helper() @@ -226,13 +226,13 @@ func TestConverter_OIDCResolution(t *testing.T) { func TestConverter_CompositeToolsPassThrough(t *testing.T) { t.Parallel() - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ CompositeTools: []vmcpconfig.CompositeToolConfig{ { @@ -288,8 +288,8 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { tests := []struct { name string - incomingAuth *mcpv1alpha1.IncomingAuthConfig - oidcConfig *mcpv1alpha1.MCPOIDCConfig // MCPOIDCConfig object to add to fake client + incomingAuth *mcpv1beta1.IncomingAuthConfig + oidcConfig *mcpv1beta1.MCPOIDCConfig // MCPOIDCConfig object to add to fake client expectedAuthType string expectedOIDCConfig *vmcpconfig.OIDCConfig expectNilAuth bool @@ -304,7 +304,7 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { }, { name: "explicit anonymous auth", - incomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + incomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, expectedAuthType: "anonymous", @@ -312,11 +312,11 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { }, { name: "explicit oidc auth via MCPOIDCConfigRef", - incomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + incomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, }, - oidcConfig: newTestMCPOIDCConfigInline(&mcpv1alpha1.InlineOIDCSharedConfig{ + oidcConfig: newTestMCPOIDCConfigInline(&mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://example.com", ClientID: "test-client", }), @@ -335,11 +335,11 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { }, { name: "oidc auth with scopes", - incomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + incomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "google-audience"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "google-audience"}, }, - oidcConfig: newTestMCPOIDCConfigInline(&mcpv1alpha1.InlineOIDCSharedConfig{ + oidcConfig: newTestMCPOIDCConfigInline(&mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "google-client", }), @@ -360,11 +360,11 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { }, { name: "oidc auth with jwksUrl and introspectionUrl", - incomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + incomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, }, - oidcConfig: newTestMCPOIDCConfigInline(&mcpv1alpha1.InlineOIDCSharedConfig{ + oidcConfig: newTestMCPOIDCConfigInline(&mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://auth.example.com", ClientID: "test-client", JWKSURL: "https://auth.example.com/custom/jwks", @@ -393,13 +393,13 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, IncomingAuth: tt.incomingAuth, }, } @@ -457,7 +457,7 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { // createTestScheme creates a test scheme with required types func createTestScheme() *runtime.Scheme { s := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(s) + _ = mcpv1beta1.AddToScheme(s) return s } @@ -466,8 +466,8 @@ func TestConverter_CompositeToolRefs(t *testing.T) { tests := []struct { name string - vmcp *mcpv1alpha1.VirtualMCPServer - compositeDefs []*mcpv1alpha1.VirtualMCPCompositeToolDefinition + vmcp *mcpv1beta1.VirtualMCPServer + compositeDefs []*mcpv1beta1.VirtualMCPCompositeToolDefinition k8sClient client.Client expectError bool errorContains string @@ -475,13 +475,13 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }{ { name: "successfully fetch and merge referenced composite tool", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "referenced-tool"}, @@ -489,13 +489,13 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, }, }, - compositeDefs: []*mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeDefs: []*mcpv1beta1.VirtualMCPCompositeToolDefinition{ { ObjectMeta: metav1.ObjectMeta{ Name: "referenced-tool", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "referenced-tool", Description: "A referenced composite tool", @@ -523,13 +523,13 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, { name: "merge inline and referenced composite tools", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ CompositeTools: []vmcpconfig.CompositeToolConfig{ { @@ -550,13 +550,13 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, }, }, - compositeDefs: []*mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeDefs: []*mcpv1beta1.VirtualMCPCompositeToolDefinition{ { ObjectMeta: metav1.ObjectMeta{ Name: "referenced-tool", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "referenced-tool", Description: "A referenced composite tool", @@ -586,13 +586,13 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, { name: "error when referenced composite tool not found", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "non-existent-tool"}, @@ -600,19 +600,19 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, }, }, - compositeDefs: []*mcpv1alpha1.VirtualMCPCompositeToolDefinition{}, + compositeDefs: []*mcpv1beta1.VirtualMCPCompositeToolDefinition{}, expectError: true, errorContains: "not found", }, { name: "error when duplicate tool names exist", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ CompositeTools: []vmcpconfig.CompositeToolConfig{ { @@ -633,13 +633,13 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, }, }, - compositeDefs: []*mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeDefs: []*mcpv1beta1.VirtualMCPCompositeToolDefinition{ { ObjectMeta: metav1.ObjectMeta{ Name: "referenced-tool", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "duplicate-tool", // Same name as inline tool Description: "A referenced tool with duplicate name", @@ -659,29 +659,29 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, { name: "error when k8sClient is nil", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, - compositeDefs: []*mcpv1alpha1.VirtualMCPCompositeToolDefinition{}, + compositeDefs: []*mcpv1beta1.VirtualMCPCompositeToolDefinition{}, k8sClient: nil, // No client provided expectError: true, errorContains: "k8sClient is required", }, { name: "handle multiple referenced tools", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "tool1"}, @@ -690,13 +690,13 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, }, }, - compositeDefs: []*mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeDefs: []*mcpv1beta1.VirtualMCPCompositeToolDefinition{ { ObjectMeta: metav1.ObjectMeta{ Name: "tool1", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "tool1", Description: "First referenced tool", @@ -715,7 +715,7 @@ func TestConverter_CompositeToolRefs(t *testing.T) { Name: "tool2", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "tool2", Description: "Second referenced tool", @@ -744,13 +744,13 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, { name: "convert referenced tool with parameters and timeout", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "referenced-tool"}, @@ -758,13 +758,13 @@ func TestConverter_CompositeToolRefs(t *testing.T) { }, }, }, - compositeDefs: []*mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeDefs: []*mcpv1beta1.VirtualMCPCompositeToolDefinition{ { ObjectMeta: metav1.ObjectMeta{ Name: "referenced-tool", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "referenced-tool", Description: "A referenced tool with parameters", @@ -910,23 +910,23 @@ func TestConverter_CompositeToolDefinitionFieldsPreserved(t *testing.T) { } // Create a VirtualMCPCompositeToolDefinition with all fields populated - compositeDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeDef := &mcpv1beta1.VirtualMCPCompositeToolDefinition{ ObjectMeta: metav1.ObjectMeta{ Name: "comprehensive-tool", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: expectedConfig, }, } - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "comprehensive-tool"}, @@ -957,19 +957,19 @@ func TestConverter_CompositeToolDefinitionFieldsPreserved(t *testing.T) { } // Test helpers for MCPToolConfig tests -func newMCPToolConfig(name, namespace string, filter []string, overrides map[string]mcpv1alpha1.ToolOverride) *mcpv1alpha1.MCPToolConfig { - return &mcpv1alpha1.MCPToolConfig{ +func newMCPToolConfig(name, namespace string, filter []string, overrides map[string]mcpv1beta1.ToolOverride) *mcpv1beta1.MCPToolConfig { + return &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, - Spec: mcpv1alpha1.MCPToolConfigSpec{ToolsFilter: filter, ToolsOverride: overrides}, + Spec: mcpv1beta1.MCPToolConfigSpec{ToolsFilter: filter, ToolsOverride: overrides}, } } -func toolOverride(name, desc string) mcpv1alpha1.ToolOverride { - return mcpv1alpha1.ToolOverride{Name: name, Description: desc} +func toolOverride(name, desc string) mcpv1beta1.ToolOverride { + return mcpv1beta1.ToolOverride{Name: name, Description: desc} } -func toolOverrideWithAnnotations(name, desc string, ann *mcpv1alpha1.ToolAnnotationsOverride) mcpv1alpha1.ToolOverride { - return mcpv1alpha1.ToolOverride{Name: name, Description: desc, Annotations: ann} +func toolOverrideWithAnnotations(name, desc string, ann *mcpv1beta1.ToolAnnotationsOverride) mcpv1beta1.ToolOverride { + return mcpv1beta1.ToolOverride{Name: name, Description: desc, Annotations: ann} } func vmcpToolOverride(name, desc string) *vmcpconfig.ToolOverride { @@ -990,7 +990,7 @@ func TestResolveMCPToolConfig(t *testing.T) { tests := []struct { name string configName string - existing *mcpv1alpha1.MCPToolConfig + existing *mcpv1beta1.MCPToolConfig expectError bool }{ { @@ -1007,7 +1007,7 @@ func TestResolveMCPToolConfig(t *testing.T) { name: "successfully resolve with overrides", configName: "config-with-overrides", existing: newMCPToolConfig("config-with-overrides", ns, []string{"fetch"}, - map[string]mcpv1alpha1.ToolOverride{"fetch": toolOverride("renamed_fetch", "Renamed tool")}), + map[string]mcpv1beta1.ToolOverride{"fetch": toolOverride("renamed_fetch", "Renamed tool")}), }, } @@ -1045,7 +1045,7 @@ func TestMergeToolConfigFilter(t *testing.T) { tests := []struct { name string existing []string - config *mcpv1alpha1.MCPToolConfig + config *mcpv1beta1.MCPToolConfig expected []string }{ { @@ -1092,25 +1092,25 @@ func TestMergeToolConfigOverrides(t *testing.T) { tests := []struct { name string existing map[string]*vmcpconfig.ToolOverride - config *mcpv1alpha1.MCPToolConfig + config *mcpv1beta1.MCPToolConfig expected map[string]*vmcpconfig.ToolOverride }{ { name: "merge when workload has none", existing: nil, - config: newMCPToolConfig("", "", nil, map[string]mcpv1alpha1.ToolOverride{"tool1": toolOverride("renamed_tool1", "Renamed description")}), + config: newMCPToolConfig("", "", nil, map[string]mcpv1beta1.ToolOverride{"tool1": toolOverride("renamed_tool1", "Renamed description")}), expected: map[string]*vmcpconfig.ToolOverride{"tool1": vmcpToolOverride("renamed_tool1", "Renamed description")}, }, { name: "inline takes precedence", existing: map[string]*vmcpconfig.ToolOverride{"tool1": vmcpToolOverride("inline_name", "Inline description")}, - config: newMCPToolConfig("", "", nil, map[string]mcpv1alpha1.ToolOverride{"tool1": toolOverride("config_name", "Config description")}), + config: newMCPToolConfig("", "", nil, map[string]mcpv1beta1.ToolOverride{"tool1": toolOverride("config_name", "Config description")}), expected: map[string]*vmcpconfig.ToolOverride{"tool1": vmcpToolOverride("inline_name", "Inline description")}, }, { name: "merge non-conflicting", existing: map[string]*vmcpconfig.ToolOverride{"tool1": vmcpToolOverride("inline_tool1", "Inline description")}, - config: newMCPToolConfig("", "", nil, map[string]mcpv1alpha1.ToolOverride{"tool2": toolOverride("config_tool2", "Config description")}), + config: newMCPToolConfig("", "", nil, map[string]mcpv1beta1.ToolOverride{"tool2": toolOverride("config_tool2", "Config description")}), expected: map[string]*vmcpconfig.ToolOverride{ "tool1": vmcpToolOverride("inline_tool1", "Inline description"), "tool2": vmcpToolOverride("config_tool2", "Config description"), @@ -1125,8 +1125,8 @@ func TestMergeToolConfigOverrides(t *testing.T) { { name: "merge preserves annotation overrides from CRD", existing: nil, - config: newMCPToolConfig("", "", nil, map[string]mcpv1alpha1.ToolOverride{ - "tool1": toolOverrideWithAnnotations("renamed", "desc", &mcpv1alpha1.ToolAnnotationsOverride{ + config: newMCPToolConfig("", "", nil, map[string]mcpv1beta1.ToolOverride{ + "tool1": toolOverrideWithAnnotations("renamed", "desc", &mcpv1beta1.ToolAnnotationsOverride{ Title: stringPtr("Custom Title"), ReadOnlyHint: boolPtr(true), }), @@ -1141,7 +1141,7 @@ func TestMergeToolConfigOverrides(t *testing.T) { { name: "merge preserves nil annotations", existing: nil, - config: newMCPToolConfig("", "", nil, map[string]mcpv1alpha1.ToolOverride{ + config: newMCPToolConfig("", "", nil, map[string]mcpv1beta1.ToolOverride{ "tool1": toolOverride("renamed", "desc"), }), expected: map[string]*vmcpconfig.ToolOverride{ @@ -1167,7 +1167,7 @@ func TestConvertCRDToolOverride(t *testing.T) { tests := []struct { name string - input mcpv1alpha1.ToolOverride + input mcpv1beta1.ToolOverride expected *vmcpconfig.ToolOverride }{ { @@ -1177,7 +1177,7 @@ func TestConvertCRDToolOverride(t *testing.T) { }, { name: "all annotation fields converted", - input: toolOverrideWithAnnotations("renamed", "desc", &mcpv1alpha1.ToolAnnotationsOverride{ + input: toolOverrideWithAnnotations("renamed", "desc", &mcpv1beta1.ToolAnnotationsOverride{ Title: stringPtr("My Title"), ReadOnlyHint: boolPtr(true), DestructiveHint: boolPtr(false), @@ -1194,7 +1194,7 @@ func TestConvertCRDToolOverride(t *testing.T) { }, { name: "title annotation only", - input: toolOverrideWithAnnotations("renamed", "desc", &mcpv1alpha1.ToolAnnotationsOverride{Title: stringPtr("Just Title")}), + input: toolOverrideWithAnnotations("renamed", "desc", &mcpv1beta1.ToolAnnotationsOverride{Title: stringPtr("Just Title")}), expected: vmcpToolOverrideWithAnnotations("renamed", "desc", &vmcpconfig.ToolAnnotationsOverride{ Title: stringPtr("Just Title"), }), @@ -1217,7 +1217,7 @@ func TestResolveToolConfigRefs(t *testing.T) { tests := []struct { name string tools []*vmcpconfig.WorkloadToolConfig - existingConfig *mcpv1alpha1.MCPToolConfig + existingConfig *mcpv1beta1.MCPToolConfig expectedWorkload string expectedFilter []string expectedOverride map[string]*vmcpconfig.ToolOverride @@ -1240,7 +1240,7 @@ func TestResolveToolConfigRefs(t *testing.T) { ToolConfigRef: &vmcpconfig.ToolConfigRef{Name: "test-config"}, }}, existingConfig: newMCPToolConfig("test-config", "default", []string{"fetch"}, - map[string]mcpv1alpha1.ToolOverride{"fetch": toolOverride("renamed_fetch", "Renamed fetch")}), + map[string]mcpv1beta1.ToolOverride{"fetch": toolOverride("renamed_fetch", "Renamed fetch")}), expectedWorkload: "backend1", expectedFilter: []string{"fetch"}, expectedOverride: map[string]*vmcpconfig.ToolOverride{"fetch": vmcpToolOverride("renamed_fetch", "Renamed fetch")}, @@ -1254,7 +1254,7 @@ func TestResolveToolConfigRefs(t *testing.T) { Overrides: map[string]*vmcpconfig.ToolOverride{"fetch": vmcpToolOverride("inline_fetch", "Inline override")}, }}, existingConfig: newMCPToolConfig("test-config", "default", []string{"config_tool"}, - map[string]mcpv1alpha1.ToolOverride{"fetch": toolOverride("config_fetch", "Config override")}), + map[string]mcpv1beta1.ToolOverride{"fetch": toolOverride("config_fetch", "Config override")}), expectedWorkload: "backend1", expectedFilter: []string{"inline_tool"}, expectedOverride: map[string]*vmcpconfig.ToolOverride{"fetch": vmcpToolOverride("inline_fetch", "Inline override")}, @@ -1277,7 +1277,7 @@ func TestResolveToolConfigRefs(t *testing.T) { converter.k8sClient = k8sClient srcAgg := &vmcpconfig.AggregationConfig{Tools: tt.tools} - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, } @@ -1302,7 +1302,7 @@ func TestResolveToolConfigRefs_FailClosed(t *testing.T) { tests := []struct { name string tools []*vmcpconfig.WorkloadToolConfig - existingConfig *mcpv1alpha1.MCPToolConfig + existingConfig *mcpv1beta1.MCPToolConfig expectError bool expectedErrMsg string }{ @@ -1352,7 +1352,7 @@ func TestResolveToolConfigRefs_FailClosed(t *testing.T) { converter.k8sClient = k8sClient srcAgg := &vmcpconfig.AggregationConfig{Tools: tt.tools} - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, } @@ -1376,17 +1376,17 @@ func TestConvert_MCPToolConfigFailClosed(t *testing.T) { tests := []struct { name string - vmcp *mcpv1alpha1.VirtualMCPServer - existingConfig *mcpv1alpha1.MCPToolConfig + vmcp *mcpv1beta1.VirtualMCPServer + existingConfig *mcpv1beta1.MCPToolConfig expectError bool expectedErrMsg string }{ { name: "Convert fails when MCPToolConfig not found", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ Aggregation: &vmcpconfig.AggregationConfig{ Tools: []*vmcpconfig.WorkloadToolConfig{{ @@ -1403,10 +1403,10 @@ func TestConvert_MCPToolConfigFailClosed(t *testing.T) { }, { name: "Convert succeeds when MCPToolConfig exists", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ Aggregation: &vmcpconfig.AggregationConfig{ Tools: []*vmcpconfig.WorkloadToolConfig{{ @@ -1422,10 +1422,10 @@ func TestConvert_MCPToolConfigFailClosed(t *testing.T) { }, { name: "Convert succeeds when no Aggregation specified", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, existingConfig: nil, @@ -1467,14 +1467,14 @@ func TestConvert_MCPToolConfigFailClosed(t *testing.T) { func TestConverter_InlineTelemetryIgnored(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, Config: vmcpconfig.Config{ @@ -1499,14 +1499,14 @@ func TestConverter_InlineTelemetryIgnored(t *testing.T) { func TestConverter_TelemetryNil(t *testing.T) { t.Parallel() - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, Config: vmcpconfig.Config{ @@ -1529,14 +1529,14 @@ func TestConverter_SessionStorage(t *testing.T) { tests := []struct { name string - sessionStorage *mcpv1alpha1.SessionStorageConfig + sessionStorage *mcpv1beta1.SessionStorageConfig inlineConfig *vmcpconfig.SessionStorageConfig expectedStorage *vmcpconfig.SessionStorageConfig }{ { name: "redis provider populates SessionStorage", - sessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + sessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379", DB: 2, KeyPrefix: "thv:", @@ -1550,7 +1550,7 @@ func TestConverter_SessionStorage(t *testing.T) { }, { name: "memory provider results in nil SessionStorage", - sessionStorage: &mcpv1alpha1.SessionStorageConfig{ + sessionStorage: &mcpv1beta1.SessionStorageConfig{ Provider: "memory", }, expectedStorage: nil, @@ -1575,13 +1575,13 @@ func TestConverter_SessionStorage(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ SessionStorage: tt.inlineConfig, }, @@ -1781,34 +1781,34 @@ func TestConvert_AuthServerConfigIntegration(t *testing.T) { ResourceURL: "https://resource.example.com", }, nil) - oidcCfg := newTestMCPOIDCConfigInline(&mcpv1alpha1.InlineOIDCSharedConfig{ + oidcCfg := newTestMCPOIDCConfigInline(&mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://incoming-issuer.example.com", }) k8sClient := newTestK8sClient(t, oidcCfg) converter, err := NewConverter(mockResolver, k8sClient) require.NoError(t, err) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "https://my-vmcp.example.com"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "https://my-vmcp.example.com"}, }, - AuthServerConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + AuthServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://authserver.example.com", - SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{ + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ {Name: "signing-key", Key: "private.pem"}, }, - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "corp-idp", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://corp.example.com", ClientID: "corp-client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "corp-secret", Key: "client-secret", }, @@ -1847,17 +1847,17 @@ func TestConvert_AuthServerConfigIntegration(t *testing.T) { func TestConverter_TelemetryConfigRef(t *testing.T) { t.Parallel() - telemetryCfg := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryCfg := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{Name: "shared-telemetry", Namespace: "default"}, - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "https://otel-collector:4317", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{ + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{ Enabled: true, SamplingRate: "0.5", }, - Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{ + Metrics: &mcpv1beta1.OpenTelemetryMetricsConfig{ Enabled: true, }, }, @@ -1868,12 +1868,12 @@ func TestConverter_TelemetryConfigRef(t *testing.T) { converter, err := NewConverter(newNoOpMockResolver(t), k8sClient) require.NoError(t, err) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "shared-telemetry", ServiceName: "custom-svc", }, diff --git a/cmd/thv-operator/test-integration/embedding-server/embeddingserver_creation_test.go b/cmd/thv-operator/test-integration/embedding-server/embeddingserver_creation_test.go index efb3841a54..8160fc709f 100644 --- a/cmd/thv-operator/test-integration/embedding-server/embeddingserver_creation_test.go +++ b/cmd/thv-operator/test-integration/embedding-server/embeddingserver_creation_test.go @@ -16,7 +16,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // TestCase defines a table-driven test case for EmbeddingServer controller @@ -30,7 +30,7 @@ type TestCase struct { // InitialState represents the initial Kubernetes objects to create type InitialState struct { - EmbeddingServer *mcpv1alpha1.EmbeddingServer + EmbeddingServer *mcpv1beta1.EmbeddingServer Secrets []*corev1.Secret } @@ -42,7 +42,7 @@ type FinalState struct { // Service expected state (nil means don't check specific fields) Service *corev1.Service // EmbeddingServer status expectations - Status *mcpv1alpha1.EmbeddingServerStatus + Status *mcpv1beta1.EmbeddingServerStatus } var _ = Describe("EmbeddingServer Controller Integration Tests", func() { @@ -65,7 +65,7 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { // Helper to run a single test case runTestCase := func(tc TestCase) { Context(tc.Name, Ordered, func() { - var createdEmbeddingServer *mcpv1alpha1.EmbeddingServer + var createdEmbeddingServer *mcpv1beta1.EmbeddingServer BeforeAll(func() { namespace := tc.InitialState.EmbeddingServer.Namespace @@ -80,7 +80,7 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { Expect(k8sClient.Create(ctx, tc.InitialState.EmbeddingServer)).Should(Succeed()) // Fetch the created resource to get UID etc. - createdEmbeddingServer = &mcpv1alpha1.EmbeddingServer{} + createdEmbeddingServer = &mcpv1beta1.EmbeddingServer{} Eventually(func() error { return k8sClient.Get(ctx, types.NamespacedName{ Name: tc.InitialState.EmbeddingServer.Name, @@ -135,7 +135,7 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { // Status assertions It("Should have expected status and finalizer", func() { Eventually(func() bool { - actual := &mcpv1alpha1.EmbeddingServer{} + actual := &mcpv1beta1.EmbeddingServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: tc.InitialState.EmbeddingServer.Name, Namespace: tc.InitialState.EmbeddingServer.Namespace, @@ -154,12 +154,12 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with minimal config (verifies defaults)", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-defaults", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ // Only required fields - model and image Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", @@ -208,7 +208,7 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { Ports: []corev1.ServicePort{{Port: 8080}}, }, }, - Status: &mcpv1alpha1.EmbeddingServerStatus{ + Status: &mcpv1beta1.EmbeddingServerStatus{ // URL uses default port URL: "http://test-defaults.default.svc.cluster.local:8080", }, @@ -217,12 +217,12 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating a basic EmbeddingServer", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-basic", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 8080, @@ -264,7 +264,7 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { Ports: []corev1.ServicePort{{Port: 8080}}, }, }, - Status: &mcpv1alpha1.EmbeddingServerStatus{ + Status: &mcpv1beta1.EmbeddingServerStatus{ URL: "http://test-basic.default.svc.cluster.local:8080", }, }, @@ -272,16 +272,16 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with model cache enabled", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-with-cache", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 8080, - ModelCache: &mcpv1alpha1.ModelCacheConfig{ + ModelCache: &mcpv1beta1.ModelCacheConfig{ Enabled: true, Size: "20Gi", }, @@ -318,18 +318,18 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with resource requirements", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-resources", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 8080, - Resources: mcpv1alpha1.ResourceRequirements{ - Limits: mcpv1alpha1.ResourceList{CPU: "2", Memory: "4Gi"}, - Requests: mcpv1alpha1.ResourceList{CPU: "500m", Memory: "1Gi"}, + Resources: mcpv1beta1.ResourceRequirements{ + Limits: mcpv1beta1.ResourceList{CPU: "2", Memory: "4Gi"}, + Requests: mcpv1beta1.ResourceList{CPU: "500m", Memory: "1Gi"}, }, }, }, @@ -355,12 +355,12 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with custom replicas", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-replicas", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 8080, @@ -379,12 +379,12 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with invalid PodTemplateSpec", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-invalid-podtemplate", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 8080, @@ -395,12 +395,12 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { }, }, FinalState: FinalState{ - Status: &mcpv1alpha1.EmbeddingServerStatus{ - Phase: mcpv1alpha1.EmbeddingServerPhaseFailed, + Status: &mcpv1beta1.EmbeddingServerStatus{ + Phase: mcpv1beta1.EmbeddingServerPhaseFailed, Conditions: []metav1.Condition{{ - Type: mcpv1alpha1.ConditionPodTemplateValid, + Type: mcpv1beta1.ConditionPodTemplateValid, Status: metav1.ConditionFalse, - Reason: mcpv1alpha1.ConditionReasonPodTemplateInvalid, + Reason: mcpv1beta1.ConditionReasonPodTemplateInvalid, }}, }, }, @@ -408,12 +408,12 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with valid PodTemplateSpec (nodeSelector)", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-valid-podtemplate", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 8080, @@ -433,9 +433,9 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { }, }, }, - Status: &mcpv1alpha1.EmbeddingServerStatus{ + Status: &mcpv1beta1.EmbeddingServerStatus{ Conditions: []metav1.Condition{{ - Type: mcpv1alpha1.ConditionPodTemplateValid, + Type: mcpv1beta1.ConditionPodTemplateValid, Status: metav1.ConditionTrue, }}, }, @@ -444,16 +444,16 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with HuggingFace token secret", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-hf-token", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 8080, - HFTokenSecretRef: &mcpv1alpha1.SecretKeyRef{ + HFTokenSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "hf-token-secret", Key: "token", }, @@ -493,16 +493,16 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with custom environment variables", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-custom-env", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 8080, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "CUSTOM_VAR_1", Value: "value1"}, {Name: "CUSTOM_VAR_2", Value: "value2"}, }, @@ -530,12 +530,12 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with custom args", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-custom-args", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 8080, @@ -561,12 +561,12 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with custom port", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-custom-port", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 9090, @@ -587,18 +587,18 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { }, }, Service: &corev1.Service{Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 9090}}}}, - Status: &mcpv1alpha1.EmbeddingServerStatus{URL: "http://test-custom-port.default.svc.cluster.local:9090"}, + Status: &mcpv1beta1.EmbeddingServerStatus{URL: "http://test-custom-port.default.svc.cluster.local:9090"}, }, }, { Name: "When creating an EmbeddingServer with ImagePullPolicy Always", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-imagepullpolicy-always", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", ImagePullPolicy: "Always", @@ -623,12 +623,12 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with ImagePullPolicy Never", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-imagepullpolicy-never", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", ImagePullPolicy: "Never", @@ -653,15 +653,15 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with model cache and custom storage class", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-cache-storageclass", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", - ModelCache: &mcpv1alpha1.ModelCacheConfig{ + ModelCache: &mcpv1beta1.ModelCacheConfig{ Enabled: true, Size: "50Gi", StorageClassName: ptr.To("fast-ssd"), @@ -689,15 +689,15 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with model cache ReadWriteMany access mode", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-cache-rwx", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", - ModelCache: &mcpv1alpha1.ModelCacheConfig{ + ModelCache: &mcpv1beta1.ModelCacheConfig{ Enabled: true, Size: "10Gi", AccessMode: "ReadWriteMany", @@ -721,12 +721,12 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with PodTemplateSpec tolerations", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-tolerations", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", PodTemplateSpec: &runtime.RawExtension{ @@ -754,12 +754,12 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with PodTemplateSpec serviceAccountName", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-serviceaccount", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", PodTemplateSpec: &runtime.RawExtension{ @@ -784,17 +784,17 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with ResourceOverrides on StatefulSet", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-resource-overrides-sts", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", - ResourceOverrides: &mcpv1alpha1.EmbeddingResourceOverrides{ - StatefulSet: &mcpv1alpha1.EmbeddingStatefulSetOverrides{ - ResourceMetadataOverrides: mcpv1alpha1.ResourceMetadataOverrides{ + ResourceOverrides: &mcpv1beta1.EmbeddingResourceOverrides{ + StatefulSet: &mcpv1beta1.EmbeddingStatefulSetOverrides{ + ResourceMetadataOverrides: mcpv1beta1.ResourceMetadataOverrides{ Annotations: map[string]string{"custom-annotation": "sts-value"}, Labels: map[string]string{"custom-label": "sts-value"}, }, @@ -823,16 +823,16 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with ResourceOverrides on Service", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-resource-overrides-svc", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", - ResourceOverrides: &mcpv1alpha1.EmbeddingResourceOverrides{ - Service: &mcpv1alpha1.ResourceMetadataOverrides{ + ResourceOverrides: &mcpv1beta1.EmbeddingResourceOverrides{ + Service: &mcpv1beta1.ResourceMetadataOverrides{ Annotations: map[string]string{"service-annotation": "svc-value"}, Labels: map[string]string{"service-label": "svc-value"}, }, @@ -863,17 +863,17 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer with ResourceOverrides on pod template", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-resource-overrides-pod", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", - ResourceOverrides: &mcpv1alpha1.EmbeddingResourceOverrides{ - StatefulSet: &mcpv1alpha1.EmbeddingStatefulSetOverrides{ - PodTemplateMetadataOverrides: &mcpv1alpha1.ResourceMetadataOverrides{ + ResourceOverrides: &mcpv1beta1.EmbeddingResourceOverrides{ + StatefulSet: &mcpv1beta1.EmbeddingStatefulSetOverrides{ + PodTemplateMetadataOverrides: &mcpv1beta1.ResourceMetadataOverrides{ Annotations: map[string]string{"pod-annotation": "pod-value"}, Labels: map[string]string{"pod-label": "pod-value"}, }, @@ -905,12 +905,12 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer verifies container port", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-container-port", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 8080, @@ -939,12 +939,12 @@ var _ = Describe("EmbeddingServer Controller Integration Tests", func() { { Name: "When creating an EmbeddingServer verifies Service selector and type", InitialState: InitialState{ - EmbeddingServer: &mcpv1alpha1.EmbeddingServer{ + EmbeddingServer: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-service-selector", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", }, @@ -1141,7 +1141,7 @@ func verifyServiceEqualsG(g Gomega, actual, expected *corev1.Service) { } // verifyStatusEquals checks status fields match and finalizer is present. -func verifyStatusEquals(actual *mcpv1alpha1.EmbeddingServer, expected *mcpv1alpha1.EmbeddingServerStatus) bool { +func verifyStatusEquals(actual *mcpv1beta1.EmbeddingServer, expected *mcpv1beta1.EmbeddingServerStatus) bool { if expected != nil && expected.Phase != "" && actual.Status.Phase != expected.Phase { return false } @@ -1166,9 +1166,9 @@ func containsString(slice []string, s string) bool { } // verifyOwnerReference checks owner reference is set correctly. -func verifyOwnerReference(ownerRefs []metav1.OwnerReference, embedding *mcpv1alpha1.EmbeddingServer, _ string) { +func verifyOwnerReference(ownerRefs []metav1.OwnerReference, embedding *mcpv1beta1.EmbeddingServer, _ string) { Expect(ownerRefs).To(HaveLen(1)) - Expect(ownerRefs[0].APIVersion).To(Equal("toolhive.stacklok.dev/v1alpha1")) + Expect(ownerRefs[0].APIVersion).To(Equal("toolhive.stacklok.dev/v1beta1")) Expect(ownerRefs[0].Kind).To(Equal("EmbeddingServer")) Expect(ownerRefs[0].Name).To(Equal(embedding.Name)) Expect(ownerRefs[0].UID).To(Equal(embedding.UID)) diff --git a/cmd/thv-operator/test-integration/embedding-server/embeddingserver_update_test.go b/cmd/thv-operator/test-integration/embedding-server/embeddingserver_update_test.go index 12aecdffa3..b91f9d021d 100644 --- a/cmd/thv-operator/test-integration/embedding-server/embeddingserver_update_test.go +++ b/cmd/thv-operator/test-integration/embedding-server/embeddingserver_update_test.go @@ -15,20 +15,20 @@ import ( "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // UpdateTestCase defines a test case for EmbeddingServer update scenarios. type UpdateTestCase struct { Name string - InitialState *mcpv1alpha1.EmbeddingServer + InitialState *mcpv1beta1.EmbeddingServer Updates []UpdateStep } // UpdateStep defines a single update operation and its expected result. type UpdateStep struct { Name string - ApplyUpdate func(es *mcpv1alpha1.EmbeddingServer) + ApplyUpdate func(es *mcpv1beta1.EmbeddingServer) // Expected StatefulSet state after the update (nil means expect no changes) ExpectedStatefulSet *appsv1.StatefulSet // Expected Service state after the update (nil means expect no changes) @@ -46,12 +46,12 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { updateTestCases := []UpdateTestCase{ { Name: "When updating EmbeddingServer image", - InitialState: &mcpv1alpha1.EmbeddingServer{ + InitialState: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-update-image", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:v1.0", Port: 8080, @@ -60,7 +60,7 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { Updates: []UpdateStep{ { Name: "Should update StatefulSet when image changes to v2.0", - ApplyUpdate: func(es *mcpv1alpha1.EmbeddingServer) { + ApplyUpdate: func(es *mcpv1beta1.EmbeddingServer) { es.Spec.Image = "ghcr.io/huggingface/text-embeddings-inference:v2.0" }, ExpectedStatefulSet: &appsv1.StatefulSet{ @@ -77,7 +77,7 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { }, { Name: "Should update StatefulSet when image changes to v3.0", - ApplyUpdate: func(es *mcpv1alpha1.EmbeddingServer) { + ApplyUpdate: func(es *mcpv1beta1.EmbeddingServer) { es.Spec.Image = "ghcr.io/huggingface/text-embeddings-inference:v3.0" }, ExpectedStatefulSet: &appsv1.StatefulSet{ @@ -96,12 +96,12 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { }, { Name: "When updating EmbeddingServer replicas", - InitialState: &mcpv1alpha1.EmbeddingServer{ + InitialState: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-update-replicas", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 8080, @@ -111,7 +111,7 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { Updates: []UpdateStep{ { Name: "Should scale up to 3 replicas", - ApplyUpdate: func(es *mcpv1alpha1.EmbeddingServer) { + ApplyUpdate: func(es *mcpv1beta1.EmbeddingServer) { es.Spec.Replicas = ptr.To(int32(3)) }, ExpectedStatefulSet: &appsv1.StatefulSet{ @@ -122,7 +122,7 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { }, { Name: "Should scale down to 2 replicas", - ApplyUpdate: func(es *mcpv1alpha1.EmbeddingServer) { + ApplyUpdate: func(es *mcpv1beta1.EmbeddingServer) { es.Spec.Replicas = ptr.To(int32(2)) }, ExpectedStatefulSet: &appsv1.StatefulSet{ @@ -135,12 +135,12 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { }, { Name: "When updating EmbeddingServer model", - InitialState: &mcpv1alpha1.EmbeddingServer{ + InitialState: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-update-model", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 8080, @@ -149,7 +149,7 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { Updates: []UpdateStep{ { Name: "Should update StatefulSet args when model changes", - ApplyUpdate: func(es *mcpv1alpha1.EmbeddingServer) { + ApplyUpdate: func(es *mcpv1beta1.EmbeddingServer) { es.Spec.Model = "sentence-transformers/all-mpnet-base-v2" }, ExpectedStatefulSet: &appsv1.StatefulSet{ @@ -168,16 +168,16 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { }, { Name: "When updating EmbeddingServer environment variables", - InitialState: &mcpv1alpha1.EmbeddingServer{ + InitialState: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-update-env", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 8080, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "LOG_LEVEL", Value: "info"}, }, }, @@ -185,8 +185,8 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { Updates: []UpdateStep{ { Name: "Should update StatefulSet when env var value changes", - ApplyUpdate: func(es *mcpv1alpha1.EmbeddingServer) { - es.Spec.Env = []mcpv1alpha1.EnvVar{ + ApplyUpdate: func(es *mcpv1beta1.EmbeddingServer) { + es.Spec.Env = []mcpv1beta1.EnvVar{ {Name: "LOG_LEVEL", Value: "debug"}, } }, @@ -204,8 +204,8 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { }, { Name: "Should update StatefulSet when new env var is added", - ApplyUpdate: func(es *mcpv1alpha1.EmbeddingServer) { - es.Spec.Env = []mcpv1alpha1.EnvVar{ + ApplyUpdate: func(es *mcpv1beta1.EmbeddingServer) { + es.Spec.Env = []mcpv1beta1.EnvVar{ {Name: "LOG_LEVEL", Value: "debug"}, {Name: "NEW_VAR", Value: "new_value"}, } @@ -229,12 +229,12 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { }, { Name: "When updating EmbeddingServer port", - InitialState: &mcpv1alpha1.EmbeddingServer{ + InitialState: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-update-port", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Port: 8080, @@ -243,7 +243,7 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { Updates: []UpdateStep{ { Name: "Should update StatefulSet and Service when port changes", - ApplyUpdate: func(es *mcpv1alpha1.EmbeddingServer) { + ApplyUpdate: func(es *mcpv1beta1.EmbeddingServer) { es.Spec.Port = 9090 }, ExpectedStatefulSet: &appsv1.StatefulSet{ @@ -267,27 +267,27 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { }, { Name: "When updating EmbeddingServer resources", - InitialState: &mcpv1alpha1.EmbeddingServer{ + InitialState: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-update-resources", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", - Resources: mcpv1alpha1.ResourceRequirements{ - Limits: mcpv1alpha1.ResourceList{CPU: "1", Memory: "2Gi"}, - Requests: mcpv1alpha1.ResourceList{CPU: "500m", Memory: "1Gi"}, + Resources: mcpv1beta1.ResourceRequirements{ + Limits: mcpv1beta1.ResourceList{CPU: "1", Memory: "2Gi"}, + Requests: mcpv1beta1.ResourceList{CPU: "500m", Memory: "1Gi"}, }, }, }, Updates: []UpdateStep{ { Name: "Should update StatefulSet when resource limits change", - ApplyUpdate: func(es *mcpv1alpha1.EmbeddingServer) { - es.Spec.Resources = mcpv1alpha1.ResourceRequirements{ - Limits: mcpv1alpha1.ResourceList{CPU: "2", Memory: "4Gi"}, - Requests: mcpv1alpha1.ResourceList{CPU: "1", Memory: "2Gi"}, + ApplyUpdate: func(es *mcpv1beta1.EmbeddingServer) { + es.Spec.Resources = mcpv1beta1.ResourceRequirements{ + Limits: mcpv1beta1.ResourceList{CPU: "2", Memory: "4Gi"}, + Requests: mcpv1beta1.ResourceList{CPU: "1", Memory: "2Gi"}, } }, ExpectedStatefulSet: &appsv1.StatefulSet{ @@ -315,12 +315,12 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { }, { Name: "When updating EmbeddingServer args", - InitialState: &mcpv1alpha1.EmbeddingServer{ + InitialState: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-update-args", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", Args: []string{"--max-concurrent-requests", "256"}, @@ -329,7 +329,7 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { Updates: []UpdateStep{ { Name: "Should update StatefulSet when args change", - ApplyUpdate: func(es *mcpv1alpha1.EmbeddingServer) { + ApplyUpdate: func(es *mcpv1beta1.EmbeddingServer) { es.Spec.Args = []string{"--max-concurrent-requests", "512", "--tokenization-workers", "4"} }, ExpectedStatefulSet: &appsv1.StatefulSet{ @@ -346,7 +346,7 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { }, { Name: "Should update StatefulSet when args are removed", - ApplyUpdate: func(es *mcpv1alpha1.EmbeddingServer) { + ApplyUpdate: func(es *mcpv1beta1.EmbeddingServer) { es.Spec.Args = nil }, ExpectedStatefulSet: &appsv1.StatefulSet{ @@ -365,12 +365,12 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { }, { Name: "When updating EmbeddingServer ImagePullPolicy", - InitialState: &mcpv1alpha1.EmbeddingServer{ + InitialState: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-update-imagepullpolicy", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", ImagePullPolicy: "IfNotPresent", @@ -379,7 +379,7 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { Updates: []UpdateStep{ { Name: "Should update StatefulSet when ImagePullPolicy changes", - ApplyUpdate: func(es *mcpv1alpha1.EmbeddingServer) { + ApplyUpdate: func(es *mcpv1beta1.EmbeddingServer) { es.Spec.ImagePullPolicy = "Always" }, ExpectedStatefulSet: &appsv1.StatefulSet{ @@ -398,12 +398,12 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { }, { Name: "When updating EmbeddingServer ResourceOverrides", - InitialState: &mcpv1alpha1.EmbeddingServer{ + InitialState: &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-update-resourceoverrides", Namespace: defaultNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "sentence-transformers/all-MiniLM-L6-v2", Image: "ghcr.io/huggingface/text-embeddings-inference:latest", }, @@ -411,10 +411,10 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { Updates: []UpdateStep{ { Name: "Should update StatefulSet when adding annotations", - ApplyUpdate: func(es *mcpv1alpha1.EmbeddingServer) { - es.Spec.ResourceOverrides = &mcpv1alpha1.EmbeddingResourceOverrides{ - StatefulSet: &mcpv1alpha1.EmbeddingStatefulSetOverrides{ - ResourceMetadataOverrides: mcpv1alpha1.ResourceMetadataOverrides{ + ApplyUpdate: func(es *mcpv1beta1.EmbeddingServer) { + es.Spec.ResourceOverrides = &mcpv1beta1.EmbeddingResourceOverrides{ + StatefulSet: &mcpv1beta1.EmbeddingStatefulSetOverrides{ + ResourceMetadataOverrides: mcpv1beta1.ResourceMetadataOverrides{ Annotations: map[string]string{"new-annotation": "new-value"}, }, }, @@ -428,14 +428,14 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { }, { Name: "Should update StatefulSet and Service when adding annotations to both", - ApplyUpdate: func(es *mcpv1alpha1.EmbeddingServer) { - es.Spec.ResourceOverrides = &mcpv1alpha1.EmbeddingResourceOverrides{ - StatefulSet: &mcpv1alpha1.EmbeddingStatefulSetOverrides{ - ResourceMetadataOverrides: mcpv1alpha1.ResourceMetadataOverrides{ + ApplyUpdate: func(es *mcpv1beta1.EmbeddingServer) { + es.Spec.ResourceOverrides = &mcpv1beta1.EmbeddingResourceOverrides{ + StatefulSet: &mcpv1beta1.EmbeddingStatefulSetOverrides{ + ResourceMetadataOverrides: mcpv1beta1.ResourceMetadataOverrides{ Annotations: map[string]string{"new-annotation": "new-value"}, }, }, - Service: &mcpv1alpha1.ResourceMetadataOverrides{ + Service: &mcpv1beta1.ResourceMetadataOverrides{ Annotations: map[string]string{"service-annotation": "service-value"}, }, } @@ -458,7 +458,7 @@ var _ = Describe("EmbeddingServer Controller Update Tests", func() { // Helper to run a single update test case runUpdateTestCase := func(tc UpdateTestCase) { Context(tc.Name, Ordered, func() { - var embeddingServer *mcpv1alpha1.EmbeddingServer + var embeddingServer *mcpv1beta1.EmbeddingServer BeforeAll(func() { _ = k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: tc.InitialState.Namespace}}) diff --git a/cmd/thv-operator/test-integration/embedding-server/suite_test.go b/cmd/thv-operator/test-integration/embedding-server/suite_test.go index 99c841a779..6f463b9ec6 100644 --- a/cmd/thv-operator/test-integration/embedding-server/suite_test.go +++ b/cmd/thv-operator/test-integration/embedding-server/suite_test.go @@ -23,7 +23,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/controllers" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -69,7 +69,7 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) - err = mcpv1alpha1.AddToScheme(scheme.Scheme) + err = mcpv1beta1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) // Add other schemes that the controllers use diff --git a/cmd/thv-operator/test-integration/mcp-external-auth/mcpexternalauthconfig_controller_integration_test.go b/cmd/thv-operator/test-integration/mcp-external-auth/mcpexternalauthconfig_controller_integration_test.go index 09b7937b66..9a31659385 100644 --- a/cmd/thv-operator/test-integration/mcp-external-auth/mcpexternalauthconfig_controller_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-external-auth/mcpexternalauthconfig_controller_integration_test.go @@ -14,7 +14,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { @@ -28,7 +28,7 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { var ( namespace string authConfigName string - authConfig *mcpv1alpha1.MCPExternalAuthConfig + authConfig *mcpv1beta1.MCPExternalAuthConfig oauthSecret *corev1.Secret oauthSecretName string ) @@ -59,17 +59,17 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { Expect(k8sClient.Create(ctx, oauthSecret)).Should(Succeed()) // Define the MCPExternalAuthConfig resource - authConfig = &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig = &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: authConfigName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: "tokenExchange", - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: oauthSecretName, Key: "client-secret", }, @@ -93,7 +93,7 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { It("Should calculate and set config hash in status", func() { // Wait for the status to be updated with the config hash Eventually(func() bool { - updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + updatedAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: authConfigName, Namespace: namespace, @@ -106,7 +106,7 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { }, timeout, interval).Should(BeTrue()) // Verify the config hash is not empty - updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + updatedAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: authConfigName, Namespace: namespace, @@ -117,7 +117,7 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { }) It("Should have a finalizer added", func() { - updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + updatedAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: authConfigName, Namespace: namespace, @@ -131,9 +131,9 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { var ( namespace string authConfigName string - authConfig *mcpv1alpha1.MCPExternalAuthConfig + authConfig *mcpv1beta1.MCPExternalAuthConfig mcpServerName string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer oauthSecret *corev1.Secret oauthSecretName string configHash string @@ -166,17 +166,17 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { Expect(k8sClient.Create(ctx, oauthSecret)).Should(Succeed()) // Create MCPExternalAuthConfig - authConfig = &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig = &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: authConfigName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: "tokenExchange", - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "test-client-id-2", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: oauthSecretName, Key: "client-secret", }, @@ -189,7 +189,7 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { // Wait for the auth config to have a hash Eventually(func() bool { - updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + updatedAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: authConfigName, Namespace: namespace, @@ -202,16 +202,16 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { }, timeout, interval).Should(BeTrue()) // Create MCPServer with external auth reference - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/stackloklabs/mcp-fetch:latest", Transport: "stdio", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: authConfigName, }, }, @@ -229,7 +229,7 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { It("Should propagate external auth config hash to MCPServer status", func() { // Wait for the MCPServer status to be updated with the external auth config hash Eventually(func() bool { - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -245,7 +245,7 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { It("Should update MCPExternalAuthConfig status with referencing workload", func() { // Wait for the auth config status to be updated with the referencing workload Eventually(func() bool { - updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + updatedAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: authConfigName, Namespace: namespace, @@ -327,9 +327,9 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { var ( namespace string authConfigName string - authConfig *mcpv1alpha1.MCPExternalAuthConfig + authConfig *mcpv1beta1.MCPExternalAuthConfig mcpServerName string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer oauthSecret *corev1.Secret oauthSecretName string originalHash string @@ -362,17 +362,17 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { Expect(k8sClient.Create(ctx, oauthSecret)).Should(Succeed()) // Create MCPExternalAuthConfig - authConfig = &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig = &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: authConfigName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: "tokenExchange", - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://oauth.example.com/token", ClientID: "original-client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: oauthSecretName, Key: "client-secret", }, @@ -385,7 +385,7 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { // Wait for the auth config to have a hash Eventually(func() bool { - updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + updatedAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: authConfigName, Namespace: namespace, @@ -398,16 +398,16 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { }, timeout, interval).Should(BeTrue()) // Create MCPServer with external auth reference - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/stackloklabs/mcp-fetch:latest", Transport: "stdio", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: authConfigName, }, }, @@ -416,7 +416,7 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { // Wait for the MCPServer to have the original hash Eventually(func() bool { - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -438,7 +438,7 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { It("Should update config hash when auth config is modified", func() { // Update the auth config Eventually(func() error { - updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + updatedAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: authConfigName, Namespace: namespace, @@ -454,7 +454,7 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { // Wait for the config hash to change var newHash string Eventually(func() bool { - updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + updatedAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: authConfigName, Namespace: namespace, @@ -473,7 +473,7 @@ var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { It("Should trigger MCPServer reconciliation with updated hash", func() { // Wait for the MCPServer to get the updated hash Eventually(func() bool { - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, diff --git a/cmd/thv-operator/test-integration/mcp-external-auth/suite_test.go b/cmd/thv-operator/test-integration/mcp-external-auth/suite_test.go index a6a12c83e3..9637a421b5 100644 --- a/cmd/thv-operator/test-integration/mcp-external-auth/suite_test.go +++ b/cmd/thv-operator/test-integration/mcp-external-auth/suite_test.go @@ -25,7 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/controllers" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -70,7 +70,7 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) - err = mcpv1alpha1.AddToScheme(scheme.Scheme) + err = mcpv1beta1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) // Add other schemes that the controllers use diff --git a/cmd/thv-operator/test-integration/mcp-group/mcpgroup_controller_integration_test.go b/cmd/thv-operator/test-integration/mcp-group/mcpgroup_controller_integration_test.go index d262095a12..9a25e7535a 100644 --- a/cmd/thv-operator/test-integration/mcp-group/mcpgroup_controller_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-group/mcpgroup_controller_integration_test.go @@ -13,7 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) var _ = Describe("MCPGroup Controller Integration Tests", func() { @@ -26,10 +26,10 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { var ( namespace string mcpGroupName string - mcpGroup *mcpv1alpha1.MCPGroup - server1 *mcpv1alpha1.MCPServer - server2 *mcpv1alpha1.MCPServer - serverNoGroup *mcpv1alpha1.MCPServer + mcpGroup *mcpv1beta1.MCPGroup + server1 *mcpv1beta1.MCPServer + server2 *mcpv1beta1.MCPServer + serverNoGroup *mcpv1beta1.MCPServer ) BeforeAll(func() { @@ -45,36 +45,36 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) // Create MCPServers first - server1 = &mcpv1alpha1.MCPServer{ + server1 = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, server1)).Should(Succeed()) - server2 = &mcpv1alpha1.MCPServer{ + server2 = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server2", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, server2)).Should(Succeed()) - serverNoGroup = &mcpv1alpha1.MCPServer{ + serverNoGroup = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-no-group", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", // No GroupRef }, @@ -83,47 +83,47 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { // Update server statuses to Running Eventually(func() error { - freshServer := &mcpv1alpha1.MCPServer{} + freshServer := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: server1.Name, Namespace: namespace}, freshServer); err != nil { return err } - freshServer.Status.Phase = mcpv1alpha1.MCPServerPhaseReady + freshServer.Status.Phase = mcpv1beta1.MCPServerPhaseReady return k8sClient.Status().Update(ctx, freshServer) }, timeout, interval).Should(Succeed()) Eventually(func() error { - freshServer := &mcpv1alpha1.MCPServer{} + freshServer := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: server2.Name, Namespace: namespace}, freshServer); err != nil { return err } - freshServer.Status.Phase = mcpv1alpha1.MCPServerPhaseReady + freshServer.Status.Phase = mcpv1beta1.MCPServerPhaseReady return k8sClient.Status().Update(ctx, freshServer) }, timeout, interval).Should(Succeed()) // Verify the statuses were updated Eventually(func() bool { - freshServer := &mcpv1alpha1.MCPServer{} + freshServer := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: server1.Name, Namespace: namespace}, freshServer); err != nil { return false } - return freshServer.Status.Phase == mcpv1alpha1.MCPServerPhaseReady + return freshServer.Status.Phase == mcpv1beta1.MCPServerPhaseReady }, timeout, interval).Should(BeTrue()) Eventually(func() bool { - freshServer := &mcpv1alpha1.MCPServer{} + freshServer := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: server2.Name, Namespace: namespace}, freshServer); err != nil { return false } - return freshServer.Status.Phase == mcpv1alpha1.MCPServerPhaseReady + return freshServer.Status.Phase == mcpv1beta1.MCPServerPhaseReady }, timeout, interval).Should(BeTrue()) // Now create the MCPGroup - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for integration tests", }, } @@ -149,7 +149,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { It("Should find existing MCPServers and update status", func() { // Check that the group found both servers Eventually(func() int32 { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, @@ -160,8 +160,8 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { }, timeout, interval).Should(Equal(int32(2))) // The group should be Ready after successful reconciliation - Eventually(func() mcpv1alpha1.MCPGroupPhase { - updatedGroup := &mcpv1alpha1.MCPGroup{} + Eventually(func() mcpv1beta1.MCPGroupPhase { + updatedGroup := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, @@ -169,11 +169,11 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { return "" } return updatedGroup.Status.Phase - }, timeout, interval).Should(Equal(mcpv1alpha1.MCPGroupPhaseReady)) + }, timeout, interval).Should(Equal(mcpv1beta1.MCPGroupPhaseReady)) // Verify ObservedGeneration is set after reconciliation Eventually(func() int64 { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, @@ -184,7 +184,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { }, timeout, interval).Should(Equal(mcpGroup.Generation)) // Verify the servers are in the group - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, @@ -199,8 +199,8 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { var ( namespace string mcpGroupName string - mcpGroup *mcpv1alpha1.MCPGroup - newServer *mcpv1alpha1.MCPServer + mcpGroup *mcpv1beta1.MCPGroup + newServer *mcpv1beta1.MCPServer ) BeforeAll(func() { @@ -216,12 +216,12 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) // Create MCPGroup first - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for new server", }, } @@ -229,12 +229,12 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { // Wait for initial reconciliation Eventually(func() bool { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, }, updatedGroup) - return err == nil && updatedGroup.Status.Phase == mcpv1alpha1.MCPGroupPhaseReady + return err == nil && updatedGroup.Status.Phase == mcpv1beta1.MCPGroupPhaseReady }, timeout, interval).Should(BeTrue()) }) @@ -256,14 +256,14 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { It("Should trigger MCPGroup reconciliation when server is created", func() { // Create new server with groupRef - newServer = &mcpv1alpha1.MCPServer{ + newServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "new-server", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, newServer)).Should(Succeed()) @@ -273,13 +273,13 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { if err := k8sClient.Get(ctx, types.NamespacedName{Name: newServer.Name, Namespace: namespace}, newServer); err != nil { return err } - newServer.Status.Phase = mcpv1alpha1.MCPServerPhaseReady + newServer.Status.Phase = mcpv1beta1.MCPServerPhaseReady return k8sClient.Status().Update(ctx, newServer) }, timeout, interval).Should(Succeed()) // Wait for MCPGroup to be updated Eventually(func() bool { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, @@ -289,11 +289,11 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { } return updatedGroup.Status.ServerCount == 1 && - updatedGroup.Status.Phase == mcpv1alpha1.MCPGroupPhaseReady + updatedGroup.Status.Phase == mcpv1beta1.MCPGroupPhaseReady }, timeout, interval).Should(BeTrue()) // Verify the server is in the group - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, @@ -307,9 +307,9 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { var ( namespace string mcpGroupName string - mcpGroup *mcpv1alpha1.MCPGroup - server1 *mcpv1alpha1.MCPServer - server2 *mcpv1alpha1.MCPServer + mcpGroup *mcpv1beta1.MCPGroup + server1 *mcpv1beta1.MCPServer + server2 *mcpv1beta1.MCPServer ) BeforeAll(func() { @@ -325,56 +325,56 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) // Create MCPServers - server1 = &mcpv1alpha1.MCPServer{ + server1 = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, server1)).Should(Succeed()) - server2 = &mcpv1alpha1.MCPServer{ + server2 = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server2", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, server2)).Should(Succeed()) // Update server statuses to Running Eventually(func() error { - freshServer := &mcpv1alpha1.MCPServer{} + freshServer := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: server1.Name, Namespace: namespace}, freshServer); err != nil { return err } - freshServer.Status.Phase = mcpv1alpha1.MCPServerPhaseReady + freshServer.Status.Phase = mcpv1beta1.MCPServerPhaseReady return k8sClient.Status().Update(ctx, freshServer) }, timeout, interval).Should(Succeed()) Eventually(func() error { - freshServer := &mcpv1alpha1.MCPServer{} + freshServer := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: server2.Name, Namespace: namespace}, freshServer); err != nil { return err } - freshServer.Status.Phase = mcpv1alpha1.MCPServerPhaseReady + freshServer.Status.Phase = mcpv1beta1.MCPServerPhaseReady return k8sClient.Status().Update(ctx, freshServer) }, timeout, interval).Should(Succeed()) // Create MCPGroup - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for server deletion", }, } @@ -382,7 +382,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { // Wait for initial reconciliation with both servers Eventually(func() bool { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, @@ -413,19 +413,19 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { // The MCPGroup should remain Ready because it can successfully list servers // in the namespace. The MCPGroup phase is based on the ability to query // servers, not on the state or count of servers. - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, }, updatedGroup)).Should(Succeed()) // The MCPGroup should be Ready with 2 servers - Expect(updatedGroup.Status.Phase).To(Equal(mcpv1alpha1.MCPGroupPhaseReady)) + Expect(updatedGroup.Status.Phase).To(Equal(mcpv1beta1.MCPGroupPhaseReady)) Expect(updatedGroup.Status.ServerCount).To(Equal(int32(2))) // Trigger a reconciliation by updating the MCPGroup spec Eventually(func() error { - freshGroup := &mcpv1alpha1.MCPGroup{} + freshGroup := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: mcpGroupName, Namespace: namespace}, freshGroup); err != nil { return err } @@ -434,8 +434,8 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { }, timeout, interval).Should(Succeed()) // After reconciliation, the MCPGroup should still be Ready - Eventually(func() mcpv1alpha1.MCPGroupPhase { - updatedGroup := &mcpv1alpha1.MCPGroup{} + Eventually(func() mcpv1beta1.MCPGroupPhase { + updatedGroup := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, @@ -443,7 +443,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { return "" } return updatedGroup.Status.Phase - }, timeout, interval).Should(Equal(mcpv1alpha1.MCPGroupPhaseReady)) + }, timeout, interval).Should(Equal(mcpv1beta1.MCPGroupPhaseReady)) }) }) @@ -451,9 +451,9 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { var ( namespace string mcpGroupName string - mcpGroup *mcpv1alpha1.MCPGroup - server1 *mcpv1alpha1.MCPServer - server2 *mcpv1alpha1.MCPServer + mcpGroup *mcpv1beta1.MCPGroup + server1 *mcpv1beta1.MCPServer + server2 *mcpv1beta1.MCPServer ) BeforeAll(func() { @@ -469,56 +469,56 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) // Create MCPServers - server1 = &mcpv1alpha1.MCPServer{ + server1 = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, server1)).Should(Succeed()) - server2 = &mcpv1alpha1.MCPServer{ + server2 = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server2", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, server2)).Should(Succeed()) // Update server statuses to Running Eventually(func() error { - freshServer := &mcpv1alpha1.MCPServer{} + freshServer := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: server1.Name, Namespace: namespace}, freshServer); err != nil { return err } - freshServer.Status.Phase = mcpv1alpha1.MCPServerPhaseReady + freshServer.Status.Phase = mcpv1beta1.MCPServerPhaseReady return k8sClient.Status().Update(ctx, freshServer) }, timeout, interval).Should(Succeed()) Eventually(func() error { - freshServer := &mcpv1alpha1.MCPServer{} + freshServer := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: server2.Name, Namespace: namespace}, freshServer); err != nil { return err } - freshServer.Status.Phase = mcpv1alpha1.MCPServerPhaseReady + freshServer.Status.Phase = mcpv1beta1.MCPServerPhaseReady return k8sClient.Status().Update(ctx, freshServer) }, timeout, interval).Should(Succeed()) // Create MCPGroup - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for state changes", }, } @@ -526,7 +526,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { // Wait for initial reconciliation - the group should find the servers Eventually(func() int32 { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, @@ -555,18 +555,18 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { It("Should remain Ready when reconciled after server status changes", func() { // Update server1 status to Failed Eventually(func() error { - freshServer := &mcpv1alpha1.MCPServer{} + freshServer := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: server1.Name, Namespace: namespace}, freshServer); err != nil { return err } - freshServer.Status.Phase = mcpv1alpha1.MCPServerPhaseFailed + freshServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed return k8sClient.Status().Update(ctx, freshServer) }, timeout, interval).Should(Succeed()) // Status changes don't trigger MCPGroup reconciliation, so we need to trigger it // by updating the MCPGroup spec (e.g., adding/updating description) Eventually(func() error { - freshGroup := &mcpv1alpha1.MCPGroup{} + freshGroup := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: mcpGroupName, Namespace: namespace}, freshGroup); err != nil { return err } @@ -576,8 +576,8 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { // The MCPGroup should still be Ready because it doesn't check individual server phases // (it only checks if servers exist). This reflects the simplified controller logic. - Eventually(func() mcpv1alpha1.MCPGroupPhase { - updatedGroup := &mcpv1alpha1.MCPGroup{} + Eventually(func() mcpv1beta1.MCPGroupPhase { + updatedGroup := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, @@ -585,10 +585,10 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { return "" } return updatedGroup.Status.Phase - }, timeout, interval).Should(Equal(mcpv1alpha1.MCPGroupPhaseReady)) + }, timeout, interval).Should(Equal(mcpv1beta1.MCPGroupPhaseReady)) // Verify both servers are still counted - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, @@ -602,9 +602,9 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { namespaceA string namespaceB string mcpGroupName string - mcpGroupA *mcpv1alpha1.MCPGroup - serverA *mcpv1alpha1.MCPServer - serverB *mcpv1alpha1.MCPServer + mcpGroupA *mcpv1beta1.MCPGroup + serverA *mcpv1beta1.MCPServer + serverB *mcpv1beta1.MCPServer ) BeforeAll(func() { @@ -628,57 +628,57 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { Expect(k8sClient.Create(ctx, nsB)).Should(Succeed()) // Create server in namespace A - serverA = &mcpv1alpha1.MCPServer{ + serverA = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-a", Namespace: namespaceA, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, }, } Expect(k8sClient.Create(ctx, serverA)).Should(Succeed()) // Create server in namespace B with same group name - serverB = &mcpv1alpha1.MCPServer{ + serverB = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-b", Namespace: namespaceB, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, // Same group name, different namespace + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, // Same group name, different namespace }, } Expect(k8sClient.Create(ctx, serverB)).Should(Succeed()) // Update server statuses Eventually(func() error { - freshServer := &mcpv1alpha1.MCPServer{} + freshServer := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: serverA.Name, Namespace: namespaceA}, freshServer); err != nil { return err } - freshServer.Status.Phase = mcpv1alpha1.MCPServerPhaseReady + freshServer.Status.Phase = mcpv1beta1.MCPServerPhaseReady return k8sClient.Status().Update(ctx, freshServer) }, timeout, interval).Should(Succeed()) Eventually(func() error { - freshServer := &mcpv1alpha1.MCPServer{} + freshServer := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: serverB.Name, Namespace: namespaceB}, freshServer); err != nil { return err } - freshServer.Status.Phase = mcpv1alpha1.MCPServerPhaseReady + freshServer.Status.Phase = mcpv1beta1.MCPServerPhaseReady return k8sClient.Status().Update(ctx, freshServer) }, timeout, interval).Should(Succeed()) // Create MCPGroup in namespace A - mcpGroupA = &mcpv1alpha1.MCPGroup{ + mcpGroupA = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespaceA, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group in namespace A", }, } @@ -710,7 +710,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { It("Should only include servers from the same namespace", func() { // Wait for reconciliation Eventually(func() bool { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespaceA, @@ -719,7 +719,7 @@ var _ = Describe("MCPGroup Controller Integration Tests", func() { }, timeout, interval).Should(BeTrue()) // Verify only server-a is in the group - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespaceA, diff --git a/cmd/thv-operator/test-integration/mcp-group/suite_test.go b/cmd/thv-operator/test-integration/mcp-group/suite_test.go index 69facd2053..192e7f0cbd 100644 --- a/cmd/thv-operator/test-integration/mcp-group/suite_test.go +++ b/cmd/thv-operator/test-integration/mcp-group/suite_test.go @@ -24,7 +24,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/controllers" ) @@ -71,7 +71,7 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) - err = mcpv1alpha1.AddToScheme(scheme.Scheme) + err = mcpv1beta1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) // Add other schemes that the controllers use @@ -103,10 +103,10 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPServer.Spec.GroupRef err = k8sManager.GetFieldIndexer().IndexField( context.Background(), - &mcpv1alpha1.MCPServer{}, + &mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + mcpServer := obj.(*mcpv1beta1.MCPServer) name := mcpServer.Spec.GroupRef.GetName() if name == "" { return nil @@ -119,10 +119,10 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPRemoteProxy.Spec.GroupRef err = k8sManager.GetFieldIndexer().IndexField( context.Background(), - &mcpv1alpha1.MCPRemoteProxy{}, + &mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) name := mcpRemoteProxy.Spec.GroupRef.GetName() if name == "" { return nil @@ -135,10 +135,10 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPServerEntry.Spec.GroupRef err = k8sManager.GetFieldIndexer().IndexField( context.Background(), - &mcpv1alpha1.MCPServerEntry{}, + &mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) name := mcpServerEntry.Spec.GroupRef.GetName() if name == "" { return nil diff --git a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_controller_integration_test.go b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_controller_integration_test.go index e9c1840d6e..6b1db5415a 100644 --- a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_controller_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_controller_integration_test.go @@ -11,7 +11,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) const ( @@ -21,14 +21,14 @@ const ( var _ = Describe("MCPOIDCConfig Controller", func() { It("should set Ready condition and config hash on creation", func() { - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-oidc-creation", Namespace: "default", }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -39,7 +39,7 @@ var _ = Describe("MCPOIDCConfig Controller", func() { // Verify config hash is set Eventually(func() bool { - fetched := &mcpv1alpha1.MCPOIDCConfig{} + fetched := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: oidcConfig.Name, Namespace: oidcConfig.Namespace, @@ -52,7 +52,7 @@ var _ = Describe("MCPOIDCConfig Controller", func() { // Verify Ready condition is set to True Eventually(func() bool { - fetched := &mcpv1alpha1.MCPOIDCConfig{} + fetched := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: oidcConfig.Name, Namespace: oidcConfig.Namespace, @@ -61,7 +61,7 @@ var _ = Describe("MCPOIDCConfig Controller", func() { return false } for _, cond := range fetched.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeOIDCConfigValid && cond.Status == metav1.ConditionTrue { + if cond.Type == mcpv1beta1.ConditionTypeOIDCConfigValid && cond.Status == metav1.ConditionTrue { return true } } @@ -70,14 +70,14 @@ var _ = Describe("MCPOIDCConfig Controller", func() { }) It("should update config hash when spec changes", func() { - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-oidc-hash-change", Namespace: "default", }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "original-client", }, @@ -89,7 +89,7 @@ var _ = Describe("MCPOIDCConfig Controller", func() { // Wait for initial hash var firstHash string Eventually(func() bool { - fetched := &mcpv1alpha1.MCPOIDCConfig{} + fetched := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: oidcConfig.Name, Namespace: oidcConfig.Namespace, @@ -102,7 +102,7 @@ var _ = Describe("MCPOIDCConfig Controller", func() { }, timeout, interval).Should(BeTrue()) // Update the spec - fetched := &mcpv1alpha1.MCPOIDCConfig{} + fetched := &mcpv1beta1.MCPOIDCConfig{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: oidcConfig.Name, Namespace: oidcConfig.Namespace, @@ -113,7 +113,7 @@ var _ = Describe("MCPOIDCConfig Controller", func() { // Verify hash changed Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: oidcConfig.Name, Namespace: oidcConfig.Namespace, @@ -126,14 +126,14 @@ var _ = Describe("MCPOIDCConfig Controller", func() { }) It("should allow deletion by removing finalizer", func() { - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-oidc-deletion", Namespace: "default", }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeKubernetesServiceAccount, - KubernetesServiceAccount: &mcpv1alpha1.KubernetesServiceAccountOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount, + KubernetesServiceAccount: &mcpv1beta1.KubernetesServiceAccountOIDCConfig{ Issuer: "https://kubernetes.default.svc", }, }, @@ -143,7 +143,7 @@ var _ = Describe("MCPOIDCConfig Controller", func() { // Wait for finalizer to be added Eventually(func() bool { - fetched := &mcpv1alpha1.MCPOIDCConfig{} + fetched := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: oidcConfig.Name, Namespace: oidcConfig.Namespace, @@ -164,7 +164,7 @@ var _ = Describe("MCPOIDCConfig Controller", func() { // Verify it's actually deleted (finalizer removed, object gone) Eventually(func() bool { - fetched := &mcpv1alpha1.MCPOIDCConfig{} + fetched := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: oidcConfig.Name, Namespace: oidcConfig.Namespace, diff --git a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_mcpremoteproxy_integration_test.go b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_mcpremoteproxy_integration_test.go index 18ec9ca781..97c98a309a 100644 --- a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_mcpremoteproxy_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_mcpremoteproxy_integration_test.go @@ -12,7 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) const ( @@ -22,13 +22,13 @@ const ( // newTestMCPRemoteProxy creates an MCPRemoteProxy with an optional OIDCConfigRef pointing // to a shared MCPOIDCConfig (when oidcConfigRefName is non-empty). -func newTestMCPRemoteProxy(name, namespace string, oidcConfigRefName string) *mcpv1alpha1.MCPRemoteProxy { - proxy := &mcpv1alpha1.MCPRemoteProxy{ +func newTestMCPRemoteProxy(name, namespace string, oidcConfigRefName string) *mcpv1beta1.MCPRemoteProxy { + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: testRemoteURL, ProxyPort: 8080, Transport: "streamable-http", @@ -36,7 +36,7 @@ func newTestMCPRemoteProxy(name, namespace string, oidcConfigRefName string) *mc } if oidcConfigRefName != "" { - proxy.Spec.OIDCConfigRef = &mcpv1alpha1.MCPOIDCConfigReference{ + proxy.Spec.OIDCConfigRef = &mcpv1beta1.MCPOIDCConfigReference{ Name: oidcConfigRefName, Audience: "test-proxy-audience", Scopes: []string{"openid"}, @@ -52,8 +52,8 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te namespace string configName string proxyName string - oidcConfig *mcpv1alpha1.MCPOIDCConfig - proxy *mcpv1alpha1.MCPRemoteProxy + oidcConfig *mcpv1beta1.MCPOIDCConfig + proxy *mcpv1beta1.MCPRemoteProxy ns *corev1.Namespace ) @@ -70,14 +70,14 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te proxyName = testRemoteProxyName // Create MCPOIDCConfig - oidcConfig = &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig = &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -87,7 +87,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // Wait for Ready condition and ConfigHash to be set Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -99,7 +99,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te return false } for _, cond := range updated.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeOIDCConfigValid && cond.Status == metav1.ConditionTrue { + if cond.Type == mcpv1beta1.ConditionTypeOIDCConfigValid && cond.Status == metav1.ConditionTrue { return true } } @@ -119,7 +119,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te It("should set OIDCConfigRefValidated condition to True", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPRemoteProxy{} + updated := &mcpv1beta1.MCPRemoteProxy{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: proxyName, Namespace: namespace, @@ -127,18 +127,18 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te if err != nil { return false } - condition := meta.FindStatusCondition(updated.Status.Conditions, mcpv1alpha1.ConditionOIDCConfigRefValidated) + condition := meta.FindStatusCondition(updated.Status.Conditions, mcpv1beta1.ConditionOIDCConfigRefValidated) if condition == nil { return false } return condition.Status == metav1.ConditionTrue && - condition.Reason == mcpv1alpha1.ConditionReasonOIDCConfigRefValid + condition.Reason == mcpv1beta1.ConditionReasonOIDCConfigRefValid }, timeout, interval).Should(BeTrue()) }) It("should set OIDCConfigHash in MCPRemoteProxy status", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPRemoteProxy{} + updated := &mcpv1beta1.MCPRemoteProxy{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: proxyName, Namespace: namespace, @@ -152,7 +152,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te It("should track MCPRemoteProxy in MCPOIDCConfig ReferencingWorkloads", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -160,7 +160,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te if err != nil { return false } - expectedRef := mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPRemoteProxy, Name: proxyName} + expectedRef := mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindMCPRemoteProxy, Name: proxyName} for _, ref := range updated.Status.ReferencingWorkloads { if ref == expectedRef { return true @@ -175,7 +175,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te var ( namespace string proxyName string - proxy *mcpv1alpha1.MCPRemoteProxy + proxy *mcpv1beta1.MCPRemoteProxy ns *corev1.Namespace ) @@ -202,7 +202,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te It("should enter Failed phase", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPRemoteProxy{} + updated := &mcpv1beta1.MCPRemoteProxy{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: proxyName, Namespace: namespace, @@ -210,13 +210,13 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te if err != nil { return false } - return updated.Status.Phase == mcpv1alpha1.MCPRemoteProxyPhaseFailed + return updated.Status.Phase == mcpv1beta1.MCPRemoteProxyPhaseFailed }, timeout, interval).Should(BeTrue()) }) It("should set OIDCConfigRefValidated condition to False with NotFound reason", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPRemoteProxy{} + updated := &mcpv1beta1.MCPRemoteProxy{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: proxyName, Namespace: namespace, @@ -224,12 +224,12 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te if err != nil { return false } - condition := meta.FindStatusCondition(updated.Status.Conditions, mcpv1alpha1.ConditionOIDCConfigRefValidated) + condition := meta.FindStatusCondition(updated.Status.Conditions, mcpv1beta1.ConditionOIDCConfigRefValidated) if condition == nil { return false } return condition.Status == metav1.ConditionFalse && - condition.Reason == mcpv1alpha1.ConditionReasonOIDCConfigRefNotFound + condition.Reason == mcpv1beta1.ConditionReasonOIDCConfigRefNotFound }, timeout, interval).Should(BeTrue()) }) }) @@ -239,8 +239,8 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te namespace string configName string proxyName string - oidcConfig *mcpv1alpha1.MCPOIDCConfig - proxy *mcpv1alpha1.MCPRemoteProxy + oidcConfig *mcpv1beta1.MCPOIDCConfig + proxy *mcpv1beta1.MCPRemoteProxy ns *corev1.Namespace originalHash string originalCfgHash string @@ -259,14 +259,14 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te proxyName = testRemoteProxyName // Create MCPOIDCConfig - oidcConfig = &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig = &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -276,7 +276,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // Wait for Ready condition and ConfigHash to be set Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -289,7 +289,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te } originalCfgHash = updated.Status.ConfigHash for _, cond := range updated.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeOIDCConfigValid && cond.Status == metav1.ConditionTrue { + if cond.Type == mcpv1beta1.ConditionTypeOIDCConfigValid && cond.Status == metav1.ConditionTrue { return true } } @@ -302,7 +302,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // Wait for the proxy to pick up the original hash Eventually(func() bool { - updated := &mcpv1alpha1.MCPRemoteProxy{} + updated := &mcpv1beta1.MCPRemoteProxy{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: proxyName, Namespace: namespace, @@ -326,7 +326,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te It("should update MCPRemoteProxy OIDCConfigHash when MCPOIDCConfig spec changes", func() { // Update the MCPOIDCConfig spec to trigger a hash change - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -337,7 +337,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // Wait for MCPOIDCConfig ConfigHash to change Eventually(func() bool { - cfg := &mcpv1alpha1.MCPOIDCConfig{} + cfg := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -350,7 +350,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // Eventually the MCPRemoteProxy should pick up the new hash Eventually(func() bool { - proxyUpdated := &mcpv1alpha1.MCPRemoteProxy{} + proxyUpdated := &mcpv1beta1.MCPRemoteProxy{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: proxyName, Namespace: namespace, @@ -369,8 +369,8 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te namespace string configName string proxyName string - oidcConfig *mcpv1alpha1.MCPOIDCConfig - proxy *mcpv1alpha1.MCPRemoteProxy + oidcConfig *mcpv1beta1.MCPOIDCConfig + proxy *mcpv1beta1.MCPRemoteProxy ns *corev1.Namespace ) @@ -387,14 +387,14 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te proxyName = testRemoteProxyName // Create MCPOIDCConfig - oidcConfig = &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig = &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -404,7 +404,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // Wait for ready Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -421,7 +421,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // Wait for ReferencingWorkloads to be populated Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -429,7 +429,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te if err != nil { return false } - expectedRef := mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPRemoteProxy, Name: proxyName} + expectedRef := mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindMCPRemoteProxy, Name: proxyName} for _, ref := range updated.Status.ReferencingWorkloads { if ref == expectedRef { return true @@ -449,7 +449,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // Wait for MCPOIDCConfig to be fully removed Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -463,7 +463,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te It("should not be deleted while referenced by MCPRemoteProxy", func() { // The object should still exist because the finalizer blocks deletion Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -481,7 +481,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // The MCPOIDCConfig should eventually be fully deleted Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -496,8 +496,8 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te namespace string configName string proxyName string - oidcConfig *mcpv1alpha1.MCPOIDCConfig - proxy *mcpv1alpha1.MCPRemoteProxy + oidcConfig *mcpv1beta1.MCPOIDCConfig + proxy *mcpv1beta1.MCPRemoteProxy ns *corev1.Namespace ) @@ -514,14 +514,14 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te proxyName = testRemoteProxyName // Create MCPOIDCConfig - oidcConfig = &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig = &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -531,7 +531,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // Wait for ready Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -543,7 +543,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te return false } for _, cond := range updated.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeOIDCConfigValid && cond.Status == metav1.ConditionTrue { + if cond.Type == mcpv1beta1.ConditionTypeOIDCConfigValid && cond.Status == metav1.ConditionTrue { return true } } @@ -556,7 +556,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // Wait for ReferencingWorkloads to contain the proxy Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -564,7 +564,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te if err != nil { return false } - expectedRef := mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPRemoteProxy, Name: proxyName} + expectedRef := mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindMCPRemoteProxy, Name: proxyName} for _, ref := range updated.Status.ReferencingWorkloads { if ref == expectedRef { return true @@ -575,7 +575,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // Wait for the proxy OIDCConfigHash to be populated Eventually(func() bool { - updated := &mcpv1alpha1.MCPRemoteProxy{} + updated := &mcpv1beta1.MCPRemoteProxy{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: proxyName, Namespace: namespace, @@ -595,7 +595,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te It("should clean up ReferencingWorkloads and clear OIDCConfigHash after ref removal", func() { // Remove the OIDCConfigRef from the MCPRemoteProxy - updated := &mcpv1alpha1.MCPRemoteProxy{} + updated := &mcpv1beta1.MCPRemoteProxy{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: proxyName, Namespace: namespace, @@ -607,7 +607,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // MCPOIDCConfig should no longer list MCPRemoteProxy in ReferencingWorkloads Eventually(func() bool { - cfg := &mcpv1alpha1.MCPOIDCConfig{} + cfg := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -615,7 +615,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te if err != nil { return false } - expectedRef := mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPRemoteProxy, Name: proxyName} + expectedRef := mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindMCPRemoteProxy, Name: proxyName} for _, ref := range cfg.Status.ReferencingWorkloads { if ref == expectedRef { return false @@ -626,7 +626,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // MCPRemoteProxy OIDCConfigHash should be cleared and condition removed Eventually(func() bool { - proxyUpdated := &mcpv1alpha1.MCPRemoteProxy{} + proxyUpdated := &mcpv1beta1.MCPRemoteProxy{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: proxyName, Namespace: namespace, @@ -638,7 +638,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te return false } // Verify the OIDCConfigRefValidated condition was removed - cond := meta.FindStatusCondition(proxyUpdated.Status.Conditions, mcpv1alpha1.ConditionOIDCConfigRefValidated) + cond := meta.FindStatusCondition(proxyUpdated.Status.Conditions, mcpv1beta1.ConditionOIDCConfigRefValidated) return cond == nil }, timeout, interval).Should(BeTrue()) }) @@ -649,8 +649,8 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te namespace string configName string proxyName string - oidcConfig *mcpv1alpha1.MCPOIDCConfig - proxy *mcpv1alpha1.MCPRemoteProxy + oidcConfig *mcpv1beta1.MCPOIDCConfig + proxy *mcpv1beta1.MCPRemoteProxy ns *corev1.Namespace ) @@ -667,14 +667,14 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te proxyName = testRemoteProxyName // Create MCPOIDCConfig - oidcConfig = &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig = &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -684,7 +684,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // Wait for ready Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -701,7 +701,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // Wait for ReferencingWorkloads to contain the proxy Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -709,7 +709,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te if err != nil { return false } - expectedRef := mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPRemoteProxy, Name: proxyName} + expectedRef := mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindMCPRemoteProxy, Name: proxyName} for _, ref := range updated.Status.ReferencingWorkloads { if ref == expectedRef { return true @@ -730,7 +730,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te // Eventually the referencing workloads list should not contain the proxy Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -738,7 +738,7 @@ var _ = Describe("MCPOIDCConfig and MCPRemoteProxy Cross-Resource Integration Te if err != nil { return false } - expectedRef := mcpv1alpha1.WorkloadReference{Kind: mcpv1alpha1.WorkloadKindMCPRemoteProxy, Name: proxyName} + expectedRef := mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindMCPRemoteProxy, Name: proxyName} for _, ref := range updated.Status.ReferencingWorkloads { if ref == expectedRef { return false diff --git a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_mcpserver_integration_test.go b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_mcpserver_integration_test.go index d1a199f5ff..b61cfa66ac 100644 --- a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_mcpserver_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_mcpserver_integration_test.go @@ -12,7 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) const ( @@ -27,8 +27,8 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", namespace string configName string serverName string - oidcConfig *mcpv1alpha1.MCPOIDCConfig - mcpServer *mcpv1alpha1.MCPServer + oidcConfig *mcpv1beta1.MCPOIDCConfig + mcpServer *mcpv1beta1.MCPServer ns *corev1.Namespace ) @@ -46,14 +46,14 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", serverName = testServerName // Create MCPOIDCConfig - oidcConfig = &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig = &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -63,7 +63,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", // Wait for Ready condition and ConfigHash to be set Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -75,7 +75,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", return false } for _, cond := range updated.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeOIDCConfigValid && cond.Status == metav1.ConditionTrue { + if cond.Type == mcpv1beta1.ConditionTypeOIDCConfigValid && cond.Status == metav1.ConditionTrue { return true } } @@ -83,14 +83,14 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", }, timeout, interval).Should(BeTrue()) // Create MCPServer with OIDCConfigRef - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: serverName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testServerImage, - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: configName, Audience: "test-audience", Scopes: []string{"openid"}, @@ -109,7 +109,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", It("should set OIDCConfigRefValidated condition to True", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPServer{} + updated := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: serverName, Namespace: namespace, @@ -117,7 +117,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", if err != nil { return false } - condition := meta.FindStatusCondition(updated.Status.Conditions, mcpv1alpha1.ConditionOIDCConfigRefValidated) + condition := meta.FindStatusCondition(updated.Status.Conditions, mcpv1beta1.ConditionOIDCConfigRefValidated) if condition == nil { return false } @@ -127,7 +127,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", It("should set OIDCConfigHash in MCPServer status", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPServer{} + updated := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: serverName, Namespace: namespace, @@ -141,7 +141,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", It("should track MCPServer in MCPOIDCConfig ReferencingWorkloads", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -149,7 +149,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", if err != nil { return false } - expectedRef := mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: serverName} + expectedRef := mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: serverName} for _, ref := range updated.Status.ReferencingWorkloads { if ref == expectedRef { return true @@ -165,8 +165,8 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", namespace string configName string serverName string - oidcConfig *mcpv1alpha1.MCPOIDCConfig - mcpServer *mcpv1alpha1.MCPServer + oidcConfig *mcpv1beta1.MCPOIDCConfig + mcpServer *mcpv1beta1.MCPServer ns *corev1.Namespace ) @@ -184,14 +184,14 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", serverName = testServerName // Create MCPOIDCConfig - oidcConfig = &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig = &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -201,7 +201,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", // Wait for ready Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -213,14 +213,14 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", }, timeout, interval).Should(BeTrue()) // Create MCPServer with OIDCConfigRef - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: serverName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testServerImage, - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: configName, Audience: "test-audience", Scopes: []string{"openid"}, @@ -231,7 +231,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", // Wait for ReferencingWorkloads to contain the server Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -239,7 +239,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", if err != nil { return false } - expectedRef := mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: serverName} + expectedRef := mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: serverName} for _, ref := range updated.Status.ReferencingWorkloads { if ref == expectedRef { return true @@ -260,7 +260,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", // Eventually the referencing workloads list should be empty Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -278,8 +278,8 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", namespace string configName string serverName string - oidcConfig *mcpv1alpha1.MCPOIDCConfig - mcpServer *mcpv1alpha1.MCPServer + oidcConfig *mcpv1beta1.MCPOIDCConfig + mcpServer *mcpv1beta1.MCPServer ns *corev1.Namespace ) @@ -297,14 +297,14 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", serverName = testServerName // Create MCPOIDCConfig - oidcConfig = &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig = &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -314,7 +314,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", // Wait for ready Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -326,14 +326,14 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", }, timeout, interval).Should(BeTrue()) // Create MCPServer with OIDCConfigRef - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: serverName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testServerImage, - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: configName, Audience: "test-audience", Scopes: []string{"openid"}, @@ -344,7 +344,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", // Wait for ReferencingWorkloads to be populated Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -352,7 +352,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", if err != nil { return false } - expectedRef := mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: serverName} + expectedRef := mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: serverName} for _, ref := range updated.Status.ReferencingWorkloads { if ref == expectedRef { return true @@ -372,7 +372,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", // Wait for MCPOIDCConfig to be fully removed Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -386,7 +386,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", It("should not be deleted while referenced", func() { // The object should still exist because the finalizer blocks deletion Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -404,7 +404,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", // The MCPOIDCConfig should eventually be fully deleted Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -418,7 +418,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", var ( namespace string serverName string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer ns *corev1.Namespace ) @@ -435,14 +435,14 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", serverName = testServerName // Create MCPServer with OIDCConfigRef pointing to a non-existent config - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: serverName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testServerImage, - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: "does-not-exist", Audience: "test-audience", Scopes: []string{"openid"}, @@ -459,7 +459,7 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", It("should set OIDCConfigRefValidated condition to False with NotFound reason", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPServer{} + updated := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: serverName, Namespace: namespace, @@ -467,12 +467,12 @@ var _ = Describe("MCPOIDCConfig and MCPServer Cross-Resource Integration Tests", if err != nil { return false } - condition := meta.FindStatusCondition(updated.Status.Conditions, mcpv1alpha1.ConditionOIDCConfigRefValidated) + condition := meta.FindStatusCondition(updated.Status.Conditions, mcpv1beta1.ConditionOIDCConfigRefValidated) if condition == nil { return false } return condition.Status == metav1.ConditionFalse && - condition.Reason == mcpv1alpha1.ConditionReasonOIDCConfigRefNotFound + condition.Reason == mcpv1beta1.ConditionReasonOIDCConfigRefNotFound }, timeout, interval).Should(BeTrue()) }) }) diff --git a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go index cda92baa98..f8d18f043e 100644 --- a/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-oidc-config/mcpoidcconfig_virtualmcpserver_integration_test.go @@ -13,7 +13,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/yaml" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -29,9 +29,9 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration configName string vmcpName string groupName string - oidcConfig *mcpv1alpha1.MCPOIDCConfig - vmcpServer *mcpv1alpha1.VirtualMCPServer - mcpGroup *mcpv1alpha1.MCPGroup + oidcConfig *mcpv1beta1.MCPOIDCConfig + vmcpServer *mcpv1beta1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup ns *corev1.Namespace ) @@ -50,7 +50,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration groupName = testVMCPGroupName // Create MCPGroup (required by VirtualMCPServer) - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: groupName, Namespace: namespace, @@ -59,14 +59,14 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) // Create MCPOIDCConfig - oidcConfig = &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig = &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -76,7 +76,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration // Wait for Valid condition and ConfigHash to be set Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -88,7 +88,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration return false } for _, cond := range updated.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeOIDCConfigValid && cond.Status == metav1.ConditionTrue { + if cond.Type == mcpv1beta1.ConditionTypeOIDCConfigValid && cond.Status == metav1.ConditionTrue { return true } } @@ -96,17 +96,17 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration }, timeout, interval).Should(BeTrue()) // Create VirtualMCPServer with OIDCConfigRef - vmcpServer = &mcpv1alpha1.VirtualMCPServer{ + vmcpServer = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, Config: vmcpconfig.Config{Group: groupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: configName, Audience: "test-vmcp-audience", Scopes: []string{"openid"}, @@ -128,7 +128,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration It("should set OIDCConfigRefValidated condition to True", func() { Eventually(func() bool { - updated := &mcpv1alpha1.VirtualMCPServer{} + updated := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -136,7 +136,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration if err != nil { return false } - condition := meta.FindStatusCondition(updated.Status.Conditions, mcpv1alpha1.ConditionOIDCConfigRefValidated) + condition := meta.FindStatusCondition(updated.Status.Conditions, mcpv1beta1.ConditionOIDCConfigRefValidated) if condition == nil { return false } @@ -146,7 +146,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration It("should set OIDCConfigHash in VirtualMCPServer status", func() { Eventually(func() bool { - updated := &mcpv1alpha1.VirtualMCPServer{} + updated := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -190,7 +190,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration It("should track VirtualMCPServer in MCPOIDCConfig ReferencingWorkloads", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -198,7 +198,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration if err != nil { return false } - expectedRef := mcpv1alpha1.WorkloadReference{Kind: "VirtualMCPServer", Name: vmcpName} + expectedRef := mcpv1beta1.WorkloadReference{Kind: "VirtualMCPServer", Name: vmcpName} for _, ref := range updated.Status.ReferencingWorkloads { if ref == expectedRef { return true @@ -215,9 +215,9 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration configName string vmcpName string groupName string - oidcConfig *mcpv1alpha1.MCPOIDCConfig - vmcpServer *mcpv1alpha1.VirtualMCPServer - mcpGroup *mcpv1alpha1.MCPGroup + oidcConfig *mcpv1beta1.MCPOIDCConfig + vmcpServer *mcpv1beta1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup ns *corev1.Namespace ) @@ -236,7 +236,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration groupName = testVMCPGroupName // Create MCPGroup (required by VirtualMCPServer) - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: groupName, Namespace: namespace, @@ -245,14 +245,14 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) // Create MCPOIDCConfig - oidcConfig = &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig = &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -262,7 +262,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration // Wait for ready Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -274,17 +274,17 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration }, timeout, interval).Should(BeTrue()) // Create VirtualMCPServer with OIDCConfigRef - vmcpServer = &mcpv1alpha1.VirtualMCPServer{ + vmcpServer = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, Config: vmcpconfig.Config{Group: groupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: configName, Audience: "test-vmcp-audience", Scopes: []string{"openid"}, @@ -296,7 +296,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration // Wait for ReferencingWorkloads to contain the VirtualMCPServer Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -304,7 +304,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration if err != nil { return false } - expectedRef := mcpv1alpha1.WorkloadReference{Kind: "VirtualMCPServer", Name: vmcpName} + expectedRef := mcpv1beta1.WorkloadReference{Kind: "VirtualMCPServer", Name: vmcpName} for _, ref := range updated.Status.ReferencingWorkloads { if ref == expectedRef { return true @@ -326,7 +326,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration // Eventually the referencing workloads list should not contain the vmcp entry Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -334,7 +334,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration if err != nil { return false } - expectedRef := mcpv1alpha1.WorkloadReference{Kind: "VirtualMCPServer", Name: vmcpName} + expectedRef := mcpv1beta1.WorkloadReference{Kind: "VirtualMCPServer", Name: vmcpName} for _, ref := range updated.Status.ReferencingWorkloads { if ref == expectedRef { return false @@ -351,9 +351,9 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration configName string vmcpName string groupName string - oidcConfig *mcpv1alpha1.MCPOIDCConfig - vmcpServer *mcpv1alpha1.VirtualMCPServer - mcpGroup *mcpv1alpha1.MCPGroup + oidcConfig *mcpv1beta1.MCPOIDCConfig + vmcpServer *mcpv1beta1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup ns *corev1.Namespace ) @@ -372,7 +372,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration groupName = testVMCPGroupName // Create MCPGroup (required by VirtualMCPServer) - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: groupName, Namespace: namespace, @@ -381,14 +381,14 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) // Create MCPOIDCConfig - oidcConfig = &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig = &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -398,7 +398,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration // Wait for ready Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -410,17 +410,17 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration }, timeout, interval).Should(BeTrue()) // Create VirtualMCPServer with OIDCConfigRef - vmcpServer = &mcpv1alpha1.VirtualMCPServer{ + vmcpServer = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, Config: vmcpconfig.Config{Group: groupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: configName, Audience: "test-vmcp-audience", Scopes: []string{"openid"}, @@ -432,7 +432,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration // Wait for ReferencingWorkloads to be populated Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -440,7 +440,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration if err != nil { return false } - expectedRef := mcpv1alpha1.WorkloadReference{Kind: "VirtualMCPServer", Name: vmcpName} + expectedRef := mcpv1beta1.WorkloadReference{Kind: "VirtualMCPServer", Name: vmcpName} for _, ref := range updated.Status.ReferencingWorkloads { if ref == expectedRef { return true @@ -460,7 +460,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration // Wait for MCPOIDCConfig to be fully removed Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -475,7 +475,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration It("should not be deleted while referenced by VirtualMCPServer", func() { // The object should still exist because the finalizer blocks deletion Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -493,7 +493,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration // The MCPOIDCConfig should eventually be fully deleted Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -508,8 +508,8 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration namespace string vmcpName string groupName string - vmcpServer *mcpv1alpha1.VirtualMCPServer - mcpGroup *mcpv1alpha1.MCPGroup + vmcpServer *mcpv1beta1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup ns *corev1.Namespace ) @@ -527,7 +527,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration groupName = testVMCPGroupName // Create MCPGroup (required by VirtualMCPServer) - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: groupName, Namespace: namespace, @@ -536,17 +536,17 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) // Create VirtualMCPServer with OIDCConfigRef pointing to a non-existent config - vmcpServer = &mcpv1alpha1.VirtualMCPServer{ + vmcpServer = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, Config: vmcpconfig.Config{Group: groupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: "does-not-exist", Audience: "test-vmcp-audience", Scopes: []string{"openid"}, @@ -565,7 +565,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration It("should set OIDCConfigRefValidated condition to False with NotFound reason", func() { Eventually(func() bool { - updated := &mcpv1alpha1.VirtualMCPServer{} + updated := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -573,12 +573,12 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration if err != nil { return false } - condition := meta.FindStatusCondition(updated.Status.Conditions, mcpv1alpha1.ConditionOIDCConfigRefValidated) + condition := meta.FindStatusCondition(updated.Status.Conditions, mcpv1beta1.ConditionOIDCConfigRefValidated) if condition == nil { return false } return condition.Status == metav1.ConditionFalse && - condition.Reason == mcpv1alpha1.ConditionReasonOIDCConfigRefNotFound + condition.Reason == mcpv1beta1.ConditionReasonOIDCConfigRefNotFound }, timeout, interval).Should(BeTrue()) }) }) @@ -590,10 +590,10 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration serverName string vmcpName string groupName string - oidcConfig *mcpv1alpha1.MCPOIDCConfig - mcpServer *mcpv1alpha1.MCPServer - vmcpServer *mcpv1alpha1.VirtualMCPServer - mcpGroup *mcpv1alpha1.MCPGroup + oidcConfig *mcpv1beta1.MCPOIDCConfig + mcpServer *mcpv1beta1.MCPServer + vmcpServer *mcpv1beta1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup ns *corev1.Namespace ) @@ -613,7 +613,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration groupName = testVMCPGroupName // Create MCPGroup (required by VirtualMCPServer) - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: groupName, Namespace: namespace, @@ -622,14 +622,14 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) // Create MCPOIDCConfig - oidcConfig = &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig = &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "test-client", }, @@ -639,7 +639,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration // Wait for Valid condition and ConfigHash to be set Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -651,7 +651,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration return false } for _, cond := range updated.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeOIDCConfigValid && cond.Status == metav1.ConditionTrue { + if cond.Type == mcpv1beta1.ConditionTypeOIDCConfigValid && cond.Status == metav1.ConditionTrue { return true } } @@ -659,14 +659,14 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration }, timeout, interval).Should(BeTrue()) // Create MCPServer with OIDCConfigRef - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: serverName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testServerImage, - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: configName, Audience: "test-audience", Scopes: []string{"openid"}, @@ -676,17 +676,17 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration Expect(k8sClient.Create(ctx, mcpServer)).Should(Succeed()) // Create VirtualMCPServer with OIDCConfigRef - vmcpServer = &mcpv1alpha1.VirtualMCPServer{ + vmcpServer = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, Config: vmcpconfig.Config{Group: groupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: configName, Audience: "test-vmcp-audience", Scopes: []string{"openid"}, @@ -707,7 +707,7 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration It("should track both workloads in ReferencingWorkloads", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -715,8 +715,8 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration if err != nil { return false } - mcpServerRef := mcpv1alpha1.WorkloadReference{Kind: "MCPServer", Name: serverName} - vmcpServerRef := mcpv1alpha1.WorkloadReference{Kind: "VirtualMCPServer", Name: vmcpName} + mcpServerRef := mcpv1beta1.WorkloadReference{Kind: "MCPServer", Name: serverName} + vmcpServerRef := mcpv1beta1.WorkloadReference{Kind: "VirtualMCPServer", Name: vmcpName} hasMCPServer := false hasVMCPServer := false for _, ref := range updated.Status.ReferencingWorkloads { diff --git a/cmd/thv-operator/test-integration/mcp-oidc-config/suite_test.go b/cmd/thv-operator/test-integration/mcp-oidc-config/suite_test.go index a849394049..700a54691c 100644 --- a/cmd/thv-operator/test-integration/mcp-oidc-config/suite_test.go +++ b/cmd/thv-operator/test-integration/mcp-oidc-config/suite_test.go @@ -25,7 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/controllers" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -67,7 +67,7 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) - err = mcpv1alpha1.AddToScheme(scheme.Scheme) + err = mcpv1beta1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) // Add other schemes that the controllers use @@ -102,8 +102,8 @@ var _ = BeforeSuite(func() { Expect(err).ToNot(HaveOccurred()) // Set up field indexing for MCPServer.Spec.GroupRef (required by VirtualMCPServer controller) - if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) name := mcpServer.Spec.GroupRef.GetName() if name == "" { return nil @@ -114,8 +114,8 @@ var _ = BeforeSuite(func() { } // Set up field indexing for MCPRemoteProxy.Spec.GroupRef (required by VirtualMCPServer controller) - if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) name := mcpRemoteProxy.Spec.GroupRef.GetName() if name == "" { return nil @@ -128,10 +128,10 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPServerEntry.Spec.GroupRef err = k8sManager.GetFieldIndexer().IndexField( context.Background(), - &mcpv1alpha1.MCPServerEntry{}, + &mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) name := mcpServerEntry.Spec.GroupRef.GetName() if name == "" { return nil diff --git a/cmd/thv-operator/test-integration/mcp-registry/registry_helpers.go b/cmd/thv-operator/test-integration/mcp-registry/registry_helpers.go index 5ddcbc10ac..5dc75e5134 100644 --- a/cmd/thv-operator/test-integration/mcp-registry/registry_helpers.go +++ b/cmd/thv-operator/test-integration/mcp-registry/registry_helpers.go @@ -19,7 +19,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // MCPRegistryTestHelper provides specialized utilities for MCPRegistry testing @@ -179,10 +179,10 @@ func (rb *RegistryBuilder) WithTagExcludeFilter(tags []string) *RegistryBuilder } // Build returns the constructed MCPRegistry with configYAML generated from the builder config. -func (rb *RegistryBuilder) Build() *mcpv1alpha1.MCPRegistry { +func (rb *RegistryBuilder) Build() *mcpv1beta1.MCPRegistry { configYAML := rb.buildConfigYAML() - spec := mcpv1alpha1.MCPRegistrySpec{ + spec := mcpv1beta1.MCPRegistrySpec{ ConfigYAML: configYAML, } @@ -218,7 +218,7 @@ func (rb *RegistryBuilder) Build() *mcpv1alpha1.MCPRegistry { spec.VolumeMounts = []apiextensionsv1.JSON{{Raw: mountJSON}} } - return &mcpv1alpha1.MCPRegistry{ + return &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: rb.name, Namespace: rb.namespace, @@ -230,7 +230,7 @@ func (rb *RegistryBuilder) Build() *mcpv1alpha1.MCPRegistry { } // Create builds and creates the MCPRegistry in the cluster -func (rb *RegistryBuilder) Create(h *MCPRegistryTestHelper) *mcpv1alpha1.MCPRegistry { +func (rb *RegistryBuilder) Create(h *MCPRegistryTestHelper) *mcpv1beta1.MCPRegistry { registry := rb.Build() err := h.Client.Create(h.Context, registry) gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to create MCPRegistry") @@ -325,7 +325,7 @@ func writeStringList(b *strings.Builder, label string, items []string) { } // CreateBasicConfigMapRegistry creates a simple MCPRegistry with ConfigMap source -func (h *MCPRegistryTestHelper) CreateBasicConfigMapRegistry(name, configMapName string) *mcpv1alpha1.MCPRegistry { +func (h *MCPRegistryTestHelper) CreateBasicConfigMapRegistry(name, configMapName string) *mcpv1beta1.MCPRegistry { return h.NewRegistryBuilder(name). WithConfigMapSource(configMapName, "registry.json"). WithSyncPolicy("1h"). @@ -333,14 +333,14 @@ func (h *MCPRegistryTestHelper) CreateBasicConfigMapRegistry(name, configMapName } // CreateManualSyncRegistry creates an MCPRegistry with manual sync only -func (h *MCPRegistryTestHelper) CreateManualSyncRegistry(name, configMapName string) *mcpv1alpha1.MCPRegistry { +func (h *MCPRegistryTestHelper) CreateManualSyncRegistry(name, configMapName string) *mcpv1beta1.MCPRegistry { return h.NewRegistryBuilder(name). WithConfigMapSource(configMapName, "registry.json"). Create(h) } // CreateUpstreamFormatRegistry creates an MCPRegistry with upstream format -func (h *MCPRegistryTestHelper) CreateUpstreamFormatRegistry(name, configMapName string) *mcpv1alpha1.MCPRegistry { +func (h *MCPRegistryTestHelper) CreateUpstreamFormatRegistry(name, configMapName string) *mcpv1beta1.MCPRegistry { return h.NewRegistryBuilder(name). WithConfigMapSource(configMapName, "registry.json"). WithUpstreamFormat(). @@ -349,8 +349,8 @@ func (h *MCPRegistryTestHelper) CreateUpstreamFormatRegistry(name, configMapName } // GetRegistry retrieves an MCPRegistry by name -func (h *MCPRegistryTestHelper) GetRegistry(name string) (*mcpv1alpha1.MCPRegistry, error) { - registry := &mcpv1alpha1.MCPRegistry{} +func (h *MCPRegistryTestHelper) GetRegistry(name string) (*mcpv1beta1.MCPRegistry, error) { + registry := &mcpv1beta1.MCPRegistry{} err := h.Client.Get(h.Context, types.NamespacedName{ Namespace: h.Namespace, Name: name, @@ -359,13 +359,13 @@ func (h *MCPRegistryTestHelper) GetRegistry(name string) (*mcpv1alpha1.MCPRegist } // UpdateRegistry updates an existing MCPRegistry -func (h *MCPRegistryTestHelper) UpdateRegistry(registry *mcpv1alpha1.MCPRegistry) error { +func (h *MCPRegistryTestHelper) UpdateRegistry(registry *mcpv1beta1.MCPRegistry) error { return h.Client.Update(h.Context, registry) } // PatchRegistry patches an MCPRegistry with the given patch func (h *MCPRegistryTestHelper) PatchRegistry(name string, patch client.Patch) error { - registry := &mcpv1alpha1.MCPRegistry{} + registry := &mcpv1beta1.MCPRegistry{} registry.Name = name registry.Namespace = h.Namespace return h.Client.Patch(h.Context, registry, patch) @@ -373,7 +373,7 @@ func (h *MCPRegistryTestHelper) PatchRegistry(name string, patch client.Patch) e // DeleteRegistry deletes an MCPRegistry by name func (h *MCPRegistryTestHelper) DeleteRegistry(name string) error { - registry := &mcpv1alpha1.MCPRegistry{ + registry := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: h.Namespace, @@ -398,7 +398,7 @@ func (h *MCPRegistryTestHelper) TriggerManualSync(name string) error { } // GetRegistryStatus returns the current status of an MCPRegistry -func (h *MCPRegistryTestHelper) GetRegistryStatus(name string) (*mcpv1alpha1.MCPRegistryStatus, error) { +func (h *MCPRegistryTestHelper) GetRegistryStatus(name string) (*mcpv1beta1.MCPRegistryStatus, error) { registry, err := h.GetRegistry(name) if err != nil { return nil, err @@ -407,7 +407,7 @@ func (h *MCPRegistryTestHelper) GetRegistryStatus(name string) (*mcpv1alpha1.MCP } // GetRegistryPhase returns the current phase of an MCPRegistry -func (h *MCPRegistryTestHelper) GetRegistryPhase(name string) (mcpv1alpha1.MCPRegistryPhase, error) { +func (h *MCPRegistryTestHelper) GetRegistryPhase(name string) (mcpv1beta1.MCPRegistryPhase, error) { status, err := h.GetRegistryStatus(name) if err != nil { return "", err @@ -431,8 +431,8 @@ func (h *MCPRegistryTestHelper) GetRegistryCondition(name, conditionType string) } // ListRegistries returns all MCPRegistries in the namespace -func (h *MCPRegistryTestHelper) ListRegistries() (*mcpv1alpha1.MCPRegistryList, error) { - registryList := &mcpv1alpha1.MCPRegistryList{} +func (h *MCPRegistryTestHelper) ListRegistries() (*mcpv1beta1.MCPRegistryList, error) { + registryList := &mcpv1beta1.MCPRegistryList{} err := h.Client.List(h.Context, registryList, client.InNamespace(h.Namespace)) return registryList, err } @@ -476,9 +476,9 @@ func (h *MCPRegistryTestHelper) WaitForRegistryInitialization(registryName strin // Wait for controller to process and verify initial status ginkgo.By("waiting for controller to process and verify initial status") - statusHelper.WaitForPhaseAny(registryName, []mcpv1alpha1.MCPRegistryPhase{ - mcpv1alpha1.MCPRegistryPhasePending, - mcpv1alpha1.MCPRegistryPhaseReady, + statusHelper.WaitForPhaseAny(registryName, []mcpv1beta1.MCPRegistryPhase{ + mcpv1beta1.MCPRegistryPhasePending, + mcpv1beta1.MCPRegistryPhaseReady, }, MediumTimeout) } diff --git a/cmd/thv-operator/test-integration/mcp-registry/registry_lifecycle_test.go b/cmd/thv-operator/test-integration/mcp-registry/registry_lifecycle_test.go index a42d6b220b..f93cc0d525 100644 --- a/cmd/thv-operator/test-integration/mcp-registry/registry_lifecycle_test.go +++ b/cmd/thv-operator/test-integration/mcp-registry/registry_lifecycle_test.go @@ -13,7 +13,7 @@ import ( "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) const ( @@ -97,7 +97,7 @@ var _ = Describe("MCPRegistry Lifecycle Management", Label("k8s", "registry"), f // Verify registry enters terminating phase By("waiting for registry to enter terminating phase") - statusHelper.WaitForPhase(registry.Name, mcpv1alpha1.MCPRegistryPhaseTerminating, MediumTimeout) + statusHelper.WaitForPhase(registry.Name, mcpv1beta1.MCPRegistryPhaseTerminating, MediumTimeout) By("waiting for finalizer to be removed") timingHelper.WaitForControllerReconciliation(func() interface{} { @@ -127,13 +127,13 @@ var _ = Describe("MCPRegistry Lifecycle Management", Label("k8s", "registry"), f Create(registryHelper) // Wait for registry to be ready - statusHelper.WaitForPhaseAny(registry.Name, []mcpv1alpha1.MCPRegistryPhase{mcpv1alpha1.MCPRegistryPhaseReady, mcpv1alpha1.MCPRegistryPhasePending}, MediumTimeout) + statusHelper.WaitForPhaseAny(registry.Name, []mcpv1beta1.MCPRegistryPhase{mcpv1beta1.MCPRegistryPhaseReady, mcpv1beta1.MCPRegistryPhasePending}, MediumTimeout) // Delete the registry Expect(registryHelper.DeleteRegistry(registry.Name)).To(Succeed()) // Verify graceful deletion process - statusHelper.WaitForPhase(registry.Name, mcpv1alpha1.MCPRegistryPhaseTerminating, QuickTimeout) + statusHelper.WaitForPhase(registry.Name, mcpv1beta1.MCPRegistryPhaseTerminating, QuickTimeout) // Verify complete deletion timingHelper.WaitForControllerReconciliation(func() interface{} { @@ -183,8 +183,8 @@ var _ = Describe("MCPRegistry Lifecycle Management", Label("k8s", "registry"), f Create(registryHelper) // Both should become ready independently - statusHelper.WaitForPhaseAny(registry1.Name, []mcpv1alpha1.MCPRegistryPhase{mcpv1alpha1.MCPRegistryPhaseReady, mcpv1alpha1.MCPRegistryPhasePending}, MediumTimeout) - statusHelper.WaitForPhaseAny(registry2.Name, []mcpv1alpha1.MCPRegistryPhase{mcpv1alpha1.MCPRegistryPhaseReady, mcpv1alpha1.MCPRegistryPhasePending}, MediumTimeout) + statusHelper.WaitForPhaseAny(registry1.Name, []mcpv1beta1.MCPRegistryPhase{mcpv1beta1.MCPRegistryPhaseReady, mcpv1beta1.MCPRegistryPhasePending}, MediumTimeout) + statusHelper.WaitForPhaseAny(registry2.Name, []mcpv1beta1.MCPRegistryPhase{mcpv1beta1.MCPRegistryPhaseReady, mcpv1beta1.MCPRegistryPhasePending}, MediumTimeout) // Verify they operate independently by checking their configYAML Expect(registry1.Spec.ConfigYAML).To(ContainSubstring("interval: 1h")) @@ -209,7 +209,7 @@ var _ = Describe("MCPRegistry Lifecycle Management", Label("k8s", "registry"), f Create(registryHelper) // Both should become ready - statusHelper.WaitForPhaseAny(registry1.Name, []mcpv1alpha1.MCPRegistryPhase{mcpv1alpha1.MCPRegistryPhaseReady, mcpv1alpha1.MCPRegistryPhasePending}, MediumTimeout) + statusHelper.WaitForPhaseAny(registry1.Name, []mcpv1beta1.MCPRegistryPhase{mcpv1beta1.MCPRegistryPhaseReady, mcpv1beta1.MCPRegistryPhasePending}, MediumTimeout) By("verifying registry servers config ConfigMap is created") serverConfigMap1 := testHelpers.waitForAndGetServerConfigMap(registry1.Name) @@ -246,7 +246,7 @@ var _ = Describe("MCPRegistry Lifecycle Management", Label("k8s", "registry"), f Expect(errors.IsAlreadyExists(err)).To(BeTrue()) // Original registry should still be functional - statusHelper.WaitForPhaseAny(registry1.Name, []mcpv1alpha1.MCPRegistryPhase{mcpv1alpha1.MCPRegistryPhaseReady, mcpv1alpha1.MCPRegistryPhasePending}, MediumTimeout) + statusHelper.WaitForPhaseAny(registry1.Name, []mcpv1beta1.MCPRegistryPhase{mcpv1beta1.MCPRegistryPhaseReady, mcpv1beta1.MCPRegistryPhasePending}, MediumTimeout) }) }) }) diff --git a/cmd/thv-operator/test-integration/mcp-registry/registry_server_rbac_test.go b/cmd/thv-operator/test-integration/mcp-registry/registry_server_rbac_test.go index e520cde2e4..ccc9328324 100644 --- a/cmd/thv-operator/test-integration/mcp-registry/registry_server_rbac_test.go +++ b/cmd/thv-operator/test-integration/mcp-registry/registry_server_rbac_test.go @@ -12,7 +12,7 @@ import ( rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/registryapi" ) @@ -49,9 +49,9 @@ var _ = Describe("MCPRegistry RBAC Resources", Label("k8s", "registry", "rbac"), Create(registryHelper) // Wait for registry to be reconciled - statusHelper.WaitForPhaseAny(registry.Name, []mcpv1alpha1.MCPRegistryPhase{ - mcpv1alpha1.MCPRegistryPhaseReady, - mcpv1alpha1.MCPRegistryPhasePending, + statusHelper.WaitForPhaseAny(registry.Name, []mcpv1beta1.MCPRegistryPhase{ + mcpv1beta1.MCPRegistryPhaseReady, + mcpv1beta1.MCPRegistryPhasePending, }, MediumTimeout) resourceName := registryapi.GetServiceAccountName(registry) diff --git a/cmd/thv-operator/test-integration/mcp-registry/registryserver_config_test.go b/cmd/thv-operator/test-integration/mcp-registry/registryserver_config_test.go index b06fba8b1b..01116a9ac5 100644 --- a/cmd/thv-operator/test-integration/mcp-registry/registryserver_config_test.go +++ b/cmd/thv-operator/test-integration/mcp-registry/registryserver_config_test.go @@ -20,7 +20,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/registryapi" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/registryapi/config" ) @@ -78,9 +78,9 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis DescribeTable("Registry Server Config Creation for Different Sources", func( registryName string, - setupRegistry func() *mcpv1alpha1.MCPRegistry, + setupRegistry func() *mcpv1beta1.MCPRegistry, expectedConfigContent map[string]string, - verifySourceVolume func(*appsv1.Deployment, *mcpv1alpha1.MCPRegistry), + verifySourceVolume func(*appsv1.Deployment, *mcpv1beta1.MCPRegistry), ) { By("creating an MCPRegistry resource") registry := setupRegistry() @@ -131,15 +131,15 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis // In envtest, the deployment won't actually be ready, so expect Pending phase // but verify that sync is complete and API deployment is in progress Expect(registry.Status.Phase).To(BeElementOf( - mcpv1alpha1.MCPRegistryPhasePending, // API deployment in progress - mcpv1alpha1.MCPRegistryPhaseReady, // If somehow API becomes ready + mcpv1beta1.MCPRegistryPhasePending, // API deployment in progress + mcpv1beta1.MCPRegistryPhaseReady, // If somehow API becomes ready )) // Verify ObservedGeneration is set after reconciliation Expect(registry.Status.ObservedGeneration).To(Equal(registry.Generation)) // Verify phase and URL - if registry.Status.Phase == mcpv1alpha1.MCPRegistryPhaseReady { + if registry.Status.Phase == mcpv1beta1.MCPRegistryPhaseReady { Expect(registry.Status.URL).To(Equal(fmt.Sprintf("http://%s.%s.svc.cluster.local:8080", apiResourceName, testNamespace))) } @@ -180,7 +180,7 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis Entry("ConfigMap Source", "test-config-registry", - func() *mcpv1alpha1.MCPRegistry { + func() *mcpv1beta1.MCPRegistry { configMap := configMapHelper.CreateSampleToolHiveRegistry("test-config") return registryHelper.NewRegistryBuilder("test-config-registry"). WithConfigMapSource(configMap.Name, "registry.json"). @@ -193,7 +193,7 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis "path": "/config/registry/default/registry.json", "interval": "1h", }, - func(deployment *appsv1.Deployment, registry *mcpv1alpha1.MCPRegistry) { + func(deployment *appsv1.Deployment, registry *mcpv1beta1.MCPRegistry) { // ConfigMap sources need the source data volume testHelpers.verifySourceDataVolume(deployment, registry) }, @@ -201,7 +201,7 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis Entry("Git Source", "test-git-registry", - func() *mcpv1alpha1.MCPRegistry { + func() *mcpv1beta1.MCPRegistry { return registryHelper.NewRegistryBuilder("test-git-registry"). WithGitSource( "https://github.com/mcp-servers/example-registry.git", @@ -217,7 +217,7 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis "interval": "2h", }, - func(deployment *appsv1.Deployment, _ *mcpv1alpha1.MCPRegistry) { + func(deployment *appsv1.Deployment, _ *mcpv1beta1.MCPRegistry) { // Git sources should NOT have the source data volume testHelpers.verifyNoSourceDataVolume(deployment, "Git") }, @@ -225,7 +225,7 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis Entry("API Source", "test-api-registry", - func() *mcpv1alpha1.MCPRegistry { + func() *mcpv1beta1.MCPRegistry { return registryHelper.NewRegistryBuilder("test-api-registry"). WithAPISource("http://registry-api.default.svc.cluster.local:8080/api"). WithSyncPolicy("30m"). @@ -235,7 +235,7 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis "endpoint": "http://registry-api.default.svc.cluster.local:8080/api", "interval": "30m", }, - func(deployment *appsv1.Deployment, _ *mcpv1alpha1.MCPRegistry) { + func(deployment *appsv1.Deployment, _ *mcpv1beta1.MCPRegistry) { // API sources should NOT have the source data volume testHelpers.verifyNoSourceDataVolume(deployment, "API") }, @@ -374,12 +374,12 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis })}, } - registry := &mcpv1alpha1.MCPRegistry{ + registry := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "multi-cm-volumes-test", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPRegistrySpec{ + Spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: configYAML, Volumes: volumes, VolumeMounts: volumeMounts, @@ -544,12 +544,12 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis ReadOnly: true, } - registry := &mcpv1alpha1.MCPRegistry{ + registry := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "git-auth-test", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPRegistrySpec{ + Spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: gitConfigYAML, Volumes: []apiextensionsv1.JSON{{Raw: mustMarshalJSON(secretVol)}}, VolumeMounts: []apiextensionsv1.JSON{{Raw: mustMarshalJSON(secretMount)}}, @@ -672,12 +672,12 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis })}, } - registry := &mcpv1alpha1.MCPRegistry{ + registry := &mcpv1beta1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "multi-git-auth-test", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPRegistrySpec{ + Spec: mcpv1beta1.MCPRegistrySpec{ ConfigYAML: multiGitConfigYAML, Volumes: volumes, VolumeMounts: volumeMounts, @@ -896,7 +896,7 @@ func (*serverConfigTestHelpers) verifyContainerArguments(deployment *appsv1.Depl } // verifyConfigMapOwnership verifies the ConfigMap is owned by the MCPRegistry -func (*serverConfigTestHelpers) verifyConfigMapOwnership(configMap *corev1.ConfigMap, registry *mcpv1alpha1.MCPRegistry) { +func (*serverConfigTestHelpers) verifyConfigMapOwnership(configMap *corev1.ConfigMap, registry *mcpv1beta1.MCPRegistry) { Expect(configMap.OwnerReferences).To(HaveLen(1)) Expect(configMap.OwnerReferences[0].Kind).To(Equal("MCPRegistry")) Expect(configMap.OwnerReferences[0].Name).To(Equal(registry.Name)) @@ -931,7 +931,7 @@ func (*serverConfigTestHelpers) verifyNoSourceDataVolume(deployment *appsv1.Depl // verifySourceDataVolume verifies the source data ConfigMap volume for ConfigMap sources // by checking the user-provided Volumes/VolumeMounts on the registry spec. -func (*serverConfigTestHelpers) verifySourceDataVolume(deployment *appsv1.Deployment, registry *mcpv1alpha1.MCPRegistry) { +func (*serverConfigTestHelpers) verifySourceDataVolume(deployment *appsv1.Deployment, registry *mcpv1beta1.MCPRegistry) { // Parse volumes from the registry spec to understand expected volume configuration userVolumes, err := registry.Spec.ParseVolumes() Expect(err).NotTo(HaveOccurred()) @@ -1020,17 +1020,17 @@ func (h *serverConfigTestHelpers) verifyPodTemplateValidCondition(registryName s if err != nil { return false } - condition := meta.FindStatusCondition(updatedRegistry.Status.Conditions, mcpv1alpha1.ConditionPodTemplateValid) + condition := meta.FindStatusCondition(updatedRegistry.Status.Conditions, mcpv1beta1.ConditionPodTemplateValid) if condition == nil { return false } if expectedValid { return condition.Status == metav1.ConditionTrue && - condition.Reason == mcpv1alpha1.ConditionReasonPodTemplateValid + condition.Reason == mcpv1beta1.ConditionReasonPodTemplateValid } return condition.Status == metav1.ConditionFalse && - condition.Reason == mcpv1alpha1.ConditionReasonPodTemplateInvalid + condition.Reason == mcpv1beta1.ConditionReasonPodTemplateInvalid }, MediumTimeout, DefaultPollingInterval).Should(BeTrue(), fmt.Sprintf("PodTemplateValid condition should be %v", expectedValid)) } @@ -1042,7 +1042,7 @@ func (h *serverConfigTestHelpers) verifyRegistryFailedWithInvalidPodTemplate(reg if err != nil { return false } - return updatedRegistry.Status.Phase == mcpv1alpha1.MCPRegistryPhaseFailed && + return updatedRegistry.Status.Phase == mcpv1beta1.MCPRegistryPhaseFailed && strings.Contains(updatedRegistry.Status.Message, "Invalid PodTemplateSpec") }, MediumTimeout, DefaultPollingInterval).Should(BeTrue(), "MCPRegistry should be in Failed phase with Invalid PodTemplateSpec message") diff --git a/cmd/thv-operator/test-integration/mcp-registry/status_helpers.go b/cmd/thv-operator/test-integration/mcp-registry/status_helpers.go index ab89694dd3..b8763088df 100644 --- a/cmd/thv-operator/test-integration/mcp-registry/status_helpers.go +++ b/cmd/thv-operator/test-integration/mcp-registry/status_helpers.go @@ -14,7 +14,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // StatusTestHelper provides utilities for MCPRegistry status testing and validation @@ -30,20 +30,20 @@ func NewStatusTestHelper(ctx context.Context, k8sClient client.Client, namespace } // WaitForPhase waits for an MCPRegistry to reach the specified phase -func (h *StatusTestHelper) WaitForPhase(registryName string, expectedPhase mcpv1alpha1.MCPRegistryPhase, timeout time.Duration) { - h.WaitForPhaseAny(registryName, []mcpv1alpha1.MCPRegistryPhase{expectedPhase}, timeout) +func (h *StatusTestHelper) WaitForPhase(registryName string, expectedPhase mcpv1beta1.MCPRegistryPhase, timeout time.Duration) { + h.WaitForPhaseAny(registryName, []mcpv1beta1.MCPRegistryPhase{expectedPhase}, timeout) } // WaitForPhaseAny waits for an MCPRegistry to reach any of the specified phases func (h *StatusTestHelper) WaitForPhaseAny(registryName string, - expectedPhases []mcpv1alpha1.MCPRegistryPhase, timeout time.Duration) { - gomega.Eventually(func() mcpv1alpha1.MCPRegistryPhase { + expectedPhases []mcpv1beta1.MCPRegistryPhase, timeout time.Duration) { + gomega.Eventually(func() mcpv1beta1.MCPRegistryPhase { ginkgo.By(fmt.Sprintf("waiting for registry %s to reach one of phases %v", registryName, expectedPhases)) registry, err := h.registryHelper.GetRegistry(registryName) if err != nil { if errors.IsNotFound(err) { ginkgo.By(fmt.Sprintf("registry %s not found", registryName)) - return mcpv1alpha1.MCPRegistryPhaseTerminating + return mcpv1beta1.MCPRegistryPhaseTerminating } return "" } @@ -87,8 +87,8 @@ func (h *StatusTestHelper) WaitForSyncCompletion(registryName string, timeout ti // Check if sync is no longer in progress phase := registry.Status.Phase - return phase == mcpv1alpha1.MCPRegistryPhaseReady || - phase == mcpv1alpha1.MCPRegistryPhaseFailed + return phase == mcpv1beta1.MCPRegistryPhaseReady || + phase == mcpv1beta1.MCPRegistryPhaseFailed }, timeout, time.Second).Should(gomega.BeTrue(), "MCPRegistry %s sync operation should complete", registryName) } diff --git a/cmd/thv-operator/test-integration/mcp-registry/suite_test.go b/cmd/thv-operator/test-integration/mcp-registry/suite_test.go index f158191dbf..52ae4e0c5f 100644 --- a/cmd/thv-operator/test-integration/mcp-registry/suite_test.go +++ b/cmd/thv-operator/test-integration/mcp-registry/suite_test.go @@ -22,7 +22,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/controllers" ) @@ -82,7 +82,7 @@ var _ = BeforeSuite(func() { Expect(cfg).NotTo(BeNil()) // Add MCPRegistry scheme - err = mcpv1alpha1.AddToScheme(scheme.Scheme) + err = mcpv1beta1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) // Create controller-runtime client @@ -93,7 +93,7 @@ var _ = BeforeSuite(func() { // Verify MCPRegistry CRD is available By("verifying MCPRegistry CRD is available") Eventually(func() error { - mcpRegistry := &mcpv1alpha1.MCPRegistry{} + mcpRegistry := &mcpv1beta1.MCPRegistry{} return k8sClient.Get(ctx, client.ObjectKey{ Namespace: "default", Name: "test-availability-check", diff --git a/cmd/thv-operator/test-integration/mcp-remote-proxy/k8s_helpers.go b/cmd/thv-operator/test-integration/mcp-remote-proxy/k8s_helpers.go index 5cedf6ccf7..7bb6f0df39 100644 --- a/cmd/thv-operator/test-integration/mcp-remote-proxy/k8s_helpers.go +++ b/cmd/thv-operator/test-integration/mcp-remote-proxy/k8s_helpers.go @@ -15,7 +15,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // WaitForDeployment waits for a Deployment to be created and returns it. @@ -169,12 +169,12 @@ func (h *MCPRemoteProxyTestHelper) WaitForToolConfigHashChange( } // verifyRemoteProxyOwnerReference verifies that the owner reference matches the expected MCPRemoteProxy. -func verifyRemoteProxyOwnerReference(ownerRefs []metav1.OwnerReference, proxy *mcpv1alpha1.MCPRemoteProxy, resourceType string) { +func verifyRemoteProxyOwnerReference(ownerRefs []metav1.OwnerReference, proxy *mcpv1beta1.MCPRemoteProxy, resourceType string) { gomega.ExpectWithOffset(1, ownerRefs).To(gomega.HaveLen(1), fmt.Sprintf("%s should have exactly one owner reference", resourceType)) ownerRef := ownerRefs[0] - gomega.ExpectWithOffset(1, ownerRef.APIVersion).To(gomega.Equal("toolhive.stacklok.dev/v1alpha1"), + gomega.ExpectWithOffset(1, ownerRef.APIVersion).To(gomega.Equal("toolhive.stacklok.dev/v1beta1"), fmt.Sprintf("%s owner reference should have correct APIVersion", resourceType)) gomega.ExpectWithOffset(1, ownerRef.Kind).To(gomega.Equal("MCPRemoteProxy"), fmt.Sprintf("%s owner reference should have correct Kind", resourceType)) diff --git a/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_authserverref_integration_test.go b/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_authserverref_integration_test.go index 9d779c8b30..a8cbf1ace0 100644 --- a/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_authserverref_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_authserverref_integration_test.go @@ -12,7 +12,7 @@ import ( . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) var _ = Describe("MCPRemoteProxy AuthServerRef Integration", Label("k8s", "remoteproxy", "authserverref"), func() { @@ -54,14 +54,14 @@ var _ = Describe("MCPRemoteProxy AuthServerRef Integration", Label("k8s", "remot By("waiting for AuthServerRefValidated condition to be True") statusHelper.WaitForCondition( proxy.Name, - mcpv1alpha1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, + mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, metav1.ConditionTrue, MediumTimeout, ) By("verifying the condition message") condition, err := proxyHelper.GetRemoteProxyCondition( - proxy.Name, mcpv1alpha1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, + proxy.Name, mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, ) Expect(err).NotTo(HaveOccurred()) Expect(condition.Message).To(ContainSubstring("is valid")) @@ -104,7 +104,7 @@ var _ = Describe("MCPRemoteProxy AuthServerRef Integration", Label("k8s", "remot By("waiting for AuthServerRefValidated condition to be True") statusHelper.WaitForCondition( proxy.Name, - mcpv1alpha1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, + mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, metav1.ConditionTrue, MediumTimeout, ) @@ -147,18 +147,18 @@ var _ = Describe("MCPRemoteProxy AuthServerRef Integration", Label("k8s", "remot By("verifying the proxy never reaches Ready phase") // The MCPRemoteProxy controller does not set Phase=Failed for // ensureAllResources errors — it requeues indefinitely. - Consistently(func() mcpv1alpha1.MCPRemoteProxyPhase { + Consistently(func() mcpv1beta1.MCPRemoteProxyPhase { p, err := proxyHelper.GetRemoteProxy(proxy.Name) if err != nil { return "" } return p.Status.Phase - }, time.Second*10, DefaultPollingInterval).ShouldNot(Equal(mcpv1alpha1.MCPRemoteProxyPhaseReady)) + }, time.Second*10, DefaultPollingInterval).ShouldNot(Equal(mcpv1beta1.MCPRemoteProxyPhaseReady)) By("verifying AuthServerRefValidated is True (individual ref is valid)") statusHelper.WaitForCondition( proxy.Name, - mcpv1alpha1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, + mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, metav1.ConditionTrue, MediumTimeout, ) @@ -173,10 +173,10 @@ var _ = Describe("MCPRemoteProxy AuthServerRef Integration", Label("k8s", "remot Context("Type mismatch: authServerRef pointing to non-embeddedAuthServer type", func() { It("should reach Failed phase with type mismatch condition", func() { By("creating MCPExternalAuthConfig with unauthenticated type") - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: "typemismatch-auth", Namespace: testNamespace}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUnauthenticated, }, } Expect(k8sClient.Create(testCtx, authConfig)).To(Succeed()) @@ -187,18 +187,18 @@ var _ = Describe("MCPRemoteProxy AuthServerRef Integration", Label("k8s", "remot Create(proxyHelper) By("waiting for Failed phase") - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhaseFailed, MediumTimeout) + statusHelper.WaitForPhase(proxy.Name, mcpv1beta1.MCPRemoteProxyPhaseFailed, MediumTimeout) By("verifying AuthServerRefValidated condition is False with type mismatch message") statusHelper.WaitForCondition( proxy.Name, - mcpv1alpha1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, + mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, metav1.ConditionFalse, MediumTimeout, ) condition, err := proxyHelper.GetRemoteProxyCondition( - proxy.Name, mcpv1alpha1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, + proxy.Name, mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, ) Expect(err).NotTo(HaveOccurred()) Expect(condition.Message).To(ContainSubstring("only embeddedAuthServer is supported")) @@ -235,7 +235,7 @@ var _ = Describe("MCPRemoteProxy AuthServerRef Integration", Label("k8s", "remot By("verifying the proxy is not in Failed phase") phase, err := proxyHelper.GetRemoteProxyPhase(proxy.Name) Expect(err).NotTo(HaveOccurred()) - Expect(phase).NotTo(Equal(mcpv1alpha1.MCPRemoteProxyPhaseFailed)) + Expect(phase).NotTo(Equal(mcpv1beta1.MCPRemoteProxyPhaseFailed)) By("cleaning up auth resources") Expect(k8sClient.Delete(testCtx, authConfig)).To(Succeed()) @@ -245,18 +245,18 @@ var _ = Describe("MCPRemoteProxy AuthServerRef Integration", Label("k8s", "remot }) // newEmbeddedAuthConfig creates an MCPExternalAuthConfig with type embeddedAuthServer. -func newEmbeddedAuthConfig(name, namespace string) *mcpv1alpha1.MCPExternalAuthConfig { - return &mcpv1alpha1.MCPExternalAuthConfig{ +func newEmbeddedAuthConfig(name, namespace string) *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "http://localhost:9090", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "test-provider", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://accounts.google.com", ClientID: "test-client-id", }, @@ -268,12 +268,12 @@ func newEmbeddedAuthConfig(name, namespace string) *mcpv1alpha1.MCPExternalAuthC } // newAWSStsConfig creates an MCPExternalAuthConfig with type awsSts. -func newAWSStsConfig(name, namespace string) *mcpv1alpha1.MCPExternalAuthConfig { - return &mcpv1alpha1.MCPExternalAuthConfig{ +func newAWSStsConfig(name, namespace string) *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeAWSSts, - AWSSts: &mcpv1alpha1.AWSStsConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeAWSSts, + AWSSts: &mcpv1beta1.AWSStsConfig{ Region: "us-east-1", FallbackRoleArn: "arn:aws:iam::123456789012:role/test-role", }, @@ -282,12 +282,12 @@ func newAWSStsConfig(name, namespace string) *mcpv1alpha1.MCPExternalAuthConfig } // newMCPOIDCConfig creates an MCPOIDCConfig with inline OIDC configuration. -func newMCPOIDCConfig(name, namespace string) *mcpv1alpha1.MCPOIDCConfig { - return &mcpv1alpha1.MCPOIDCConfig{ +func newMCPOIDCConfig(name, namespace string) *mcpv1beta1.MCPOIDCConfig { + return &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "http://localhost:9090", }, }, diff --git a/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_controller_integration_test.go b/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_controller_integration_test.go index 923bc7a0fa..1cad6fc9df 100644 --- a/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_controller_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_controller_integration_test.go @@ -14,7 +14,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/runner" ) @@ -241,16 +241,16 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( By("waiting for Ready condition to be set") statusHelper.WaitForCondition( - proxy.Name, mcpv1alpha1.ConditionTypeReady, metav1.ConditionFalse, MediumTimeout, + proxy.Name, mcpv1beta1.ConditionTypeReady, metav1.ConditionFalse, MediumTimeout, ) By("verifying the Ready condition reason") - condition, err := proxyHelper.GetRemoteProxyCondition(proxy.Name, mcpv1alpha1.ConditionTypeReady) + condition, err := proxyHelper.GetRemoteProxyCondition(proxy.Name, mcpv1beta1.ConditionTypeReady) Expect(err).NotTo(HaveOccurred()) Expect(condition).NotTo(BeNil()) // Initially the condition will be False because the deployment pods won't be running in envtest Expect(condition.Status).To(Equal(metav1.ConditionFalse)) - Expect(condition.Reason).To(Equal(mcpv1alpha1.ConditionReasonDeploymentNotReady)) + Expect(condition.Reason).To(Equal(mcpv1beta1.ConditionReasonDeploymentNotReady)) }) It("should set Pending phase initially", func() { @@ -258,14 +258,14 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( proxy := proxyHelper.NewRemoteProxyBuilder("test-pending-phase").Create(proxyHelper) By("waiting for status to be updated") - statusHelper.WaitForPhaseAny(proxy.Name, []mcpv1alpha1.MCPRemoteProxyPhase{ - mcpv1alpha1.MCPRemoteProxyPhasePending, - mcpv1alpha1.MCPRemoteProxyPhaseReady, + statusHelper.WaitForPhaseAny(proxy.Name, []mcpv1beta1.MCPRemoteProxyPhase{ + mcpv1beta1.MCPRemoteProxyPhasePending, + mcpv1beta1.MCPRemoteProxyPhaseReady, }, MediumTimeout) By("verifying the phase is Pending (since deployment is not ready in envtest)") // In envtest, pods don't actually run so phase will be Pending - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhasePending, MediumTimeout) + statusHelper.WaitForPhase(proxy.Name, mcpv1beta1.MCPRemoteProxyPhasePending, MediumTimeout) }) It("should update ObservedGeneration in status", func() { @@ -307,14 +307,14 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( proxy := proxyHelper.NewRemoteProxyBuilder("test-auth-configured").Create(proxyHelper) By("waiting for controller to process the resource") - statusHelper.WaitForPhaseAny(proxy.Name, []mcpv1alpha1.MCPRemoteProxyPhase{ - mcpv1alpha1.MCPRemoteProxyPhasePending, - mcpv1alpha1.MCPRemoteProxyPhaseReady, + statusHelper.WaitForPhaseAny(proxy.Name, []mcpv1beta1.MCPRemoteProxyPhase{ + mcpv1beta1.MCPRemoteProxyPhasePending, + mcpv1beta1.MCPRemoteProxyPhaseReady, }, MediumTimeout) By("verifying that the AuthConfigured condition does not exist (valid config)") _, err := proxyHelper.GetRemoteProxyCondition( - proxy.Name, mcpv1alpha1.ConditionTypeAuthConfigured, + proxy.Name, mcpv1beta1.ConditionTypeAuthConfigured, ) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("not found")) @@ -355,18 +355,18 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( Create(proxyHelper) By("waiting for the proxy to reach Failed phase due to missing ExternalAuthConfig") - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhaseFailed, MediumTimeout) + statusHelper.WaitForPhase(proxy.Name, mcpv1beta1.MCPRemoteProxyPhaseFailed, MediumTimeout) By("verifying the AuthConfigured condition indicates invalid auth") statusHelper.WaitForConditionReason( proxy.Name, - mcpv1alpha1.ConditionTypeAuthConfigured, - mcpv1alpha1.ConditionReasonAuthInvalid, + mcpv1beta1.ConditionTypeAuthConfigured, + mcpv1beta1.ConditionReasonAuthInvalid, MediumTimeout, ) condition, err := proxyHelper.GetRemoteProxyCondition( - proxy.Name, mcpv1alpha1.ConditionTypeAuthConfigured, + proxy.Name, mcpv1beta1.ConditionTypeAuthConfigured, ) Expect(err).NotTo(HaveOccurred()) Expect(condition.Status).To(Equal(metav1.ConditionFalse)) @@ -379,16 +379,16 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( It("should successfully reconcile when referenced MCPExternalAuthConfig exists", func() { By("creating an MCPExternalAuthConfig") - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth-config", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "api-key-secret", Key: "key", }, @@ -399,7 +399,7 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( By("waiting for MCPExternalAuthConfig to have a ConfigHash") Eventually(func() string { - config := &mcpv1alpha1.MCPExternalAuthConfig{} + config := &mcpv1beta1.MCPExternalAuthConfig{} if err := k8sClient.Get(testCtx, types.NamespacedName{ Namespace: testNamespace, Name: authConfig.Name, @@ -418,18 +418,18 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( hash := proxyHelper.WaitForExternalAuthConfigHash(proxy.Name, MediumTimeout) By("verifying phase is Pending (not Failed)") - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhasePending, MediumTimeout) + statusHelper.WaitForPhase(proxy.Name, mcpv1beta1.MCPRemoteProxyPhasePending, MediumTimeout) By("verifying the ExternalAuthConfigHash is tracked in status") Expect(hash).NotTo(BeEmpty()) By("verifying the ExternalAuthConfigValidated condition is True") condition, err := proxyHelper.GetRemoteProxyCondition( - proxy.Name, mcpv1alpha1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated, + proxy.Name, mcpv1beta1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated, ) Expect(err).NotTo(HaveOccurred()) Expect(condition.Status).To(Equal(metav1.ConditionTrue)) - Expect(condition.Reason).To(Equal(mcpv1alpha1.ConditionReasonMCPRemoteProxyExternalAuthConfigValid)) + Expect(condition.Reason).To(Equal(mcpv1beta1.ConditionReasonMCPRemoteProxyExternalAuthConfigValid)) Expect(condition.Message).To(ContainSubstring("test-auth-config")) By("cleaning up the auth config") @@ -438,16 +438,16 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( It("should trigger reconciliation when MCPExternalAuthConfig is updated", func() { By("creating an MCPExternalAuthConfig") - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth-update", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-Original-Header", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "original-secret", Key: "key", }, @@ -459,7 +459,7 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( By("waiting for MCPExternalAuthConfig to have a ConfigHash") var originalHash string Eventually(func() string { - config := &mcpv1alpha1.MCPExternalAuthConfig{} + config := &mcpv1beta1.MCPExternalAuthConfig{} if err := k8sClient.Get(testCtx, types.NamespacedName{ Namespace: testNamespace, Name: authConfig.Name, @@ -486,7 +486,7 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( By("updating the MCPExternalAuthConfig") Eventually(func() error { - config := &mcpv1alpha1.MCPExternalAuthConfig{} + config := &mcpv1beta1.MCPExternalAuthConfig{} if err := k8sClient.Get(testCtx, types.NamespacedName{ Namespace: testNamespace, Name: authConfig.Name, @@ -499,7 +499,7 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( By("waiting for the auth config hash to change") Eventually(func() string { - config := &mcpv1alpha1.MCPExternalAuthConfig{} + config := &mcpv1beta1.MCPExternalAuthConfig{} if err := k8sClient.Get(testCtx, types.NamespacedName{ Namespace: testNamespace, Name: authConfig.Name, @@ -525,18 +525,18 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( Create(proxyHelper) By("waiting for the proxy to reach Failed phase due to missing ToolConfig") - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhaseFailed, MediumTimeout) + statusHelper.WaitForPhase(proxy.Name, mcpv1beta1.MCPRemoteProxyPhaseFailed, MediumTimeout) By("verifying the ToolConfigValidated condition indicates not found") statusHelper.WaitForConditionReason( proxy.Name, - mcpv1alpha1.ConditionTypeMCPRemoteProxyToolConfigValidated, - mcpv1alpha1.ConditionReasonMCPRemoteProxyToolConfigNotFound, + mcpv1beta1.ConditionTypeMCPRemoteProxyToolConfigValidated, + mcpv1beta1.ConditionReasonMCPRemoteProxyToolConfigNotFound, MediumTimeout, ) condition, err := proxyHelper.GetRemoteProxyCondition( - proxy.Name, mcpv1alpha1.ConditionTypeMCPRemoteProxyToolConfigValidated, + proxy.Name, mcpv1beta1.ConditionTypeMCPRemoteProxyToolConfigValidated, ) Expect(err).NotTo(HaveOccurred()) Expect(condition.Status).To(Equal(metav1.ConditionFalse)) @@ -545,12 +545,12 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( It("should successfully reconcile when referenced MCPToolConfig exists", func() { By("creating an MCPToolConfig") - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-tool-config", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1", "tool2"}, }, } @@ -558,7 +558,7 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( By("waiting for MCPToolConfig to have a ConfigHash") Eventually(func() string { - config := &mcpv1alpha1.MCPToolConfig{} + config := &mcpv1beta1.MCPToolConfig{} if err := k8sClient.Get(testCtx, types.NamespacedName{ Namespace: testNamespace, Name: toolConfig.Name, @@ -577,18 +577,18 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( hash := proxyHelper.WaitForToolConfigHash(proxy.Name, MediumTimeout) By("verifying phase is Pending (not Failed)") - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhasePending, MediumTimeout) + statusHelper.WaitForPhase(proxy.Name, mcpv1beta1.MCPRemoteProxyPhasePending, MediumTimeout) By("verifying the ToolConfigHash is tracked in status") Expect(hash).NotTo(BeEmpty()) By("verifying the ToolConfigValidated condition is True") condition, err := proxyHelper.GetRemoteProxyCondition( - proxy.Name, mcpv1alpha1.ConditionTypeMCPRemoteProxyToolConfigValidated, + proxy.Name, mcpv1beta1.ConditionTypeMCPRemoteProxyToolConfigValidated, ) Expect(err).NotTo(HaveOccurred()) Expect(condition.Status).To(Equal(metav1.ConditionTrue)) - Expect(condition.Reason).To(Equal(mcpv1alpha1.ConditionReasonMCPRemoteProxyToolConfigValid)) + Expect(condition.Reason).To(Equal(mcpv1beta1.ConditionReasonMCPRemoteProxyToolConfigValid)) Expect(condition.Message).To(ContainSubstring("test-tool-config")) By("cleaning up the tool config") @@ -597,12 +597,12 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( It("should propagate tool config changes to the RunConfig ConfigMap", func() { By("creating an MCPToolConfig with initial filter") - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-tool-propagate", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"initial-tool"}, }, } @@ -611,7 +611,7 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( By("waiting for MCPToolConfig to have a ConfigHash") var initialHash string Eventually(func() string { - config := &mcpv1alpha1.MCPToolConfig{} + config := &mcpv1beta1.MCPToolConfig{} if err := k8sClient.Get(testCtx, types.NamespacedName{ Namespace: testNamespace, Name: toolConfig.Name, @@ -641,7 +641,7 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( By("updating the MCPToolConfig with new filter") Eventually(func() error { - config := &mcpv1alpha1.MCPToolConfig{} + config := &mcpv1beta1.MCPToolConfig{} if err := k8sClient.Get(testCtx, types.NamespacedName{ Namespace: testNamespace, Name: toolConfig.Name, @@ -654,7 +654,7 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( By("waiting for the tool config hash to change") Eventually(func() string { - config := &mcpv1alpha1.MCPToolConfig{} + config := &mcpv1beta1.MCPToolConfig{} if err := k8sClient.Get(testCtx, types.NamespacedName{ Namespace: testNamespace, Name: toolConfig.Name, @@ -682,37 +682,37 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( By("waiting for the GroupRefValidated condition to be False") statusHelper.WaitForCondition( proxy.Name, - mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated, + mcpv1beta1.ConditionTypeMCPRemoteProxyGroupRefValidated, metav1.ConditionFalse, MediumTimeout, ) By("verifying the GroupRefValidated condition details") condition, err := proxyHelper.GetRemoteProxyCondition( - proxy.Name, mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated, + proxy.Name, mcpv1beta1.ConditionTypeMCPRemoteProxyGroupRefValidated, ) Expect(err).NotTo(HaveOccurred()) Expect(condition.Status).To(Equal(metav1.ConditionFalse)) - Expect(condition.Reason).To(Equal(mcpv1alpha1.ConditionReasonMCPRemoteProxyGroupRefNotFound)) + Expect(condition.Reason).To(Equal(mcpv1beta1.ConditionReasonMCPRemoteProxyGroupRefNotFound)) Expect(condition.Message).To(ContainSubstring("non-existent-group")) }) It("should set GroupRefValidated condition to True when referenced MCPGroup exists and is Ready", func() { By("creating an MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group-valid", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for MCPRemoteProxy integration", }, } Expect(k8sClient.Create(testCtx, mcpGroup)).To(Succeed()) By("waiting for the MCPGroup to be Ready") - Eventually(func() mcpv1alpha1.MCPGroupPhase { - group := &mcpv1alpha1.MCPGroup{} + Eventually(func() mcpv1beta1.MCPGroupPhase { + group := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(testCtx, types.NamespacedName{ Namespace: testNamespace, Name: mcpGroup.Name, @@ -720,7 +720,7 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( return "" } return group.Status.Phase - }, MediumTimeout, DefaultPollingInterval).Should(Equal(mcpv1alpha1.MCPGroupPhaseReady)) + }, MediumTimeout, DefaultPollingInterval).Should(Equal(mcpv1beta1.MCPGroupPhaseReady)) By("creating an MCPRemoteProxy referencing the MCPGroup") proxy := proxyHelper.NewRemoteProxyBuilder("test-group-valid"). @@ -730,18 +730,18 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( By("waiting for the GroupRefValidated condition to be True") statusHelper.WaitForCondition( proxy.Name, - mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated, + mcpv1beta1.ConditionTypeMCPRemoteProxyGroupRefValidated, metav1.ConditionTrue, MediumTimeout, ) By("verifying the GroupRefValidated condition details") condition, err := proxyHelper.GetRemoteProxyCondition( - proxy.Name, mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated, + proxy.Name, mcpv1beta1.ConditionTypeMCPRemoteProxyGroupRefValidated, ) Expect(err).NotTo(HaveOccurred()) Expect(condition.Status).To(Equal(metav1.ConditionTrue)) - Expect(condition.Reason).To(Equal(mcpv1alpha1.ConditionReasonMCPRemoteProxyGroupRefValidated)) + Expect(condition.Reason).To(Equal(mcpv1beta1.ConditionReasonMCPRemoteProxyGroupRefValidated)) Expect(condition.Message).To(ContainSubstring("test-group-valid")) Expect(condition.Message).To(ContainSubstring("valid and ready")) @@ -759,14 +759,14 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( proxy := proxyHelper.NewRemoteProxyBuilder("test-no-group").Create(proxyHelper) By("waiting for the proxy to be reconciled") - statusHelper.WaitForPhaseAny(proxy.Name, []mcpv1alpha1.MCPRemoteProxyPhase{ - mcpv1alpha1.MCPRemoteProxyPhasePending, - mcpv1alpha1.MCPRemoteProxyPhaseReady, + statusHelper.WaitForPhaseAny(proxy.Name, []mcpv1beta1.MCPRemoteProxyPhase{ + mcpv1beta1.MCPRemoteProxyPhasePending, + mcpv1beta1.MCPRemoteProxyPhaseReady, }, MediumTimeout) By("verifying no GroupRefValidated condition exists") _, err := proxyHelper.GetRemoteProxyCondition( - proxy.Name, mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated, + proxy.Name, mcpv1beta1.ConditionTypeMCPRemoteProxyGroupRefValidated, ) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("not found")) @@ -781,26 +781,26 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( By("waiting for the GroupRefValidated condition to be False (NotFound)") statusHelper.WaitForConditionReason( proxy.Name, - mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated, - mcpv1alpha1.ConditionReasonMCPRemoteProxyGroupRefNotFound, + mcpv1beta1.ConditionTypeMCPRemoteProxyGroupRefValidated, + mcpv1beta1.ConditionReasonMCPRemoteProxyGroupRefNotFound, MediumTimeout, ) By("creating the MCPGroup that was referenced") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group-transition", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for transition testing", }, } Expect(k8sClient.Create(testCtx, mcpGroup)).To(Succeed()) By("waiting for the MCPGroup to become Ready") - Eventually(func() mcpv1alpha1.MCPGroupPhase { - group := &mcpv1alpha1.MCPGroup{} + Eventually(func() mcpv1beta1.MCPGroupPhase { + group := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(testCtx, types.NamespacedName{ Namespace: testNamespace, Name: mcpGroup.Name, @@ -808,7 +808,7 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( return "" } return group.Status.Phase - }, MediumTimeout, DefaultPollingInterval).Should(Equal(mcpv1alpha1.MCPGroupPhaseReady)) + }, MediumTimeout, DefaultPollingInterval).Should(Equal(mcpv1beta1.MCPGroupPhaseReady)) By("triggering reconciliation by updating the proxy") Eventually(func() error { @@ -826,8 +826,8 @@ var _ = Describe("MCPRemoteProxy Controller", Label("k8s", "remoteproxy"), func( By("waiting for the GroupRefValidated condition to become True") statusHelper.WaitForConditionReason( proxy.Name, - mcpv1alpha1.ConditionTypeMCPRemoteProxyGroupRefValidated, - mcpv1alpha1.ConditionReasonMCPRemoteProxyGroupRefValidated, + mcpv1beta1.ConditionTypeMCPRemoteProxyGroupRefValidated, + mcpv1beta1.ConditionReasonMCPRemoteProxyGroupRefValidated, MediumTimeout, ) diff --git a/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_validation_integration_test.go b/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_validation_integration_test.go index e5de56483c..0ded19768c 100644 --- a/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_validation_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-remote-proxy/mcpremoteproxy_validation_integration_test.go @@ -11,7 +11,7 @@ import ( corev1 "k8s.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remoteproxy", "validation"), func() { @@ -56,13 +56,13 @@ var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remote Create(proxyHelper) By("waiting for the proxy to reach Failed phase") - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhaseFailed, MediumTimeout) + statusHelper.WaitForPhase(proxy.Name, mcpv1beta1.MCPRemoteProxyPhaseFailed, MediumTimeout) By("verifying the ConfigurationValid condition") statusHelper.WaitForConditionReason( proxy.Name, - mcpv1alpha1.ConditionTypeConfigurationValid, - mcpv1alpha1.ConditionReasonAuthzPolicySyntaxInvalid, + mcpv1beta1.ConditionTypeConfigurationValid, + mcpv1beta1.ConditionReasonAuthzPolicySyntaxInvalid, MediumTimeout, ) }) @@ -76,13 +76,13 @@ var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remote Create(proxyHelper) By("waiting for the proxy to reach Failed phase") - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhaseFailed, MediumTimeout) + statusHelper.WaitForPhase(proxy.Name, mcpv1beta1.MCPRemoteProxyPhaseFailed, MediumTimeout) By("verifying the ConfigurationValid condition") statusHelper.WaitForConditionReason( proxy.Name, - mcpv1alpha1.ConditionTypeConfigurationValid, - mcpv1alpha1.ConditionReasonAuthzConfigMapNotFound, + mcpv1beta1.ConditionTypeConfigurationValid, + mcpv1beta1.ConditionReasonAuthzConfigMapNotFound, MediumTimeout, ) }) @@ -94,13 +94,13 @@ var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remote Create(proxyHelper) By("waiting for the proxy to reach Failed phase") - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhaseFailed, MediumTimeout) + statusHelper.WaitForPhase(proxy.Name, mcpv1beta1.MCPRemoteProxyPhaseFailed, MediumTimeout) By("verifying the ConfigurationValid condition") statusHelper.WaitForConditionReason( proxy.Name, - mcpv1alpha1.ConditionTypeConfigurationValid, - mcpv1alpha1.ConditionReasonHeaderSecretNotFound, + mcpv1beta1.ConditionTypeConfigurationValid, + mcpv1beta1.ConditionReasonHeaderSecretNotFound, MediumTimeout, ) }) @@ -114,7 +114,7 @@ var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remote Create(proxyHelper) By("waiting for the proxy to reach Failed phase") - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhaseFailed, MediumTimeout) + statusHelper.WaitForPhase(proxy.Name, mcpv1beta1.MCPRemoteProxyPhaseFailed, MediumTimeout) By("verifying a Warning event was emitted with AuthzPolicySyntaxInvalid reason") Eventually(func() bool { @@ -126,7 +126,7 @@ var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remote for _, event := range eventList.Items { if event.InvolvedObject.Name == proxy.Name && event.Type == corev1.EventTypeWarning && - event.Reason == mcpv1alpha1.ConditionReasonAuthzPolicySyntaxInvalid { + event.Reason == mcpv1beta1.ConditionReasonAuthzPolicySyntaxInvalid { return true } } @@ -142,7 +142,7 @@ var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remote Create(proxyHelper) By("waiting for the proxy to reach Failed phase") - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhaseFailed, MediumTimeout) + statusHelper.WaitForPhase(proxy.Name, mcpv1beta1.MCPRemoteProxyPhaseFailed, MediumTimeout) By("verifying a Warning event was emitted") Eventually(func() bool { @@ -154,7 +154,7 @@ var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remote for _, event := range eventList.Items { if event.InvolvedObject.Name == proxy.Name && event.Type == corev1.EventTypeWarning && - event.Reason == mcpv1alpha1.ConditionReasonAuthzConfigMapNotFound { + event.Reason == mcpv1beta1.ConditionReasonAuthzConfigMapNotFound { return true } } @@ -170,7 +170,7 @@ var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remote Create(proxyHelper) By("waiting for the proxy to reach Failed phase") - statusHelper.WaitForPhase(proxy.Name, mcpv1alpha1.MCPRemoteProxyPhaseFailed, MediumTimeout) + statusHelper.WaitForPhase(proxy.Name, mcpv1beta1.MCPRemoteProxyPhaseFailed, MediumTimeout) By("verifying a Warning event was emitted") Eventually(func() bool { @@ -182,7 +182,7 @@ var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remote for _, event := range eventList.Items { if event.InvolvedObject.Name == proxy.Name && event.Type == corev1.EventTypeWarning && - event.Reason == mcpv1alpha1.ConditionReasonHeaderSecretNotFound { + event.Reason == mcpv1beta1.ConditionReasonHeaderSecretNotFound { return true } } diff --git a/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go b/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go index f6f364d435..8db8f1074f 100644 --- a/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go +++ b/cmd/thv-operator/test-integration/mcp-remote-proxy/remoteproxy_helpers.go @@ -15,7 +15,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // ServiceName returns the expected Service name for an MCPRemoteProxy, @@ -68,14 +68,14 @@ func NewMCPRemoteProxyTestHelper( // RemoteProxyBuilder provides a fluent interface for building MCPRemoteProxy objects type RemoteProxyBuilder struct { - proxy *mcpv1alpha1.MCPRemoteProxy + proxy *mcpv1beta1.MCPRemoteProxy } // NewRemoteProxyBuilder creates a new MCPRemoteProxy builder with sensible defaults // for required fields (RemoteURL, OIDCConfig) so tests only need to override what they're testing func (h *MCPRemoteProxyTestHelper) NewRemoteProxyBuilder(name string) *RemoteProxyBuilder { return &RemoteProxyBuilder{ - proxy: &mcpv1alpha1.MCPRemoteProxy{ + proxy: &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: h.Namespace, @@ -83,7 +83,7 @@ func (h *MCPRemoteProxyTestHelper) NewRemoteProxyBuilder(name string) *RemotePro "test.toolhive.io/suite": "operator-e2e", }, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote.example.com/mcp", ProxyPort: 8080, Transport: "streamable-http", @@ -100,7 +100,7 @@ func (rb *RemoteProxyBuilder) WithProxyPort(port int32) *RemoteProxyBuilder { // WithExternalAuthConfigRef sets the ExternalAuthConfigRef for the proxy func (rb *RemoteProxyBuilder) WithExternalAuthConfigRef(name string) *RemoteProxyBuilder { - rb.proxy.Spec.ExternalAuthConfigRef = &mcpv1alpha1.ExternalAuthConfigRef{ + rb.proxy.Spec.ExternalAuthConfigRef = &mcpv1beta1.ExternalAuthConfigRef{ Name: name, } return rb @@ -108,7 +108,7 @@ func (rb *RemoteProxyBuilder) WithExternalAuthConfigRef(name string) *RemoteProx // WithAuthServerRef sets the AuthServerRef for the proxy func (rb *RemoteProxyBuilder) WithAuthServerRef(name string) *RemoteProxyBuilder { - rb.proxy.Spec.AuthServerRef = &mcpv1alpha1.AuthServerRef{ + rb.proxy.Spec.AuthServerRef = &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: name, } @@ -119,7 +119,7 @@ func (rb *RemoteProxyBuilder) WithAuthServerRef(name string) *RemoteProxyBuilder // resourceURL sets both Audience and ResourceURL to the same value, which is // required when an embedded auth server is active (#4860). func (rb *RemoteProxyBuilder) WithOIDCConfigRef(name, resourceURL string) *RemoteProxyBuilder { - rb.proxy.Spec.OIDCConfigRef = &mcpv1alpha1.MCPOIDCConfigReference{ + rb.proxy.Spec.OIDCConfigRef = &mcpv1beta1.MCPOIDCConfigReference{ Name: name, Audience: resourceURL, ResourceURL: resourceURL, @@ -129,7 +129,7 @@ func (rb *RemoteProxyBuilder) WithOIDCConfigRef(name, resourceURL string) *Remot // WithToolConfigRef sets the ToolConfigRef for the proxy func (rb *RemoteProxyBuilder) WithToolConfigRef(name string) *RemoteProxyBuilder { - rb.proxy.Spec.ToolConfigRef = &mcpv1alpha1.ToolConfigRef{ + rb.proxy.Spec.ToolConfigRef = &mcpv1beta1.ToolConfigRef{ Name: name, } return rb @@ -137,7 +137,7 @@ func (rb *RemoteProxyBuilder) WithToolConfigRef(name string) *RemoteProxyBuilder // WithGroupRef sets the GroupRef for the proxy func (rb *RemoteProxyBuilder) WithGroupRef(name string) *RemoteProxyBuilder { - rb.proxy.Spec.GroupRef = &mcpv1alpha1.MCPGroupRef{Name: name} + rb.proxy.Spec.GroupRef = &mcpv1beta1.MCPGroupRef{Name: name} return rb } @@ -149,9 +149,9 @@ func (rb *RemoteProxyBuilder) WithRemoteURL(url string) *RemoteProxyBuilder { // WithInlineAuthzConfig sets an inline authz config with Cedar policies func (rb *RemoteProxyBuilder) WithInlineAuthzConfig(policies []string) *RemoteProxyBuilder { - rb.proxy.Spec.AuthzConfig = &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + rb.proxy.Spec.AuthzConfig = &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: policies, }, } @@ -160,9 +160,9 @@ func (rb *RemoteProxyBuilder) WithInlineAuthzConfig(policies []string) *RemotePr // WithAuthzConfigMapRef sets an authz config referencing a ConfigMap func (rb *RemoteProxyBuilder) WithAuthzConfigMapRef(name, key string) *RemoteProxyBuilder { - rb.proxy.Spec.AuthzConfig = &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + rb.proxy.Spec.AuthzConfig = &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: name, Key: key, }, @@ -174,11 +174,11 @@ func (rb *RemoteProxyBuilder) WithAuthzConfigMapRef(name, key string) *RemotePro func (rb *RemoteProxyBuilder) WithHeaderFromSecret( headerName, secretName, secretKey string, ) *RemoteProxyBuilder { - rb.proxy.Spec.HeaderForward = &mcpv1alpha1.HeaderForwardConfig{ - AddHeadersFromSecret: []mcpv1alpha1.HeaderFromSecret{ + rb.proxy.Spec.HeaderForward = &mcpv1beta1.HeaderForwardConfig{ + AddHeadersFromSecret: []mcpv1beta1.HeaderFromSecret{ { HeaderName: headerName, - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: secretName, Key: secretKey, }, @@ -190,12 +190,12 @@ func (rb *RemoteProxyBuilder) WithHeaderFromSecret( // Build returns a deep copy of the MCPRemoteProxy without creating it in the cluster. // Use this when testing CRD-level validation that rejects the resource at creation time. -func (rb *RemoteProxyBuilder) Build() *mcpv1alpha1.MCPRemoteProxy { +func (rb *RemoteProxyBuilder) Build() *mcpv1beta1.MCPRemoteProxy { return rb.proxy.DeepCopy() } // Create builds and creates the MCPRemoteProxy in the cluster -func (rb *RemoteProxyBuilder) Create(h *MCPRemoteProxyTestHelper) *mcpv1alpha1.MCPRemoteProxy { +func (rb *RemoteProxyBuilder) Create(h *MCPRemoteProxyTestHelper) *mcpv1beta1.MCPRemoteProxy { proxy := rb.proxy.DeepCopy() err := h.Client.Create(h.Context, proxy) gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to create MCPRemoteProxy") @@ -203,8 +203,8 @@ func (rb *RemoteProxyBuilder) Create(h *MCPRemoteProxyTestHelper) *mcpv1alpha1.M } // GetRemoteProxy retrieves an MCPRemoteProxy by name -func (h *MCPRemoteProxyTestHelper) GetRemoteProxy(name string) (*mcpv1alpha1.MCPRemoteProxy, error) { - proxy := &mcpv1alpha1.MCPRemoteProxy{} +func (h *MCPRemoteProxyTestHelper) GetRemoteProxy(name string) (*mcpv1beta1.MCPRemoteProxy, error) { + proxy := &mcpv1beta1.MCPRemoteProxy{} err := h.Client.Get(h.Context, types.NamespacedName{ Namespace: h.Namespace, Name: name, @@ -215,7 +215,7 @@ func (h *MCPRemoteProxyTestHelper) GetRemoteProxy(name string) (*mcpv1alpha1.MCP // GetRemoteProxyStatus returns the current status of an MCPRemoteProxy func (h *MCPRemoteProxyTestHelper) GetRemoteProxyStatus( name string, -) (*mcpv1alpha1.MCPRemoteProxyStatus, error) { +) (*mcpv1beta1.MCPRemoteProxyStatus, error) { proxy, err := h.GetRemoteProxy(name) if err != nil { return nil, err @@ -226,7 +226,7 @@ func (h *MCPRemoteProxyTestHelper) GetRemoteProxyStatus( // GetRemoteProxyPhase returns the current phase of an MCPRemoteProxy func (h *MCPRemoteProxyTestHelper) GetRemoteProxyPhase( name string, -) (mcpv1alpha1.MCPRemoteProxyPhase, error) { +) (mcpv1beta1.MCPRemoteProxyPhase, error) { status, err := h.GetRemoteProxyStatus(name) if err != nil { return "", err @@ -253,7 +253,7 @@ func (h *MCPRemoteProxyTestHelper) GetRemoteProxyCondition( // CleanupRemoteProxies deletes all MCPRemoteProxies in the namespace func (h *MCPRemoteProxyTestHelper) CleanupRemoteProxies() error { - proxyList := &mcpv1alpha1.MCPRemoteProxyList{} + proxyList := &mcpv1beta1.MCPRemoteProxyList{} err := h.Client.List(h.Context, proxyList, client.InNamespace(h.Namespace)) if err != nil { return err diff --git a/cmd/thv-operator/test-integration/mcp-remote-proxy/status_helpers.go b/cmd/thv-operator/test-integration/mcp-remote-proxy/status_helpers.go index d6cc259456..5c7f2adb46 100644 --- a/cmd/thv-operator/test-integration/mcp-remote-proxy/status_helpers.go +++ b/cmd/thv-operator/test-integration/mcp-remote-proxy/status_helpers.go @@ -12,7 +12,7 @@ import ( "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // RemoteProxyStatusTestHelper provides utilities for MCPRemoteProxy status testing and validation @@ -31,14 +31,14 @@ func NewRemoteProxyStatusTestHelper( // WaitForPhaseAny waits for an MCPRemoteProxy to reach any of the specified phases func (h *RemoteProxyStatusTestHelper) WaitForPhaseAny( - proxyName string, expectedPhases []mcpv1alpha1.MCPRemoteProxyPhase, timeout time.Duration, + proxyName string, expectedPhases []mcpv1beta1.MCPRemoteProxyPhase, timeout time.Duration, ) { ginkgo.By(fmt.Sprintf("waiting for remote proxy %s to reach one of phases %v", proxyName, expectedPhases)) - gomega.Eventually(func() mcpv1alpha1.MCPRemoteProxyPhase { + gomega.Eventually(func() mcpv1beta1.MCPRemoteProxyPhase { proxy, err := h.proxyHelper.GetRemoteProxy(proxyName) if err != nil { if errors.IsNotFound(err) { - return mcpv1alpha1.MCPRemoteProxyPhaseTerminating + return mcpv1beta1.MCPRemoteProxyPhaseTerminating } return "" } @@ -61,9 +61,9 @@ func (h *RemoteProxyStatusTestHelper) WaitForURL(proxyName string, timeout time. // WaitForPhase waits for an MCPRemoteProxy to reach the specified phase func (h *RemoteProxyStatusTestHelper) WaitForPhase( - proxyName string, expectedPhase mcpv1alpha1.MCPRemoteProxyPhase, timeout time.Duration, + proxyName string, expectedPhase mcpv1beta1.MCPRemoteProxyPhase, timeout time.Duration, ) { - gomega.Eventually(func() mcpv1alpha1.MCPRemoteProxyPhase { + gomega.Eventually(func() mcpv1beta1.MCPRemoteProxyPhase { proxy, err := h.proxyHelper.GetRemoteProxy(proxyName) if err != nil { return "" diff --git a/cmd/thv-operator/test-integration/mcp-remote-proxy/suite_test.go b/cmd/thv-operator/test-integration/mcp-remote-proxy/suite_test.go index 3502c9dbd5..fc3387ed81 100644 --- a/cmd/thv-operator/test-integration/mcp-remote-proxy/suite_test.go +++ b/cmd/thv-operator/test-integration/mcp-remote-proxy/suite_test.go @@ -25,7 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/controllers" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -74,7 +74,7 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) - err = mcpv1alpha1.AddToScheme(scheme.Scheme) + err = mcpv1beta1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) // Add other schemes that the controllers use @@ -104,8 +104,8 @@ var _ = BeforeSuite(func() { Expect(err).ToNot(HaveOccurred()) // Set up field indexing for MCPServer.Spec.GroupRef (required by MCPGroup controller) - if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) name := mcpServer.Spec.GroupRef.GetName() if name == "" { return nil @@ -116,8 +116,8 @@ var _ = BeforeSuite(func() { } // Set up field indexing for MCPRemoteProxy.Spec.GroupRef - if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) name := mcpRemoteProxy.Spec.GroupRef.GetName() if name == "" { return nil @@ -130,10 +130,10 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPServerEntry.Spec.GroupRef err = k8sManager.GetFieldIndexer().IndexField( context.Background(), - &mcpv1alpha1.MCPServerEntry{}, + &mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) name := mcpServerEntry.Spec.GroupRef.GetName() if name == "" { return nil diff --git a/cmd/thv-operator/test-integration/mcp-server/mcpserver_authserverref_integration_test.go b/cmd/thv-operator/test-integration/mcp-server/mcpserver_authserverref_integration_test.go index 2d2fd510ed..4fe4672ee1 100644 --- a/cmd/thv-operator/test-integration/mcp-server/mcpserver_authserverref_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/mcpserver_authserverref_integration_test.go @@ -14,7 +14,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { @@ -37,11 +37,11 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { _ = k8sClient.Create(ctx, ns) By("creating MCPOIDCConfig") - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: oidcConfigName, Namespace: namespace}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "http://localhost:9090", }, }, @@ -49,17 +49,17 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { Expect(k8sClient.Create(ctx, oidcConfig)).To(Succeed()) By("creating MCPExternalAuthConfig with embeddedAuthServer type") - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: authConfigName, Namespace: namespace}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "http://localhost:9090", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "test-provider", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://accounts.google.com", ClientID: "test-client-id", }, @@ -71,16 +71,16 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { Expect(k8sClient.Create(ctx, authConfig)).To(Succeed()) By("creating MCPServer with authServerRef") - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: serverName, Namespace: namespace}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:v1.0.0", Transport: "streamable-http", - AuthServerRef: &mcpv1alpha1.AuthServerRef{ + AuthServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: authConfigName, }, - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: oidcConfigName, Audience: "https://test-resource.example.com", ResourceURL: "https://test-resource.example.com", @@ -91,27 +91,27 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { }) AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: serverName, Namespace: namespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: authConfigName, Namespace: namespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPOIDCConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: oidcConfigName, Namespace: namespace}, }) }) It("should set AuthServerRefValidated condition to True", func() { Eventually(func() metav1.ConditionStatus { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: serverName, Namespace: namespace, }, server); err != nil { return metav1.ConditionUnknown } cond := meta.FindStatusCondition(server.Status.Conditions, - mcpv1alpha1.ConditionTypeAuthServerRefValidated) + mcpv1beta1.ConditionTypeAuthServerRefValidated) if cond == nil { return metav1.ConditionUnknown } @@ -149,11 +149,11 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { _ = k8sClient.Create(ctx, ns) By("creating MCPOIDCConfig") - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: oidcConfigName, Namespace: namespace}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "http://localhost:9090", }, }, @@ -162,17 +162,17 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { By("creating two MCPExternalAuthConfig resources with embeddedAuthServer type") for _, name := range []string{authConfigName, authConfigConflict} { - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "http://localhost:9090", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "test-provider", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://accounts.google.com", ClientID: "test-client-id", }, @@ -185,19 +185,19 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { } By("creating MCPServer with both authServerRef and externalAuthConfigRef pointing to embeddedAuthServer") - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: serverName, Namespace: namespace}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:v1.0.0", Transport: "streamable-http", - AuthServerRef: &mcpv1alpha1.AuthServerRef{ + AuthServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: authConfigName, }, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: authConfigConflict, }, - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: oidcConfigName, Audience: "https://test-resource.example.com", ResourceURL: "https://test-resource.example.com", @@ -208,34 +208,34 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { }) AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: serverName, Namespace: namespace}, }) for _, name := range []string{authConfigName, authConfigConflict} { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, }) } - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPOIDCConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: oidcConfigName, Namespace: namespace}, }) }) It("should reach Failed phase", func() { - Eventually(func() mcpv1alpha1.MCPServerPhase { - server := &mcpv1alpha1.MCPServer{} + Eventually(func() mcpv1beta1.MCPServerPhase { + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: serverName, Namespace: namespace, }, server); err != nil { return "" } return server.Status.Phase - }, timeout, interval).Should(Equal(mcpv1alpha1.MCPServerPhaseFailed)) + }, timeout, interval).Should(Equal(mcpv1beta1.MCPServerPhaseFailed)) }) It("should report conflict error in Status.Message", func() { Eventually(func() string { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: serverName, Namespace: namespace, }, server); err != nil { @@ -259,21 +259,21 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { _ = k8sClient.Create(ctx, ns) By("creating MCPExternalAuthConfig with unauthenticated type") - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: authConfigName, Namespace: namespace}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUnauthenticated, }, } Expect(k8sClient.Create(ctx, authConfig)).To(Succeed()) By("creating MCPServer with authServerRef to unauthenticated config") - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: serverName, Namespace: namespace}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:v1.0.0", Transport: "streamable-http", - AuthServerRef: &mcpv1alpha1.AuthServerRef{ + AuthServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: authConfigName, }, @@ -283,36 +283,36 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { }) AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: serverName, Namespace: namespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: authConfigName, Namespace: namespace}, }) }) It("should reach Failed phase", func() { - Eventually(func() mcpv1alpha1.MCPServerPhase { - server := &mcpv1alpha1.MCPServer{} + Eventually(func() mcpv1beta1.MCPServerPhase { + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: serverName, Namespace: namespace, }, server); err != nil { return "" } return server.Status.Phase - }, timeout, interval).Should(Equal(mcpv1alpha1.MCPServerPhaseFailed)) + }, timeout, interval).Should(Equal(mcpv1beta1.MCPServerPhaseFailed)) }) It("should set AuthServerRefValidated condition to False with type mismatch message", func() { Eventually(func() string { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: serverName, Namespace: namespace, }, server); err != nil { return "" } cond := meta.FindStatusCondition(server.Status.Conditions, - mcpv1alpha1.ConditionTypeAuthServerRefValidated) + mcpv1beta1.ConditionTypeAuthServerRefValidated) if cond == nil || cond.Status != metav1.ConditionFalse { return "" } @@ -335,11 +335,11 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { _ = k8sClient.Create(ctx, ns) By("creating MCPOIDCConfig") - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: oidcConfigName, Namespace: namespace}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "http://localhost:9090", }, }, @@ -347,17 +347,17 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { Expect(k8sClient.Create(ctx, oidcConfig)).To(Succeed()) By("creating MCPExternalAuthConfig with embeddedAuthServer type") - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: authConfigName, Namespace: namespace}, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &mcpv1alpha1.EmbeddedAuthServerConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "http://localhost:9090", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "test-provider", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://accounts.google.com", ClientID: "test-client-id", }, @@ -369,15 +369,15 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { Expect(k8sClient.Create(ctx, authConfig)).To(Succeed()) By("creating MCPServer with only externalAuthConfigRef (no authServerRef)") - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: serverName, Namespace: namespace}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:v1.0.0", Transport: "streamable-http", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: authConfigName, }, - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: oidcConfigName, Audience: "https://test-resource.example.com", ResourceURL: "https://test-resource.example.com", @@ -388,13 +388,13 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { }) AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: serverName, Namespace: namespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: authConfigName, Namespace: namespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPOIDCConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: oidcConfigName, Namespace: namespace}, }) }) @@ -417,11 +417,11 @@ var _ = Describe("MCPServer AuthServerRef Integration Tests", func() { It("should not be in Failed phase", func() { // The prior It already synchronized on ConfigMap creation, // so reconciliation has completed. A point-in-time check suffices. - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: serverName, Namespace: namespace, }, server)).To(Succeed()) - Expect(server.Status.Phase).NotTo(Equal(mcpv1alpha1.MCPServerPhaseFailed)) + Expect(server.Status.Phase).NotTo(Equal(mcpv1beta1.MCPServerPhaseFailed)) }) }) }) diff --git a/cmd/thv-operator/test-integration/mcp-server/mcpserver_cel_validation_integration_test.go b/cmd/thv-operator/test-integration/mcp-server/mcpserver_cel_validation_integration_test.go index fa06717ac5..e1d648b108 100644 --- a/cmd/thv-operator/test-integration/mcp-server/mcpserver_cel_validation_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/mcpserver_cel_validation_integration_test.go @@ -8,18 +8,18 @@ import ( . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // newMinimalMCPServer creates a minimal MCPServer with the given name and optional // AuthzConfigRef for CEL validation testing. -func newMinimalMCPServer(name string, authz *mcpv1alpha1.AuthzConfigRef) *mcpv1alpha1.MCPServer { - return &mcpv1alpha1.MCPServer{ +func newMinimalMCPServer(name string, authz *mcpv1beta1.AuthzConfigRef) *mcpv1beta1.MCPServer { + return &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", AuthzConfig: authz, }, @@ -30,7 +30,7 @@ var _ = Describe("CEL Validation for AuthzConfigRef", Label("k8s", "cel", "valid Context("AuthzConfigRef CEL validation", func() { Context("type=configMap", func() { It("should reject when configMap field is missing", func() { - server := newMinimalMCPServer("authz-cm-missing", &mcpv1alpha1.AuthzConfigRef{ + server := newMinimalMCPServer("authz-cm-missing", &mcpv1beta1.AuthzConfigRef{ Type: "configMap", }) err := k8sClient.Create(ctx, server) @@ -39,12 +39,12 @@ var _ = Describe("CEL Validation for AuthzConfigRef", Label("k8s", "cel", "valid }) It("should reject when inline field is also set", func() { - server := newMinimalMCPServer("authz-cm-with-inline", &mcpv1alpha1.AuthzConfigRef{ + server := newMinimalMCPServer("authz-cm-with-inline", &mcpv1beta1.AuthzConfigRef{ Type: "configMap", - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "test-cm", }, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{"permit(principal, action, resource);"}, }, }) @@ -54,9 +54,9 @@ var _ = Describe("CEL Validation for AuthzConfigRef", Label("k8s", "cel", "valid }) It("should accept when only configMap field is set", func() { - server := newMinimalMCPServer("authz-cm-valid", &mcpv1alpha1.AuthzConfigRef{ + server := newMinimalMCPServer("authz-cm-valid", &mcpv1beta1.AuthzConfigRef{ Type: "configMap", - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "test-cm", }, }) @@ -67,7 +67,7 @@ var _ = Describe("CEL Validation for AuthzConfigRef", Label("k8s", "cel", "valid Context("type=inline", func() { It("should reject when inline field is missing", func() { - server := newMinimalMCPServer("authz-inline-missing", &mcpv1alpha1.AuthzConfigRef{ + server := newMinimalMCPServer("authz-inline-missing", &mcpv1beta1.AuthzConfigRef{ Type: "inline", }) err := k8sClient.Create(ctx, server) @@ -76,12 +76,12 @@ var _ = Describe("CEL Validation for AuthzConfigRef", Label("k8s", "cel", "valid }) It("should reject when configMap field is also set", func() { - server := newMinimalMCPServer("authz-inline-with-cm", &mcpv1alpha1.AuthzConfigRef{ + server := newMinimalMCPServer("authz-inline-with-cm", &mcpv1beta1.AuthzConfigRef{ Type: "inline", - Inline: &mcpv1alpha1.InlineAuthzConfig{ + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{"permit(principal, action, resource);"}, }, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "test-cm", }, }) @@ -91,9 +91,9 @@ var _ = Describe("CEL Validation for AuthzConfigRef", Label("k8s", "cel", "valid }) It("should accept when only inline field is set", func() { - server := newMinimalMCPServer("authz-inline-valid", &mcpv1alpha1.AuthzConfigRef{ + server := newMinimalMCPServer("authz-inline-valid", &mcpv1beta1.AuthzConfigRef{ Type: "inline", - Inline: &mcpv1alpha1.InlineAuthzConfig{ + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{"permit(principal, action, resource);"}, }, }) @@ -105,9 +105,9 @@ var _ = Describe("CEL Validation for AuthzConfigRef", Label("k8s", "cel", "valid Context("AuthzConfigRef multi-violation CEL validation", func() { It("should report both missing-configMap and extra-inline when type=configMap but only inline is set", func() { - server := newMinimalMCPServer("authz-cm-only-inline", &mcpv1alpha1.AuthzConfigRef{ + server := newMinimalMCPServer("authz-cm-only-inline", &mcpv1beta1.AuthzConfigRef{ Type: "configMap", - Inline: &mcpv1alpha1.InlineAuthzConfig{ + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{"permit(principal, action, resource);"}, }, }) diff --git a/cmd/thv-operator/test-integration/mcp-server/mcpserver_controller_integration_test.go b/cmd/thv-operator/test-integration/mcp-server/mcpserver_controller_integration_test.go index f40d642ea7..b2a27d2b88 100644 --- a/cmd/thv-operator/test-integration/mcp-server/mcpserver_controller_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/mcpserver_controller_integration_test.go @@ -23,7 +23,7 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/utils/ptr" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) var _ = Describe("MCPServer Controller Integration Tests", func() { @@ -40,8 +40,8 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { var ( namespace string mcpServerName string - mcpServer *mcpv1alpha1.MCPServer - createdMCPServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer + createdMCPServer *mcpv1beta1.MCPServer ) BeforeAll(func() { @@ -57,37 +57,37 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { _ = k8sClient.Create(ctx, ns) // Define the MCPServer resource - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", Transport: "stdio", ProxyMode: "sse", ProxyPort: 8080, MCPPort: 8080, Args: []string{"--verbose"}, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ { Name: "DEBUG", Value: "true", }, }, - Resources: mcpv1alpha1.ResourceRequirements{ - Limits: mcpv1alpha1.ResourceList{ + Resources: mcpv1beta1.ResourceRequirements{ + Limits: mcpv1beta1.ResourceList{ CPU: "500m", Memory: "1Gi", }, - Requests: mcpv1alpha1.ResourceList{ + Requests: mcpv1beta1.ResourceList{ CPU: "100m", Memory: "128Mi", }, }, - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - PodTemplateMetadataOverrides: &mcpv1alpha1.ResourceMetadataOverrides{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + PodTemplateMetadataOverrides: &mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "podspec-testlabel": "true", }, @@ -100,7 +100,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { // Create the MCPServer Expect(k8sClient.Create(ctx, mcpServer)).Should(Succeed()) - createdMCPServer = &mcpv1alpha1.MCPServer{} + createdMCPServer = &mcpv1beta1.MCPServer{} k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -325,7 +325,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { It("Should set ObservedGeneration in status after reconciliation", func() { Eventually(func() int64 { - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -338,7 +338,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { It("Should set the Ready condition", func() { Eventually(func() bool { - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -348,7 +348,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { } for _, cond := range updatedMCPServer.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeReady { + if cond.Type == mcpv1beta1.ConditionTypeReady { // In envtest, pods don't actually run, so the condition // will be set (True if phase=Running, False if Pending) return true @@ -414,7 +414,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { var ( namespace string mcpServerName string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer ) BeforeAll(func() { @@ -430,12 +430,12 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { _ = k8sClient.Create(ctx, ns) // Define the MCPServer resource with invalid PodTemplateSpec - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/stackloklabs/mcp-fetch:latest", Transport: "stdio", ProxyPort: 8080, @@ -458,7 +458,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { It("Should set PodTemplateValid condition to False", func() { // Wait for the status to be updated with the invalid condition Eventually(func() bool { - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -478,7 +478,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { }, timeout, interval).Should(BeTrue()) // Verify the condition message contains expected text - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -510,7 +510,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { }) It("Should have Failed phase in status", func() { - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} Eventually(func() bool { err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, @@ -519,7 +519,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { if err != nil { return false } - return updatedMCPServer.Status.Phase == mcpv1alpha1.MCPServerPhaseFailed + return updatedMCPServer.Status.Phase == mcpv1beta1.MCPServerPhaseFailed }, timeout, interval).Should(BeTrue()) Expect(updatedMCPServer.Status.Message).To(ContainSubstring("Invalid PodTemplateSpec")) @@ -527,7 +527,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { It("Should set Ready condition to False for invalid PodTemplateSpec", func() { Eventually(func() bool { - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -537,9 +537,9 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { } for _, cond := range updatedMCPServer.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeReady { + if cond.Type == mcpv1beta1.ConditionTypeReady { return cond.Status == metav1.ConditionFalse && - cond.Reason == mcpv1alpha1.ConditionReasonNotReady + cond.Reason == mcpv1beta1.ConditionReasonNotReady } } return false @@ -551,8 +551,8 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { var ( namespace string mcpServerName string - mcpServer *mcpv1alpha1.MCPServer - createdMCPServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer + createdMCPServer *mcpv1beta1.MCPServer ) BeforeAll(func() { @@ -568,12 +568,12 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { _ = k8sClient.Create(ctx, ns) // Define the MCPServer resource with PodTemplateSpec resource limits - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/stackloklabs/mcp-fetch:latest", Transport: "stdio", ProxyPort: 8080, @@ -586,7 +586,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { // Create the MCPServer Expect(k8sClient.Create(ctx, mcpServer)).Should(Succeed()) - createdMCPServer = &mcpv1alpha1.MCPServer{} + createdMCPServer = &mcpv1beta1.MCPServer{} k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -652,7 +652,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { It("Should have PodTemplateValid condition set to True", func() { Eventually(func() bool { - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -675,8 +675,8 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { var ( namespace string mcpServerName string - mcpServer *mcpv1alpha1.MCPServer - createdMCPServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer + createdMCPServer *mcpv1beta1.MCPServer ) BeforeAll(func() { @@ -692,12 +692,12 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { _ = k8sClient.Create(ctx, ns) // Define the MCPServer resource with PodTemplateSpec securityContext - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/stackloklabs/mcp-fetch:latest", Transport: "stdio", ProxyPort: 8080, @@ -710,7 +710,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { // Create the MCPServer Expect(k8sClient.Create(ctx, mcpServer)).Should(Succeed()) - createdMCPServer = &mcpv1alpha1.MCPServer{} + createdMCPServer = &mcpv1beta1.MCPServer{} k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -764,7 +764,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { It("Should have PodTemplateValid condition set to True", func() { Eventually(func() bool { - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -787,7 +787,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { var ( namespace string mcpServerName string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer ) BeforeAll(func() { @@ -803,12 +803,12 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { _ = k8sClient.Create(ctx, ns) // Define the MCPServer resource WITHOUT PodTemplateSpec initially - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/stackloklabs/mcp-fetch:latest", Transport: "stdio", ProxyPort: 8080, @@ -908,7 +908,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { var ( namespace string mcpServerName string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer ) BeforeAll(func() { @@ -924,12 +924,12 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { _ = k8sClient.Create(ctx, ns) // Define the MCPServer resource with a simple valid PodTemplateSpec - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/stackloklabs/mcp-fetch:latest", Transport: "stdio", ProxyPort: 8080, @@ -950,7 +950,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { It("Should set PodTemplateValid condition to True with reason ValidPodTemplateSpec", func() { Eventually(func() bool { - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -969,7 +969,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { }, timeout, interval).Should(BeTrue()) // Verify the condition details - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -993,7 +993,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { var ( namespace string mcpServerName string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer ) BeforeAll(func() { @@ -1009,16 +1009,16 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { _ = k8sClient.Create(ctx, ns) // Define the MCPServer resource with invalid GroupRef - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/stackloklabs/mcp-fetch:latest", Transport: "stdio", ProxyPort: 8080, - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "non-existent-group"}, // This group doesn't exist + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "non-existent-group"}, // This group doesn't exist }, } @@ -1034,7 +1034,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { It("Should set GroupRefValidated condition to False with reason GroupRefNotFound", func() { // Wait for the status to be updated with the invalid condition Eventually(func() bool { - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -1054,7 +1054,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { }, timeout, interval).Should(BeTrue()) // Verify the condition message contains expected text - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -1090,7 +1090,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { // GroupRef validation doesn't block deployment creation, // so the Ready condition should eventually be set based on pod status Eventually(func() bool { - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -1100,7 +1100,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { } for _, cond := range updatedMCPServer.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeReady { + if cond.Type == mcpv1beta1.ConditionTypeReady { return true // Condition exists, regardless of status } } @@ -1114,8 +1114,8 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { namespace string mcpServerName string mcpGroupName string - mcpServer *mcpv1alpha1.MCPServer - mcpGroup *mcpv1alpha1.MCPGroup + mcpServer *mcpv1beta1.MCPServer + mcpGroup *mcpv1beta1.MCPGroup ) BeforeAll(func() { @@ -1132,12 +1132,12 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { _ = k8sClient.Create(ctx, ns) // Create MCPGroup first - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "A test group for integration testing", }, } @@ -1145,25 +1145,25 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { // Wait for the group to be created and ready Eventually(func() bool { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, }, updatedGroup) - return err == nil && updatedGroup.Status.Phase == mcpv1alpha1.MCPGroupPhaseReady + return err == nil && updatedGroup.Status.Phase == mcpv1beta1.MCPGroupPhaseReady }, timeout, interval).Should(BeTrue()) // Define the MCPServer resource with valid GroupRef - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "ghcr.io/stackloklabs/mcp-fetch:latest", Transport: "stdio", ProxyPort: 8080, - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, // This group exists + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, // This group exists }, } @@ -1181,7 +1181,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { It("Should set GroupRefValidated condition to True with reason GroupRefIsValid", func() { // Wait for the status to be updated with the valid condition Eventually(func() bool { - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -1201,7 +1201,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { }, timeout, interval).Should(BeTrue()) // Verify the condition message contains expected text - updatedMCPServer := &mcpv1alpha1.MCPServer{} + updatedMCPServer := &mcpv1beta1.MCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -1222,7 +1222,7 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { It("Should update MCPGroup with server reference", func() { // Wait for the MCPGroup to be updated with the server reference Eventually(func() bool { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, @@ -1243,11 +1243,11 @@ var _ = Describe("MCPServer Controller Integration Tests", func() { }) }) -func verifyOwnerReference(ownerRefs []metav1.OwnerReference, mcpServer *mcpv1alpha1.MCPServer, resourceType string) { +func verifyOwnerReference(ownerRefs []metav1.OwnerReference, mcpServer *mcpv1beta1.MCPServer, resourceType string) { ExpectWithOffset(1, ownerRefs).To(HaveLen(1), fmt.Sprintf("%s should have exactly one owner reference", resourceType)) ownerRef := ownerRefs[0] - ExpectWithOffset(1, ownerRef.APIVersion).To(Equal("toolhive.stacklok.dev/v1alpha1")) + ExpectWithOffset(1, ownerRef.APIVersion).To(Equal("toolhive.stacklok.dev/v1beta1")) ExpectWithOffset(1, ownerRef.Kind).To(Equal("MCPServer")) ExpectWithOffset(1, ownerRef.Name).To(Equal(mcpServer.Name)) ExpectWithOffset(1, ownerRef.UID).To(Equal(mcpServer.UID)) diff --git a/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go b/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go index 2dbbe31388..a2b42f18db 100644 --- a/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/mcpserver_runconfig_integration_test.go @@ -16,7 +16,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" "github.com/stacklok/toolhive/pkg/authz" "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" @@ -34,8 +34,8 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { var ( namespace string mcpServerName string - mcpServer *mcpv1alpha1.MCPServer - createdMCPServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer + createdMCPServer *mcpv1beta1.MCPServer configMapName string ) @@ -53,19 +53,19 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { _ = k8sClient.Create(ctx, ns) // Define the MCPServer resource with comprehensive configuration - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:v1.0.0", Transport: "stdio", ProxyMode: "sse", ProxyPort: 8080, MCPPort: 8081, Args: []string{"--verbose", "--debug"}, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ { Name: "DEBUG", Value: "true", @@ -75,7 +75,7 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { Value: "debug", }, }, - Volumes: []mcpv1alpha1.Volume{ + Volumes: []mcpv1beta1.Volume{ { Name: "config", HostPath: "/host/config", @@ -83,12 +83,12 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { ReadOnly: true, }, }, - Resources: mcpv1alpha1.ResourceRequirements{ - Limits: mcpv1alpha1.ResourceList{ + Resources: mcpv1beta1.ResourceRequirements{ + Limits: mcpv1beta1.ResourceList{ CPU: "500m", Memory: "1Gi", }, - Requests: mcpv1alpha1.ResourceList{ + Requests: mcpv1beta1.ResourceList{ CPU: "100m", Memory: "128Mi", }, @@ -99,7 +99,7 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { // Create the MCPServer Expect(k8sClient.Create(ctx, mcpServer)).Should(Succeed()) - createdMCPServer = &mcpv1alpha1.MCPServer{} + createdMCPServer = &mcpv1beta1.MCPServer{} k8sClient.Get(ctx, types.NamespacedName{ Name: mcpServerName, Namespace: namespace, @@ -300,7 +300,7 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { // Update multiple fields mcpServer.Spec.Image = "example/mcp-server:v2.0.0" mcpServer.Spec.ProxyPort = 9090 - mcpServer.Spec.Env = append(mcpServer.Spec.Env, mcpv1alpha1.EnvVar{ + mcpServer.Spec.Env = append(mcpServer.Spec.Env, mcpv1beta1.EnvVar{ Name: "NEW_VAR", Value: "new_value", }) @@ -366,18 +366,18 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { _ = k8sClient.Create(ctx, ns) backendReplicas := int32(3) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", Transport: "stdio", ProxyPort: 8080, BackendReplicas: &backendReplicas, - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379", DB: 1, KeyPrefix: "thv:", @@ -420,12 +420,12 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { } _ = k8sClient.Create(ctx, ns) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", Transport: "stdio", ProxyPort: 8080, @@ -467,27 +467,27 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { _ = k8sClient.Create(ctx, ns) // Create the MCPTelemetryConfig resource - telCfg := &mcpv1alpha1.MCPTelemetryConfig{ + telCfg := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "shared-otel-config", Namespace: namespace, }, } - telCfg.Spec.OpenTelemetry = &mcpv1alpha1.MCPTelemetryOTelConfig{ + telCfg.Spec.OpenTelemetry = &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "otel-collector:4317", Insecure: true, - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true, SamplingRate: "0.1"}, - Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true, SamplingRate: "0.1"}, + Metrics: &mcpv1beta1.OpenTelemetryMetricsConfig{Enabled: true}, } - telCfg.Spec.Prometheus = &mcpv1alpha1.PrometheusConfig{Enabled: true} + telCfg.Spec.Prometheus = &mcpv1beta1.PrometheusConfig{Enabled: true} Expect(k8sClient.Create(ctx, telCfg)).To(Succeed()) defer k8sClient.Delete(ctx, telCfg) //nolint:errcheck // Wait for the MCPTelemetryConfig to be reconciled (hash set) Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telCfg.Name, Namespace: telCfg.Namespace, @@ -496,16 +496,16 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { }, timeout, interval).Should(BeTrue()) // Create MCPServer with telemetryConfigRef - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "telemetry/mcp-server:latest", Transport: "stdio", ProxyPort: 8080, - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "shared-otel-config", ServiceName: "test-service", }, @@ -551,23 +551,23 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { } _ = k8sClient.Create(ctx, ns) - telCfg := &mcpv1alpha1.MCPTelemetryConfig{ + telCfg := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "no-svcname-config", Namespace: namespace, }, } - telCfg.Spec.OpenTelemetry = &mcpv1alpha1.MCPTelemetryOTelConfig{ + telCfg.Spec.OpenTelemetry = &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "otel-collector:4317", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, } Expect(k8sClient.Create(ctx, telCfg)).To(Succeed()) defer k8sClient.Delete(ctx, telCfg) //nolint:errcheck Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telCfg.Name, Namespace: telCfg.Namespace, @@ -575,16 +575,16 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { return err == nil && fetched.Status.ConfigHash != "" }, timeout, interval).Should(BeTrue()) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "telemetry/mcp-server:latest", Transport: "stdio", ProxyPort: 8080, - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "no-svcname-config", // ServiceName intentionally omitted — should default to server name }, @@ -625,18 +625,18 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { _ = k8sClient.Create(ctx, ns) // Create MCPServer with inline authorization - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "authz/mcp-server:latest", Transport: "stdio", ProxyPort: 8080, - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{ `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, `permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");`, @@ -691,23 +691,23 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { _ = k8sClient.Create(ctx, ns) // Create MCPServer with comprehensive configuration - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "deterministic/mcp-server:v1.0.0", Transport: "sse", ProxyPort: 9090, MCPPort: 8080, Args: []string{"--arg1", "--arg2", "--arg3"}, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "VAR_C", Value: "value_c"}, {Name: "VAR_A", Value: "value_a"}, {Name: "VAR_B", Value: "value_b"}, }, - Volumes: []mcpv1alpha1.Volume{ + Volumes: []mcpv1beta1.Volume{ {Name: "vol2", HostPath: "/host2", MountPath: "/mount2", ReadOnly: true}, {Name: "vol1", HostPath: "/host1", MountPath: "/mount1", ReadOnly: false}, }, @@ -824,18 +824,18 @@ var _ = Describe("RunConfig ConfigMap Integration Tests", func() { defer k8sClient.Delete(ctx, authzConfigMap) // Create MCPServer with ConfigMap authorization reference - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "authz/mcp-server:latest", Transport: "stdio", ProxyPort: 8080, - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: externalAuthzConfigMapName, Key: "authz.json", }, diff --git a/cmd/thv-operator/test-integration/mcp-server/mcpserver_sessionstorage_cel_test.go b/cmd/thv-operator/test-integration/mcp-server/mcpserver_sessionstorage_cel_test.go index ada7c8a346..508af923f1 100644 --- a/cmd/thv-operator/test-integration/mcp-server/mcpserver_sessionstorage_cel_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/mcpserver_sessionstorage_cel_test.go @@ -8,16 +8,16 @@ import ( . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) -func newMCPServerWithSessionStorage(name string, ss *mcpv1alpha1.SessionStorageConfig) *mcpv1alpha1.MCPServer { - return &mcpv1alpha1.MCPServer{ +func newMCPServerWithSessionStorage(name string, ss *mcpv1beta1.SessionStorageConfig) *mcpv1beta1.MCPServer { + return &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", SessionStorage: ss, }, @@ -28,7 +28,7 @@ var _ = Describe("CEL Validation for SessionStorageConfig on MCPServer", Label("k8s", "cel", "validation"), func() { Context("provider=redis", func() { It("should reject when address is missing", func() { - server := newMCPServerWithSessionStorage("mcp-redis-no-addr", &mcpv1alpha1.SessionStorageConfig{ + server := newMCPServerWithSessionStorage("mcp-redis-no-addr", &mcpv1beta1.SessionStorageConfig{ Provider: "redis", }) err := k8sClient.Create(ctx, server) @@ -37,7 +37,7 @@ var _ = Describe("CEL Validation for SessionStorageConfig on MCPServer", }) It("should reject when address is empty string", func() { - server := newMCPServerWithSessionStorage("mcp-redis-empty-addr", &mcpv1alpha1.SessionStorageConfig{ + server := newMCPServerWithSessionStorage("mcp-redis-empty-addr", &mcpv1beta1.SessionStorageConfig{ Provider: "redis", Address: "", }) @@ -46,7 +46,7 @@ var _ = Describe("CEL Validation for SessionStorageConfig on MCPServer", }) It("should accept when address is set", func() { - server := newMCPServerWithSessionStorage("mcp-redis-with-addr", &mcpv1alpha1.SessionStorageConfig{ + server := newMCPServerWithSessionStorage("mcp-redis-with-addr", &mcpv1beta1.SessionStorageConfig{ Provider: "redis", Address: "redis:6379", }) @@ -55,7 +55,7 @@ var _ = Describe("CEL Validation for SessionStorageConfig on MCPServer", }) It("should accept with all fields set", func() { - server := newMCPServerWithSessionStorage("mcp-redis-full", &mcpv1alpha1.SessionStorageConfig{ + server := newMCPServerWithSessionStorage("mcp-redis-full", &mcpv1beta1.SessionStorageConfig{ Provider: "redis", Address: "redis:6379", DB: 1, @@ -66,7 +66,7 @@ var _ = Describe("CEL Validation for SessionStorageConfig on MCPServer", }) It("should reject negative DB number", func() { - server := newMCPServerWithSessionStorage("mcp-redis-neg-db", &mcpv1alpha1.SessionStorageConfig{ + server := newMCPServerWithSessionStorage("mcp-redis-neg-db", &mcpv1beta1.SessionStorageConfig{ Provider: "redis", Address: "redis:6379", DB: -1, @@ -78,7 +78,7 @@ var _ = Describe("CEL Validation for SessionStorageConfig on MCPServer", Context("provider=memory", func() { It("should accept without address", func() { - server := newMCPServerWithSessionStorage("mcp-memory-no-addr", &mcpv1alpha1.SessionStorageConfig{ + server := newMCPServerWithSessionStorage("mcp-memory-no-addr", &mcpv1beta1.SessionStorageConfig{ Provider: "memory", }) err := k8sClient.Create(ctx, server) diff --git a/cmd/thv-operator/test-integration/mcp-server/suite_test.go b/cmd/thv-operator/test-integration/mcp-server/suite_test.go index 1ef7f960e4..3d8920c769 100644 --- a/cmd/thv-operator/test-integration/mcp-server/suite_test.go +++ b/cmd/thv-operator/test-integration/mcp-server/suite_test.go @@ -25,7 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/controllers" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -74,7 +74,7 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) - err = mcpv1alpha1.AddToScheme(scheme.Scheme) + err = mcpv1beta1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) // Add other schemes that the controllers use @@ -104,8 +104,8 @@ var _ = BeforeSuite(func() { Expect(err).ToNot(HaveOccurred()) // Set up field indexing for MCPServer.Spec.GroupRef - if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) name := mcpServer.Spec.GroupRef.GetName() if name == "" { return nil @@ -116,8 +116,8 @@ var _ = BeforeSuite(func() { } // Set up field indexing for MCPRemoteProxy.Spec.GroupRef - if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) name := mcpRemoteProxy.Spec.GroupRef.GetName() if name == "" { return nil @@ -130,10 +130,10 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPServerEntry.Spec.GroupRef err = k8sManager.GetFieldIndexer().IndexField( context.Background(), - &mcpv1alpha1.MCPServerEntry{}, + &mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) name := mcpServerEntry.Spec.GroupRef.GetName() if name == "" { return nil diff --git a/cmd/thv-operator/test-integration/mcp-telemetry-config/mcptelemetryconfig_controller_integration_test.go b/cmd/thv-operator/test-integration/mcp-telemetry-config/mcptelemetryconfig_controller_integration_test.go index 60bad92625..06ab6b6a5c 100644 --- a/cmd/thv-operator/test-integration/mcp-telemetry-config/mcptelemetryconfig_controller_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-telemetry-config/mcptelemetryconfig_controller_integration_test.go @@ -11,7 +11,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) const ( @@ -23,24 +23,24 @@ const ( var _ = Describe("MCPTelemetryConfig Controller", func() { It("should set Valid condition and config hash on creation", func() { - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-telemetry-creation", Namespace: "default", }, } - telemetryConfig.Spec.OpenTelemetry = &mcpv1alpha1.MCPTelemetryOTelConfig{ + telemetryConfig.Spec.OpenTelemetry = &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: testEndpoint, - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, - Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, + Metrics: &mcpv1beta1.OpenTelemetryMetricsConfig{Enabled: true}, } Expect(k8sClient.Create(ctx, telemetryConfig)).To(Succeed()) // Verify config hash is set Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -53,7 +53,7 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { // Verify Valid condition is set to True Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -71,16 +71,16 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { }) It("should update config hash when spec changes", func() { - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-telemetry-hash-change", Namespace: "default", }, } - telemetryConfig.Spec.OpenTelemetry = &mcpv1alpha1.MCPTelemetryOTelConfig{ + telemetryConfig.Spec.OpenTelemetry = &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: testEndpoint, - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, } Expect(k8sClient.Create(ctx, telemetryConfig)).To(Succeed()) @@ -88,7 +88,7 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { // Wait for initial hash var firstHash string Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -101,7 +101,7 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { }, timeout, interval).Should(BeTrue()) // Update the spec - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -112,7 +112,7 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { // Verify hash changed Eventually(func() bool { - updated := &mcpv1alpha1.MCPTelemetryConfig{} + updated := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -125,23 +125,23 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { }) It("should allow deletion by removing finalizer", func() { - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-telemetry-deletion", Namespace: "default", }, } - telemetryConfig.Spec.OpenTelemetry = &mcpv1alpha1.MCPTelemetryOTelConfig{ + telemetryConfig.Spec.OpenTelemetry = &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: testEndpoint, - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, } Expect(k8sClient.Create(ctx, telemetryConfig)).To(Succeed()) // Wait for finalizer to be added Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -162,7 +162,7 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { // Verify it's actually deleted (finalizer removed, object gone) Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -173,23 +173,23 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { It("should track referencing MCPServers in status", func() { // Create a telemetry config - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-ref-tracking", Namespace: "default", }, } - telemetryConfig.Spec.OpenTelemetry = &mcpv1alpha1.MCPTelemetryOTelConfig{ + telemetryConfig.Spec.OpenTelemetry = &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: testEndpoint, - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, } Expect(k8sClient.Create(ctx, telemetryConfig)).To(Succeed()) // Wait for initial reconciliation (finalizer + hash) Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -198,14 +198,14 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { }, timeout, interval).Should(BeTrue()) // Create an MCPServer that references this config - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-ref-tracking", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "test-ref-tracking", }, }, @@ -215,7 +215,7 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { // The MCPServer watch should trigger a reconciliation of the MCPTelemetryConfig. // Verify ReferencingWorkloads is updated to include our server. Eventually(func() []string { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -233,23 +233,23 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { It("should block deletion when MCPServers reference the config", func() { // Create a telemetry config - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-deletion-protection", Namespace: "default", }, } - telemetryConfig.Spec.OpenTelemetry = &mcpv1alpha1.MCPTelemetryOTelConfig{ + telemetryConfig.Spec.OpenTelemetry = &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: testEndpoint, - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, } Expect(k8sClient.Create(ctx, telemetryConfig)).To(Succeed()) // Wait for finalizer Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -266,14 +266,14 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { }, timeout, interval).Should(BeTrue()) // Create an MCPServer that references this config - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server-deletion-blocker", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "example/mcp-server:latest", - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "test-deletion-protection", }, }, @@ -282,7 +282,7 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { // Wait for ReferencingWorkloads to be populated Eventually(func() []string { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -303,7 +303,7 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { // Verify the object still exists (finalizer prevents deletion) Consistently(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -316,7 +316,7 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { // The config should now be deleted (finalizer removed after reference is gone) Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -326,23 +326,23 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { }) It("should track MCPRemoteProxy in ReferencingWorkloads", func() { - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy-ref-tracking", Namespace: "default", }, } - telemetryConfig.Spec.OpenTelemetry = &mcpv1alpha1.MCPTelemetryOTelConfig{ + telemetryConfig.Spec.OpenTelemetry = &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: testEndpoint, - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, } Expect(k8sClient.Create(ctx, telemetryConfig)).To(Succeed()) // Wait for config to be ready Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -351,14 +351,14 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { }, timeout, interval).Should(BeTrue()) // Create an MCPRemoteProxy that references this config - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "proxy-ref-tracking", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://example.com/mcp", - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "test-proxy-ref-tracking", }, }, @@ -368,7 +368,7 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { // The MCPRemoteProxy watch should trigger reconciliation of MCPTelemetryConfig. // Verify ReferencingWorkloads includes the proxy. Eventually(func() []string { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -385,23 +385,23 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { }) It("should block deletion when MCPRemoteProxy references the config", func() { - telemetryConfig := &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy-deletion-protection", Namespace: "default", }, } - telemetryConfig.Spec.OpenTelemetry = &mcpv1alpha1.MCPTelemetryOTelConfig{ + telemetryConfig.Spec.OpenTelemetry = &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: testEndpoint, - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, } Expect(k8sClient.Create(ctx, telemetryConfig)).To(Succeed()) // Wait for finalizer Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -418,14 +418,14 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { }, timeout, interval).Should(BeTrue()) // Create an MCPRemoteProxy that references this config - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "proxy-deletion-blocker", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://example.com/mcp", - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "test-proxy-deletion-protection", }, }, @@ -434,7 +434,7 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { // Wait for ReferencingWorkloads to include the proxy Eventually(func() []string { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -454,7 +454,7 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { // Verify object still exists Consistently(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, @@ -467,7 +467,7 @@ var _ = Describe("MCPTelemetryConfig Controller", func() { // Config should now be deleted Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: telemetryConfig.Namespace, diff --git a/cmd/thv-operator/test-integration/mcp-telemetry-config/suite_test.go b/cmd/thv-operator/test-integration/mcp-telemetry-config/suite_test.go index bb66d65f9e..d87fe6b6af 100644 --- a/cmd/thv-operator/test-integration/mcp-telemetry-config/suite_test.go +++ b/cmd/thv-operator/test-integration/mcp-telemetry-config/suite_test.go @@ -23,7 +23,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/controllers" ) @@ -64,7 +64,7 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) - err = mcpv1alpha1.AddToScheme(scheme.Scheme) + err = mcpv1beta1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) err = corev1.AddToScheme(scheme.Scheme) diff --git a/cmd/thv-operator/test-integration/mcp-toolconfig/mcptoolconfig_controller_integration_test.go b/cmd/thv-operator/test-integration/mcp-toolconfig/mcptoolconfig_controller_integration_test.go index 10d2d4e94f..bfb5a04867 100644 --- a/cmd/thv-operator/test-integration/mcp-toolconfig/mcptoolconfig_controller_integration_test.go +++ b/cmd/thv-operator/test-integration/mcp-toolconfig/mcptoolconfig_controller_integration_test.go @@ -15,7 +15,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) const ( @@ -32,7 +32,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { var ( namespace string configName string - toolConfig *mcpv1alpha1.MCPToolConfig + toolConfig *mcpv1beta1.MCPToolConfig ns *corev1.Namespace ) @@ -49,12 +49,12 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { configName = testConfigName // Create MCPToolConfig - toolConfig = &mcpv1alpha1.MCPToolConfig{ + toolConfig = &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1", "tool2"}, }, } @@ -68,7 +68,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { It("should add finalizer", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -87,7 +87,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { It("should set config hash in status", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -101,7 +101,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { It("should set ObservedGeneration", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -115,7 +115,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { It("should set Valid=True condition", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -137,7 +137,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { var ( namespace string configName string - toolConfig *mcpv1alpha1.MCPToolConfig + toolConfig *mcpv1beta1.MCPToolConfig ns *corev1.Namespace initialHash string ) @@ -155,12 +155,12 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { configName = testConfigName // Create MCPToolConfig - toolConfig = &mcpv1alpha1.MCPToolConfig{ + toolConfig = &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1", "tool2"}, }, } @@ -168,7 +168,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { // Wait for initial hash to be set Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -182,7 +182,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { // Update the spec to add a third tool Eventually(func() error { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -201,7 +201,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { It("should update config hash after spec change", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -215,7 +215,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { It("should maintain Valid=True condition after update", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -236,9 +236,9 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { var ( namespace string configName string - toolConfig *mcpv1alpha1.MCPToolConfig + toolConfig *mcpv1beta1.MCPToolConfig mcpServerName string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer ns *corev1.Namespace ) @@ -256,12 +256,12 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { mcpServerName = testServerName // Create MCPToolConfig - toolConfig = &mcpv1alpha1.MCPToolConfig{ + toolConfig = &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1", "tool2"}, }, } @@ -269,7 +269,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { // Wait for hash to be set before creating the MCPServer Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -281,14 +281,14 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { }, timeout, interval).Should(BeTrue()) // Create MCPServer with ToolConfigRef - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testImage, - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: configName, }, }, @@ -305,7 +305,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { It("should track referencing workloads in status", func() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -328,7 +328,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { // Eventually the referencing workloads list should be empty Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -345,9 +345,9 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { var ( namespace string configName string - toolConfig *mcpv1alpha1.MCPToolConfig + toolConfig *mcpv1beta1.MCPToolConfig mcpServerName string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer ns *corev1.Namespace ) @@ -365,12 +365,12 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { mcpServerName = testServerName // Create MCPToolConfig - toolConfig = &mcpv1alpha1.MCPToolConfig{ + toolConfig = &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: configName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ ToolsFilter: []string{"tool1", "tool2"}, }, } @@ -378,7 +378,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { // Wait for hash to be set Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -390,14 +390,14 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { }, timeout, interval).Should(BeTrue()) // Create MCPServer with ToolConfigRef - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: testImage, - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: configName, }, }, @@ -406,7 +406,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { // Wait for ReferencingWorkloads to be populated Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -433,7 +433,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { // Wait for MCPToolConfig to be fully removed Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -447,7 +447,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { It("should not be deleted while referenced", func() { // The object should still exist because the finalizer blocks deletion Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -465,7 +465,7 @@ var _ = Describe("MCPToolConfig Controller Integration Tests", func() { // The MCPToolConfig should eventually be fully deleted Eventually(func() bool { - updated := &mcpv1alpha1.MCPToolConfig{} + updated := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, diff --git a/cmd/thv-operator/test-integration/mcp-toolconfig/suite_test.go b/cmd/thv-operator/test-integration/mcp-toolconfig/suite_test.go index df58d2b687..1e6e0cccd4 100644 --- a/cmd/thv-operator/test-integration/mcp-toolconfig/suite_test.go +++ b/cmd/thv-operator/test-integration/mcp-toolconfig/suite_test.go @@ -25,7 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/controllers" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -70,7 +70,7 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) - err = mcpv1alpha1.AddToScheme(scheme.Scheme) + err = mcpv1beta1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) // Add other schemes that the controllers use diff --git a/cmd/thv-operator/test-integration/virtualmcp/suite_test.go b/cmd/thv-operator/test-integration/virtualmcp/suite_test.go index a8807ff6f8..53fe8ed827 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/suite_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/suite_test.go @@ -25,7 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/controllers" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -74,7 +74,7 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) - err = mcpv1alpha1.AddToScheme(scheme.Scheme) + err = mcpv1beta1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) // Add other schemes that the controllers use @@ -104,8 +104,8 @@ var _ = BeforeSuite(func() { Expect(err).ToNot(HaveOccurred()) // Set up field indexing for MCPServer.Spec.GroupRef - if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { - mcpServer := obj.(*mcpv1alpha1.MCPServer) + if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1beta1.MCPServer{}, "spec.groupRef", func(obj client.Object) []string { + mcpServer := obj.(*mcpv1beta1.MCPServer) name := mcpServer.Spec.GroupRef.GetName() if name == "" { return nil @@ -116,8 +116,8 @@ var _ = BeforeSuite(func() { } // Set up field indexing for MCPRemoteProxy.Spec.GroupRef - if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) + if err := k8sManager.GetFieldIndexer().IndexField(ctx, &mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) name := mcpRemoteProxy.Spec.GroupRef.GetName() if name == "" { return nil @@ -130,10 +130,10 @@ var _ = BeforeSuite(func() { // Set up field indexing for MCPServerEntry.Spec.GroupRef err = k8sManager.GetFieldIndexer().IndexField( context.Background(), - &mcpv1alpha1.MCPServerEntry{}, + &mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) name := mcpServerEntry.Spec.GroupRef.GetName() if name == "" { return nil diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_compositetool_watch_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_compositetool_watch_test.go index 5a9cf22d49..e43d99fb62 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_compositetool_watch_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_compositetool_watch_test.go @@ -12,7 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" thvjson "github.com/stacklok/toolhive/pkg/json" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -31,9 +31,9 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes vmcpName string mcpGroupName string compositeToolDefName string - vmcp *mcpv1alpha1.VirtualMCPServer - mcpGroup *mcpv1alpha1.MCPGroup - compositeToolDef *mcpv1alpha1.VirtualMCPCompositeToolDefinition + vmcp *mcpv1beta1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + compositeToolDef *mcpv1beta1.VirtualMCPCompositeToolDefinition ) BeforeAll(func() { @@ -43,12 +43,12 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes compositeToolDefName = "test-composite-tool" // Create MCPGroup first (required by VirtualMCPServer) - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for composite tool watch", }, } @@ -56,30 +56,30 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes // Wait for MCPGroup to be ready Eventually(func() bool { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, }, updatedGroup) - return err == nil && updatedGroup.Status.Phase == mcpv1alpha1.MCPGroupPhaseReady + return err == nil && updatedGroup.Status.Phase == mcpv1beta1.MCPGroupPhaseReady }, timeout, interval).Should(BeTrue()) // Create VirtualMCPServer that references the composite tool definition // (even though the composite tool doesn't exist yet) - vmcp = &mcpv1alpha1.VirtualMCPServer{ + vmcp = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: compositeToolDefName}, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -90,7 +90,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes // Check that the CompositeToolRefsValidated condition is set (even if False) // This indicates reconciliation was attempted, similar to how GroupRef validation is tested Eventually(func() bool { - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -101,9 +101,9 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes // Check for CompositeToolRefsValidated condition for _, cond := range updatedVMCP.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeCompositeToolRefsValidated { + if cond.Type == mcpv1beta1.ConditionTypeCompositeToolRefsValidated { return cond.Status == metav1.ConditionFalse && - cond.Reason == mcpv1alpha1.ConditionReasonCompositeToolRefNotFound + cond.Reason == mcpv1beta1.ConditionReasonCompositeToolRefNotFound } } return false @@ -121,12 +121,12 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes It("Should trigger VirtualMCPServer reconciliation when composite tool definition is created", func() { // Create the VirtualMCPCompositeToolDefinition with Output spec - compositeToolDef = &mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeToolDef = &mcpv1beta1.VirtualMCPCompositeToolDefinition{ ObjectMeta: metav1.ObjectMeta{ Name: compositeToolDefName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "test-workflow", Description: "Test workflow for integration test", @@ -160,7 +160,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes // Wait for VirtualMCPServer to reconcile after the composite tool definition is created // First, wait for the CompositeToolRefsValidated condition to become True Eventually(func() bool { - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -171,9 +171,9 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes // Check for CompositeToolRefsValidated condition to be True for _, cond := range updatedVMCP.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeCompositeToolRefsValidated { + if cond.Type == mcpv1beta1.ConditionTypeCompositeToolRefsValidated { return cond.Status == metav1.ConditionTrue && - cond.Reason == mcpv1alpha1.ConditionReasonCompositeToolRefsValid + cond.Reason == mcpv1beta1.ConditionReasonCompositeToolRefsValid } } return false @@ -182,7 +182,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes // Then verify that ObservedGeneration is set and matches Generation // This indicates successful reconciliation Eventually(func() bool { - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -197,7 +197,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes }, timeout, interval).Should(BeTrue()) // Verify the VirtualMCPServer is in a valid state - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -205,8 +205,8 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes Expect(updatedVMCP.Status.ObservedGeneration).To(Equal(updatedVMCP.Generation)) Expect(updatedVMCP.Status.Phase).To(Or( - Equal(mcpv1alpha1.VirtualMCPServerPhaseReady), - Equal(mcpv1alpha1.VirtualMCPServerPhasePending), + Equal(mcpv1beta1.VirtualMCPServerPhaseReady), + Equal(mcpv1beta1.VirtualMCPServerPhasePending), )) }) }) @@ -217,9 +217,9 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes vmcpName string mcpGroupName string compositeToolDefName string - vmcp *mcpv1alpha1.VirtualMCPServer - mcpGroup *mcpv1alpha1.MCPGroup - compositeToolDef *mcpv1alpha1.VirtualMCPCompositeToolDefinition + vmcp *mcpv1beta1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + compositeToolDef *mcpv1beta1.VirtualMCPCompositeToolDefinition ) BeforeAll(func() { @@ -229,12 +229,12 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes compositeToolDefName = "test-composite-tool-update" // Create MCPGroup - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for composite tool update", }, } @@ -242,21 +242,21 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes // Wait for MCPGroup to be ready Eventually(func() bool { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, }, updatedGroup) - return err == nil && updatedGroup.Status.Phase == mcpv1alpha1.MCPGroupPhaseReady + return err == nil && updatedGroup.Status.Phase == mcpv1beta1.MCPGroupPhaseReady }, timeout, interval).Should(BeTrue()) // Create VirtualMCPCompositeToolDefinition first - compositeToolDef = &mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeToolDef = &mcpv1beta1.VirtualMCPCompositeToolDefinition{ ObjectMeta: metav1.ObjectMeta{ Name: compositeToolDefName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "test-workflow-update", Description: "Initial description", @@ -272,20 +272,20 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes Expect(k8sClient.Create(ctx, compositeToolDef)).Should(Succeed()) // Create VirtualMCPServer that references the composite tool definition - vmcp = &mcpv1alpha1.VirtualMCPServer{ + vmcp = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: compositeToolDefName}, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -294,7 +294,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes // Wait for initial reconciliation Eventually(func() bool { - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -313,7 +313,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes It("Should trigger VirtualMCPServer reconciliation when composite tool definition is updated", func() { // Update the VirtualMCPCompositeToolDefinition Eventually(func() error { - freshCompositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{} + freshCompositeToolDef := &mcpv1beta1.VirtualMCPCompositeToolDefinition{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: compositeToolDefName, Namespace: namespace, @@ -327,7 +327,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes // The VirtualMCPServer should remain reconciled after the update // We verify this by checking that ObservedGeneration stays current Consistently(func() bool { - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -341,7 +341,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes }, time.Second*5, interval).Should(BeTrue()) // Verify the VirtualMCPServer is still in a valid state - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -349,8 +349,8 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes Expect(updatedVMCP.Status.ObservedGeneration).To(Equal(updatedVMCP.Generation)) Expect(updatedVMCP.Status.Phase).To(Or( - Equal(mcpv1alpha1.VirtualMCPServerPhaseReady), - Equal(mcpv1alpha1.VirtualMCPServerPhasePending), + Equal(mcpv1beta1.VirtualMCPServerPhaseReady), + Equal(mcpv1beta1.VirtualMCPServerPhasePending), )) }) }) @@ -361,9 +361,9 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes vmcpName string mcpGroupName string compositeToolDefName string - vmcp *mcpv1alpha1.VirtualMCPServer - mcpGroup *mcpv1alpha1.MCPGroup - compositeToolDef *mcpv1alpha1.VirtualMCPCompositeToolDefinition + vmcp *mcpv1beta1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + compositeToolDef *mcpv1beta1.VirtualMCPCompositeToolDefinition ) BeforeAll(func() { @@ -373,12 +373,12 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes compositeToolDefName = "test-composite-tool-noref" // Create MCPGroup - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group without composite tool ref", }, } @@ -386,24 +386,24 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes // Wait for MCPGroup to be ready Eventually(func() bool { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, }, updatedGroup) - return err == nil && updatedGroup.Status.Phase == mcpv1alpha1.MCPGroupPhaseReady + return err == nil && updatedGroup.Status.Phase == mcpv1beta1.MCPGroupPhaseReady }, timeout, interval).Should(BeTrue()) // Create VirtualMCPServer WITHOUT referencing the composite tool definition - vmcp = &mcpv1alpha1.VirtualMCPServer{ + vmcp = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{Group: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, // No CompositeToolRefs @@ -413,7 +413,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes // Wait for initial reconciliation Eventually(func() bool { - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -431,7 +431,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes It("Should NOT trigger VirtualMCPServer reconciliation when unrelated composite tool definition is created", func() { // Get initial generation and observed generation - initialVMCP := &mcpv1alpha1.VirtualMCPServer{} + initialVMCP := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -448,12 +448,12 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes } // Create a composite tool definition that is NOT referenced by the VirtualMCPServer - compositeToolDef = &mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeToolDef = &mcpv1beta1.VirtualMCPCompositeToolDefinition{ ObjectMeta: metav1.ObjectMeta{ Name: compositeToolDefName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "unrelated-workflow", Description: "Workflow not referenced by VirtualMCPServer", @@ -473,7 +473,7 @@ var _ = Describe("VirtualMCPServer CompositeToolDefinition Watch Integration Tes // Verify that the VirtualMCPServer was NOT unnecessarily reconciled // The ObservedGeneration should remain the same, and conditions shouldn't change - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_elicitation_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_elicitation_integration_test.go index 7865ae8204..9b52de1eb6 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_elicitation_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_elicitation_integration_test.go @@ -12,7 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" thvjson "github.com/stacklok/toolhive/pkg/json" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -30,9 +30,9 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { vmcpName string mcpGroupName string compositeToolDefName string - vmcp *mcpv1alpha1.VirtualMCPServer - mcpGroup *mcpv1alpha1.MCPGroup - compositeToolDef *mcpv1alpha1.VirtualMCPCompositeToolDefinition + vmcp *mcpv1beta1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + compositeToolDef *mcpv1beta1.VirtualMCPCompositeToolDefinition ) BeforeAll(func() { @@ -42,12 +42,12 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { compositeToolDefName = "test-elicitation-tool" // Create MCPGroup first (required by VirtualMCPServer) - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for elicitation integration", }, } @@ -55,21 +55,21 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Wait for MCPGroup to be ready Eventually(func() bool { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, }, updatedGroup) - return err == nil && updatedGroup.Status.Phase == mcpv1alpha1.MCPGroupPhaseReady + return err == nil && updatedGroup.Status.Phase == mcpv1beta1.MCPGroupPhaseReady }, timeout, interval).Should(BeTrue()) // Create VirtualMCPCompositeToolDefinition with elicitation steps - compositeToolDef = &mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeToolDef = &mcpv1beta1.VirtualMCPCompositeToolDefinition{ ObjectMeta: metav1.ObjectMeta{ Name: compositeToolDefName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "interactive_workflow", Description: "Workflow with user interactions via elicitations", @@ -78,14 +78,14 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Step 1: Tool call { ID: "prepare", - Type: mcpv1alpha1.WorkflowStepTypeToolCall, + Type: mcpv1beta1.WorkflowStepTypeToolCall, Tool: "echo", Timeout: vmcpconfig.Duration(1 * time.Minute), }, // Step 2: Elicitation with OnDecline and OnCancel handlers { ID: "confirm_deploy", - Type: mcpv1alpha1.WorkflowStepTypeElicitation, + Type: mcpv1beta1.WorkflowStepTypeElicitation, Message: "Proceed with deployment?", Schema: thvjson.NewMap(map[string]any{"type": "object", "properties": map[string]any{"proceed": map[string]any{"type": "boolean"}}}), DependsOn: []string{"prepare"}, @@ -100,7 +100,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Step 3: Another elicitation with different handlers { ID: "select_env", - Type: mcpv1alpha1.WorkflowStepTypeElicitation, + Type: mcpv1beta1.WorkflowStepTypeElicitation, Message: "Select target environment", Schema: thvjson.NewMap(map[string]any{"type": "object", "properties": map[string]any{"environment": map[string]any{"type": "string", "enum": []any{"staging", "production"}}}}), DependsOn: []string{"confirm_deploy"}, @@ -115,7 +115,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Step 4: Final tool call { ID: "deploy", - Type: mcpv1alpha1.WorkflowStepTypeToolCall, + Type: mcpv1beta1.WorkflowStepTypeToolCall, Tool: "deploy_app", DependsOn: []string{"select_env"}, Timeout: vmcpconfig.Duration(2 * time.Minute), @@ -127,20 +127,20 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Expect(k8sClient.Create(ctx, compositeToolDef)).Should(Succeed()) // Create VirtualMCPServer that references the composite tool definition - vmcp = &mcpv1alpha1.VirtualMCPServer{ + vmcp = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: compositeToolDefName}, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -149,7 +149,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Wait for VirtualMCPServer to reconcile Eventually(func() bool { - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -160,9 +160,9 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Check for CompositeToolRefsValidated condition to be True for _, cond := range updatedVMCP.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeCompositeToolRefsValidated { + if cond.Type == mcpv1beta1.ConditionTypeCompositeToolRefsValidated { return cond.Status == metav1.ConditionTrue && - cond.Reason == mcpv1alpha1.ConditionReasonCompositeToolRefsValid + cond.Reason == mcpv1beta1.ConditionReasonCompositeToolRefsValid } } return false @@ -177,7 +177,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { }) It("Should successfully validate composite tool with elicitation steps", func() { - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -186,24 +186,24 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Verify VirtualMCPServer is in valid state Expect(updatedVMCP.Status.ObservedGeneration).To(Equal(updatedVMCP.Generation)) Expect(updatedVMCP.Status.Phase).To(Or( - Equal(mcpv1alpha1.VirtualMCPServerPhaseReady), - Equal(mcpv1alpha1.VirtualMCPServerPhasePending), + Equal(mcpv1beta1.VirtualMCPServerPhaseReady), + Equal(mcpv1beta1.VirtualMCPServerPhasePending), )) // Verify CompositeToolRefsValidated condition is True foundValidatedCondition := false for _, cond := range updatedVMCP.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeCompositeToolRefsValidated { + if cond.Type == mcpv1beta1.ConditionTypeCompositeToolRefsValidated { foundValidatedCondition = true Expect(cond.Status).To(Equal(metav1.ConditionTrue)) - Expect(cond.Reason).To(Equal(mcpv1alpha1.ConditionReasonCompositeToolRefsValid)) + Expect(cond.Reason).To(Equal(mcpv1beta1.ConditionReasonCompositeToolRefsValid)) } } Expect(foundValidatedCondition).To(BeTrue(), "CompositeToolRefsValidated condition should exist") }) It("Should have composite tool definition with valid elicitation steps", func() { - updatedCompositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{} + updatedCompositeToolDef := &mcpv1beta1.VirtualMCPCompositeToolDefinition{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: compositeToolDefName, Namespace: namespace, @@ -215,7 +215,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Verify first elicitation step (confirm_deploy) confirmStep := updatedCompositeToolDef.Spec.Steps[1] Expect(confirmStep.ID).To(Equal("confirm_deploy")) - Expect(confirmStep.Type).To(Equal(mcpv1alpha1.WorkflowStepTypeElicitation)) + Expect(confirmStep.Type).To(Equal(mcpv1beta1.WorkflowStepTypeElicitation)) Expect(confirmStep.Message).To(Equal("Proceed with deployment?")) Expect(confirmStep.OnDecline).NotTo(BeNil()) Expect(confirmStep.OnDecline.Action).To(Equal("skip_remaining")) @@ -226,7 +226,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Verify second elicitation step (select_env) selectStep := updatedCompositeToolDef.Spec.Steps[2] Expect(selectStep.ID).To(Equal("select_env")) - Expect(selectStep.Type).To(Equal(mcpv1alpha1.WorkflowStepTypeElicitation)) + Expect(selectStep.Type).To(Equal(mcpv1beta1.WorkflowStepTypeElicitation)) Expect(selectStep.Message).To(Equal("Select target environment")) Expect(selectStep.OnDecline).NotTo(BeNil()) Expect(selectStep.OnDecline.Action).To(Equal("continue")) @@ -241,9 +241,9 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { vmcpName string mcpGroupName string compositeToolDefName string - vmcp *mcpv1alpha1.VirtualMCPServer - mcpGroup *mcpv1alpha1.MCPGroup - compositeToolDef *mcpv1alpha1.VirtualMCPCompositeToolDefinition + vmcp *mcpv1beta1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + compositeToolDef *mcpv1beta1.VirtualMCPCompositeToolDefinition ) BeforeAll(func() { @@ -253,12 +253,12 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { compositeToolDefName = "test-all-handlers-tool" // Create MCPGroup - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for all elicitation handlers", }, } @@ -266,21 +266,21 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Wait for MCPGroup to be ready Eventually(func() bool { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, }, updatedGroup) - return err == nil && updatedGroup.Status.Phase == mcpv1alpha1.MCPGroupPhaseReady + return err == nil && updatedGroup.Status.Phase == mcpv1beta1.MCPGroupPhaseReady }, timeout, interval).Should(BeTrue()) // Create VirtualMCPCompositeToolDefinition with all handler combinations - compositeToolDef = &mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeToolDef = &mcpv1beta1.VirtualMCPCompositeToolDefinition{ ObjectMeta: metav1.ObjectMeta{ Name: compositeToolDefName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "all_handlers_workflow", Description: "Test all valid elicitation handler actions", @@ -288,7 +288,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Test skip_remaining { ID: "elicit_skip", - Type: mcpv1alpha1.WorkflowStepTypeElicitation, + Type: mcpv1beta1.WorkflowStepTypeElicitation, Message: "Test skip_remaining", Schema: thvjson.NewMap(map[string]any{"type": "object"}), OnDecline: &vmcpconfig.ElicitationResponseConfig{ @@ -301,7 +301,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Test abort { ID: "elicit_abort", - Type: mcpv1alpha1.WorkflowStepTypeElicitation, + Type: mcpv1beta1.WorkflowStepTypeElicitation, Message: "Test abort", Schema: thvjson.NewMap(map[string]any{"type": "object"}), OnDecline: &vmcpconfig.ElicitationResponseConfig{ @@ -314,7 +314,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Test continue { ID: "elicit_continue", - Type: mcpv1alpha1.WorkflowStepTypeElicitation, + Type: mcpv1beta1.WorkflowStepTypeElicitation, Message: "Test continue", Schema: thvjson.NewMap(map[string]any{"type": "object"}), OnDecline: &vmcpconfig.ElicitationResponseConfig{ @@ -331,20 +331,20 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Expect(k8sClient.Create(ctx, compositeToolDef)).Should(Succeed()) // Create VirtualMCPServer - vmcp = &mcpv1alpha1.VirtualMCPServer{ + vmcp = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: compositeToolDefName}, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -353,7 +353,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Wait for reconciliation Eventually(func() bool { - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -369,7 +369,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { }) It("Should accept all valid elicitation handler actions", func() { - updatedCompositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{} + updatedCompositeToolDef := &mcpv1beta1.VirtualMCPCompositeToolDefinition{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: compositeToolDefName, Namespace: namespace, @@ -395,7 +395,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { }) It("Should have VirtualMCPServer in valid state with all handler types", func() { - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -403,14 +403,14 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Verify VirtualMCPServer successfully validated the composite tool Expect(updatedVMCP.Status.Phase).To(Or( - Equal(mcpv1alpha1.VirtualMCPServerPhaseReady), - Equal(mcpv1alpha1.VirtualMCPServerPhasePending), + Equal(mcpv1beta1.VirtualMCPServerPhaseReady), + Equal(mcpv1beta1.VirtualMCPServerPhasePending), )) // Verify CompositeToolRefsValidated condition foundCondition := false for _, cond := range updatedVMCP.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeCompositeToolRefsValidated { + if cond.Type == mcpv1beta1.ConditionTypeCompositeToolRefsValidated { foundCondition = true Expect(cond.Status).To(Equal(metav1.ConditionTrue)) } @@ -425,9 +425,9 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { vmcpName string mcpGroupName string compositeToolDefName string - vmcp *mcpv1alpha1.VirtualMCPServer - mcpGroup *mcpv1alpha1.MCPGroup - compositeToolDef *mcpv1alpha1.VirtualMCPCompositeToolDefinition + vmcp *mcpv1beta1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + compositeToolDef *mcpv1beta1.VirtualMCPCompositeToolDefinition ) BeforeAll(func() { @@ -437,12 +437,12 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { compositeToolDefName = "test-mixed-steps-tool" // Create MCPGroup - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for mixed steps", }, } @@ -450,21 +450,21 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Wait for MCPGroup to be ready Eventually(func() bool { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, }, updatedGroup) - return err == nil && updatedGroup.Status.Phase == mcpv1alpha1.MCPGroupPhaseReady + return err == nil && updatedGroup.Status.Phase == mcpv1beta1.MCPGroupPhaseReady }, timeout, interval).Should(BeTrue()) // Create composite tool with alternating tool calls and elicitations - compositeToolDef = &mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeToolDef = &mcpv1beta1.VirtualMCPCompositeToolDefinition{ ObjectMeta: metav1.ObjectMeta{ Name: compositeToolDefName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "mixed_steps_workflow", Description: "Workflow with alternating tool calls and elicitations", @@ -472,13 +472,13 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Tool call { ID: "tool1", - Type: mcpv1alpha1.WorkflowStepTypeToolCall, + Type: mcpv1beta1.WorkflowStepTypeToolCall, Tool: "prepare", }, // Elicitation { ID: "elicit1", - Type: mcpv1alpha1.WorkflowStepTypeElicitation, + Type: mcpv1beta1.WorkflowStepTypeElicitation, Message: "Confirm step 1?", Schema: thvjson.NewMap(map[string]any{"type": "object"}), DependsOn: []string{"tool1"}, @@ -489,14 +489,14 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Tool call { ID: "tool2", - Type: mcpv1alpha1.WorkflowStepTypeToolCall, + Type: mcpv1beta1.WorkflowStepTypeToolCall, Tool: "execute", DependsOn: []string{"elicit1"}, }, // Elicitation { ID: "elicit2", - Type: mcpv1alpha1.WorkflowStepTypeElicitation, + Type: mcpv1beta1.WorkflowStepTypeElicitation, Message: "Confirm step 2?", Schema: thvjson.NewMap(map[string]any{"type": "object"}), DependsOn: []string{"tool2"}, @@ -507,7 +507,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Final tool call { ID: "tool3", - Type: mcpv1alpha1.WorkflowStepTypeToolCall, + Type: mcpv1beta1.WorkflowStepTypeToolCall, Tool: "finalize", DependsOn: []string{"elicit2"}, }, @@ -518,20 +518,20 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Expect(k8sClient.Create(ctx, compositeToolDef)).Should(Succeed()) // Create VirtualMCPServer - vmcp = &mcpv1alpha1.VirtualMCPServer{ + vmcp = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: compositeToolDefName}, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -540,7 +540,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { // Wait for reconciliation Eventually(func() bool { - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -556,7 +556,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { }) It("Should successfully create workflow with mixed tool and elicitation steps", func() { - updatedCompositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{} + updatedCompositeToolDef := &mcpv1beta1.VirtualMCPCompositeToolDefinition{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: compositeToolDefName, Namespace: namespace, @@ -566,11 +566,11 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Expect(updatedCompositeToolDef.Spec.Steps).To(HaveLen(5)) // Verify alternating pattern - Expect(updatedCompositeToolDef.Spec.Steps[0].Type).To(Equal(mcpv1alpha1.WorkflowStepTypeToolCall)) - Expect(updatedCompositeToolDef.Spec.Steps[1].Type).To(Equal(mcpv1alpha1.WorkflowStepTypeElicitation)) - Expect(updatedCompositeToolDef.Spec.Steps[2].Type).To(Equal(mcpv1alpha1.WorkflowStepTypeToolCall)) - Expect(updatedCompositeToolDef.Spec.Steps[3].Type).To(Equal(mcpv1alpha1.WorkflowStepTypeElicitation)) - Expect(updatedCompositeToolDef.Spec.Steps[4].Type).To(Equal(mcpv1alpha1.WorkflowStepTypeToolCall)) + Expect(updatedCompositeToolDef.Spec.Steps[0].Type).To(Equal(mcpv1beta1.WorkflowStepTypeToolCall)) + Expect(updatedCompositeToolDef.Spec.Steps[1].Type).To(Equal(mcpv1beta1.WorkflowStepTypeElicitation)) + Expect(updatedCompositeToolDef.Spec.Steps[2].Type).To(Equal(mcpv1beta1.WorkflowStepTypeToolCall)) + Expect(updatedCompositeToolDef.Spec.Steps[3].Type).To(Equal(mcpv1beta1.WorkflowStepTypeElicitation)) + Expect(updatedCompositeToolDef.Spec.Steps[4].Type).To(Equal(mcpv1beta1.WorkflowStepTypeToolCall)) // Verify dependencies are preserved Expect(updatedCompositeToolDef.Spec.Steps[1].DependsOn).To(ContainElement("tool1")) @@ -580,7 +580,7 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { }) It("Should have valid VirtualMCPServer status for mixed step workflow", func() { - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -588,8 +588,8 @@ var _ = Describe("VirtualMCPServer Elicitation Integration Tests", func() { Expect(updatedVMCP.Status.ObservedGeneration).To(Equal(updatedVMCP.Generation)) Expect(updatedVMCP.Status.Phase).To(Or( - Equal(mcpv1alpha1.VirtualMCPServerPhaseReady), - Equal(mcpv1alpha1.VirtualMCPServerPhasePending), + Equal(mcpv1beta1.VirtualMCPServerPhaseReady), + Equal(mcpv1beta1.VirtualMCPServerPhasePending), )) }) }) diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go index 58c7c60f0b..97ff176877 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_externalauth_watch_test.go @@ -12,7 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -30,10 +30,10 @@ var _ = Describe("VirtualMCPServer ExternalAuthConfig Watch Integration Tests", mcpGroupName string mcpServerName string authConfigName string - vmcp *mcpv1alpha1.VirtualMCPServer - mcpGroup *mcpv1alpha1.MCPGroup - mcpServer *mcpv1alpha1.MCPServer - authConfig *mcpv1alpha1.MCPExternalAuthConfig + vmcp *mcpv1beta1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + mcpServer *mcpv1beta1.MCPServer + authConfig *mcpv1beta1.MCPExternalAuthConfig ) BeforeAll(func() { @@ -44,16 +44,16 @@ var _ = Describe("VirtualMCPServer ExternalAuthConfig Watch Integration Tests", authConfigName = "test-auth-watch" // Create MCPExternalAuthConfig - authConfig = &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig = &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: authConfigName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-Test-Auth", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-secret", Key: "token", }, @@ -63,12 +63,12 @@ var _ = Describe("VirtualMCPServer ExternalAuthConfig Watch Integration Tests", Expect(k8sClient.Create(ctx, authConfig)).Should(Succeed()) // Create MCPGroup - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for auth watch", }, } @@ -76,25 +76,25 @@ var _ = Describe("VirtualMCPServer ExternalAuthConfig Watch Integration Tests", // Wait for MCPGroup to be ready Eventually(func() bool { - updatedGroup := &mcpv1alpha1.MCPGroup{} + updatedGroup := &mcpv1beta1.MCPGroup{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: namespace, }, updatedGroup) - return err == nil && updatedGroup.Status.Phase == mcpv1alpha1.MCPGroupPhaseReady + return err == nil && updatedGroup.Status.Phase == mcpv1beta1.MCPGroupPhaseReady }, timeout, interval).Should(BeTrue()) // Create MCPServer that references the MCPExternalAuthConfig - mcpServer = &mcpv1alpha1.MCPServer{ + mcpServer = &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: mcpServerName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: "test-image:latest", Transport: "streamable-http", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: authConfigName, }, }, @@ -102,18 +102,18 @@ var _ = Describe("VirtualMCPServer ExternalAuthConfig Watch Integration Tests", Expect(k8sClient.Create(ctx, mcpServer)).Should(Succeed()) // Create VirtualMCPServer with discovered mode - vmcp = &mcpv1alpha1.VirtualMCPServer{ + vmcp = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{Group: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", // Use discovered mode }, }, @@ -122,7 +122,7 @@ var _ = Describe("VirtualMCPServer ExternalAuthConfig Watch Integration Tests", // Wait for initial VirtualMCPServer reconciliation Eventually(func() bool { - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -141,7 +141,7 @@ var _ = Describe("VirtualMCPServer ExternalAuthConfig Watch Integration Tests", It("Should trigger VirtualMCPServer reconciliation when ExternalAuthConfig is updated", func() { // Update the MCPExternalAuthConfig - updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + updatedAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: authConfigName, Namespace: namespace, @@ -155,7 +155,7 @@ var _ = Describe("VirtualMCPServer ExternalAuthConfig Watch Integration Tests", // We verify this by checking that ObservedGeneration stays current with Generation // This indicates the controller is continuously reconciling and processing the auth config update Consistently(func() bool { - reconciledVMCP := &mcpv1alpha1.VirtualMCPServer{} + reconciledVMCP := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -169,7 +169,7 @@ var _ = Describe("VirtualMCPServer ExternalAuthConfig Watch Integration Tests", }, time.Second*5, interval).Should(BeTrue()) // Verify the VirtualMCPServer is still in a valid state - updatedVMCP := &mcpv1alpha1.VirtualMCPServer{} + updatedVMCP := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpName, Namespace: namespace, @@ -177,8 +177,8 @@ var _ = Describe("VirtualMCPServer ExternalAuthConfig Watch Integration Tests", Expect(updatedVMCP.Status.ObservedGeneration).To(Equal(updatedVMCP.Generation)) Expect(updatedVMCP.Status.Phase).To(Or( - Equal(mcpv1alpha1.VirtualMCPServerPhaseReady), - Equal(mcpv1alpha1.VirtualMCPServerPhasePending), + Equal(mcpv1beta1.VirtualMCPServerPhaseReady), + Equal(mcpv1beta1.VirtualMCPServerPhasePending), )) }) }) diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_podtemplatespec_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_podtemplatespec_integration_test.go index b505bf9d13..0b981b5132 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_podtemplatespec_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_podtemplatespec_integration_test.go @@ -16,7 +16,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -33,8 +33,8 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { namespace string mcpGroupName string virtualMCPName string - mcpGroup *mcpv1alpha1.MCPGroup - virtualMCPServer *mcpv1alpha1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + virtualMCPServer *mcpv1beta1.VirtualMCPServer ) BeforeAll(func() { @@ -54,27 +54,27 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { } // Create MCPGroup first (required by VirtualMCPServer) - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for PodTemplateSpec tests", }, } Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) // Define the VirtualMCPServer resource with invalid PodTemplateSpec - virtualMCPServer = &mcpv1alpha1.VirtualMCPServer{ + virtualMCPServer = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: virtualMCPName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{Group: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, // Invalid PodTemplateSpec - containers should be an array, not a string @@ -98,7 +98,7 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { It("Should set PodTemplateSpecValid condition to False", func() { // Wait for the status to be updated with the invalid condition Eventually(func() bool { - updatedVirtualMCPServer := &mcpv1alpha1.VirtualMCPServer{} + updatedVirtualMCPServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: virtualMCPName, Namespace: namespace, @@ -118,7 +118,7 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { }, timeout, interval).Should(BeTrue()) // Verify the condition message contains expected text - updatedVirtualMCPServer := &mcpv1alpha1.VirtualMCPServer{} + updatedVirtualMCPServer := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: virtualMCPName, Namespace: namespace, @@ -150,7 +150,7 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { }) It("Should have Failed phase in status", func() { - updatedVirtualMCPServer := &mcpv1alpha1.VirtualMCPServer{} + updatedVirtualMCPServer := &mcpv1beta1.VirtualMCPServer{} Eventually(func() bool { err := k8sClient.Get(ctx, types.NamespacedName{ Name: virtualMCPName, @@ -159,7 +159,7 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { if err != nil { return false } - return updatedVirtualMCPServer.Status.Phase == mcpv1alpha1.VirtualMCPServerPhaseFailed + return updatedVirtualMCPServer.Status.Phase == mcpv1beta1.VirtualMCPServerPhaseFailed }, timeout, interval).Should(BeTrue()) Expect(updatedVirtualMCPServer.Status.Message).To(ContainSubstring("Invalid PodTemplateSpec")) @@ -171,8 +171,8 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { namespace string mcpGroupName string virtualMCPName string - mcpGroup *mcpv1alpha1.MCPGroup - virtualMCPServer *mcpv1alpha1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + virtualMCPServer *mcpv1beta1.VirtualMCPServer ) BeforeAll(func() { @@ -192,12 +192,12 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { } // Create MCPGroup first (required by VirtualMCPServer) - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for PodTemplateSpec tests", }, } @@ -206,15 +206,15 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { // Define the VirtualMCPServer resource with valid PodTemplateSpec containing nodeSelector // Only specify nodeSelector - don't include containers array // Strategic merge will preserve the controller-generated vmcp container - virtualMCPServer = &mcpv1alpha1.VirtualMCPServer{ + virtualMCPServer = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: virtualMCPName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{Group: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, PodTemplateSpec: &runtime.RawExtension{ @@ -236,7 +236,7 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { It("Should have PodTemplateSpecValid condition set to True", func() { Eventually(func() bool { - updatedVirtualMCPServer := &mcpv1alpha1.VirtualMCPServer{} + updatedVirtualMCPServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: virtualMCPName, Namespace: namespace, @@ -275,8 +275,8 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { namespace string mcpGroupName string virtualMCPName string - mcpGroup *mcpv1alpha1.MCPGroup - virtualMCPServer *mcpv1alpha1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + virtualMCPServer *mcpv1beta1.VirtualMCPServer ) BeforeAll(func() { @@ -296,12 +296,12 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { } // Create MCPGroup first (required by VirtualMCPServer) - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for PodTemplateSpec tests", }, } @@ -310,15 +310,15 @@ var _ = Describe("VirtualMCPServer PodTemplateSpec Integration Tests", func() { // Define the VirtualMCPServer resource with PodTemplateSpec containing nodeSelector // Only specify nodeSelector - don't include containers array // Strategic merge will preserve the controller-generated vmcp container - virtualMCPServer = &mcpv1alpha1.VirtualMCPServer{ + virtualMCPServer = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: virtualMCPName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{Group: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, PodTemplateSpec: &runtime.RawExtension{ diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_replicas_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_replicas_integration_test.go index 5d0a165cbf..491f11b854 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_replicas_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_replicas_integration_test.go @@ -15,7 +15,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -29,8 +29,8 @@ var _ = Describe("VirtualMCPServer Replicas Integration Tests", Context("When spec.replicas is set", Ordered, func() { var ( - mcpGroup *mcpv1alpha1.MCPGroup - virtualMCPServer *mcpv1alpha1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + virtualMCPServer *mcpv1beta1.VirtualMCPServer ) BeforeAll(func() { @@ -42,27 +42,27 @@ var _ = Describe("VirtualMCPServer Replicas Integration Tests", Expect(err).NotTo(HaveOccurred()) } - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group-replicas", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for replicas integration test", }, } Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) replicas := int32(3) - virtualMCPServer = &mcpv1alpha1.VirtualMCPServer{ + virtualMCPServer = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-replicas-test", Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group-replicas"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-replicas"}, Config: vmcpconfig.Config{Group: "test-group-replicas"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, Replicas: &replicas, @@ -92,31 +92,31 @@ var _ = Describe("VirtualMCPServer Replicas Integration Tests", Context("When spec.replicas is nil", Ordered, func() { var ( - mcpGroup *mcpv1alpha1.MCPGroup - virtualMCPServer *mcpv1alpha1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + virtualMCPServer *mcpv1beta1.VirtualMCPServer ) BeforeAll(func() { - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group-nil-replicas", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for nil replicas integration test", }, } Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) - virtualMCPServer = &mcpv1alpha1.VirtualMCPServer{ + virtualMCPServer = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "vmcp-nil-replicas-test", Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group-nil-replicas"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-nil-replicas"}, Config: vmcpconfig.Config{Group: "test-group-nil-replicas"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -162,7 +162,7 @@ var _ = Describe("VirtualMCPServer Replicas Integration Tests", // confirm the reconcile completed after the external scale. var triggerGeneration int64 Eventually(func() error { - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: virtualMCPServer.Name, Namespace: namespace, @@ -182,7 +182,7 @@ var _ = Describe("VirtualMCPServer Replicas Integration Tests", // Wait until the controller has processed at least triggerGeneration, // confirming a reconciliation ran after the spec change. Eventually(func() (int64, error) { - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: virtualMCPServer.Name, Namespace: namespace, diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_sessionstorage_cel_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_sessionstorage_cel_test.go index 08573e7329..45b6043196 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_sessionstorage_cel_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_sessionstorage_cel_test.go @@ -9,19 +9,19 @@ import ( . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) -func newVirtualMCPServerWithSessionStorage(name string, ss *mcpv1alpha1.SessionStorageConfig) *mcpv1alpha1.VirtualMCPServer { - return &mcpv1alpha1.VirtualMCPServer{ +func newVirtualMCPServerWithSessionStorage(name string, ss *mcpv1beta1.SessionStorageConfig) *mcpv1beta1.VirtualMCPServer { + return &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, Config: vmcpconfig.Config{ @@ -36,7 +36,7 @@ var _ = Describe("CEL Validation for SessionStorageConfig on VirtualMCPServer", Label("k8s", "cel", "validation"), func() { Context("provider=redis", func() { It("should reject when address is missing", func() { - vmcp := newVirtualMCPServerWithSessionStorage("vmcp-redis-no-addr", &mcpv1alpha1.SessionStorageConfig{ + vmcp := newVirtualMCPServerWithSessionStorage("vmcp-redis-no-addr", &mcpv1beta1.SessionStorageConfig{ Provider: "redis", }) err := k8sClient.Create(ctx, vmcp) @@ -45,7 +45,7 @@ var _ = Describe("CEL Validation for SessionStorageConfig on VirtualMCPServer", }) It("should reject when address is empty string", func() { - vmcp := newVirtualMCPServerWithSessionStorage("vmcp-redis-empty-addr", &mcpv1alpha1.SessionStorageConfig{ + vmcp := newVirtualMCPServerWithSessionStorage("vmcp-redis-empty-addr", &mcpv1beta1.SessionStorageConfig{ Provider: "redis", Address: "", }) @@ -54,7 +54,7 @@ var _ = Describe("CEL Validation for SessionStorageConfig on VirtualMCPServer", }) It("should accept when address is set", func() { - vmcp := newVirtualMCPServerWithSessionStorage("vmcp-redis-with-addr", &mcpv1alpha1.SessionStorageConfig{ + vmcp := newVirtualMCPServerWithSessionStorage("vmcp-redis-with-addr", &mcpv1beta1.SessionStorageConfig{ Provider: "redis", Address: "redis:6379", }) @@ -63,7 +63,7 @@ var _ = Describe("CEL Validation for SessionStorageConfig on VirtualMCPServer", }) It("should reject negative DB number", func() { - vmcp := newVirtualMCPServerWithSessionStorage("vmcp-redis-neg-db", &mcpv1alpha1.SessionStorageConfig{ + vmcp := newVirtualMCPServerWithSessionStorage("vmcp-redis-neg-db", &mcpv1beta1.SessionStorageConfig{ Provider: "redis", Address: "redis:6379", DB: -1, @@ -75,7 +75,7 @@ var _ = Describe("CEL Validation for SessionStorageConfig on VirtualMCPServer", Context("provider=memory", func() { It("should accept without address", func() { - vmcp := newVirtualMCPServerWithSessionStorage("vmcp-memory-no-addr", &mcpv1alpha1.SessionStorageConfig{ + vmcp := newVirtualMCPServerWithSessionStorage("vmcp-memory-no-addr", &mcpv1beta1.SessionStorageConfig{ Provider: "memory", }) err := k8sClient.Create(ctx, vmcp) diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go index 54f0356305..84a06f8ed9 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go @@ -16,7 +16,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/yaml" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -30,9 +30,9 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", Context("VirtualMCPServer with TelemetryConfigRef should track config hash in status", Ordered, func() { var ( - mcpGroup *mcpv1alpha1.MCPGroup - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig - virtualMCPServer *mcpv1alpha1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + telemetryConfig *mcpv1beta1.MCPTelemetryConfig + virtualMCPServer *mcpv1beta1.VirtualMCPServer ) BeforeAll(func() { @@ -44,34 +44,34 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", Expect(err).NotTo(HaveOccurred()) } - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group-telemetry-hash", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for telemetry config hash test", }, } Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) - telemetryConfig = &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig = &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-telemetry-vmcp-hash", Namespace: namespace, }, } - telemetryConfig.Spec.OpenTelemetry = &mcpv1alpha1.MCPTelemetryOTelConfig{ + telemetryConfig.Spec.OpenTelemetry = &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "https://otel-collector:4317", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, - Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, + Metrics: &mcpv1beta1.OpenTelemetryMetricsConfig{Enabled: true}, } Expect(k8sClient.Create(ctx, telemetryConfig)).Should(Succeed()) // Wait for the MCPTelemetryConfig controller to set ConfigHash Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: namespace, @@ -79,18 +79,18 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", return err == nil && fetched.Status.ConfigHash != "" }, timeout, interval).Should(BeTrue()) - virtualMCPServer = &mcpv1alpha1.VirtualMCPServer{ + virtualMCPServer = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp-telemetry-hash", Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group-telemetry-hash"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-telemetry-hash"}, Config: vmcpconfig.Config{Group: "test-group-telemetry-hash"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "test-telemetry-vmcp-hash", }, }, @@ -111,7 +111,7 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", It("should set status.telemetryConfigHash to a non-empty value", func() { Eventually(func() string { - fetched := &mcpv1alpha1.VirtualMCPServer{} + fetched := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: virtualMCPServer.Name, Namespace: namespace, @@ -125,7 +125,7 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", It("should set TelemetryConfigRefValidated condition to True", func() { Eventually(func() bool { - fetched := &mcpv1alpha1.VirtualMCPServer{} + fetched := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: virtualMCPServer.Name, Namespace: namespace, @@ -134,9 +134,9 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", return false } for _, cond := range fetched.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated { + if cond.Type == mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated { return cond.Status == metav1.ConditionTrue && - cond.Reason == mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid + cond.Reason == mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid } } return false @@ -173,40 +173,40 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", Context("VirtualMCPServer should update when MCPTelemetryConfig spec changes", Ordered, func() { var ( - mcpGroup *mcpv1alpha1.MCPGroup - telemetryConfig *mcpv1alpha1.MCPTelemetryConfig - virtualMCPServer *mcpv1alpha1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + telemetryConfig *mcpv1beta1.MCPTelemetryConfig + virtualMCPServer *mcpv1beta1.VirtualMCPServer initialHash string ) BeforeAll(func() { - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group-telemetry-update", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for telemetry config update test", }, } Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) - telemetryConfig = &mcpv1alpha1.MCPTelemetryConfig{ + telemetryConfig = &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-telemetry-vmcp-update", Namespace: namespace, }, } - telemetryConfig.Spec.OpenTelemetry = &mcpv1alpha1.MCPTelemetryOTelConfig{ + telemetryConfig.Spec.OpenTelemetry = &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "https://otel-collector:4317", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, } Expect(k8sClient.Create(ctx, telemetryConfig)).Should(Succeed()) // Wait for the MCPTelemetryConfig controller to set ConfigHash Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: namespace, @@ -214,18 +214,18 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", return err == nil && fetched.Status.ConfigHash != "" }, timeout, interval).Should(BeTrue()) - virtualMCPServer = &mcpv1alpha1.VirtualMCPServer{ + virtualMCPServer = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp-telemetry-update", Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group-telemetry-update"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-telemetry-update"}, Config: vmcpconfig.Config{Group: "test-group-telemetry-update"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "test-telemetry-vmcp-update", }, }, @@ -234,7 +234,7 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", // Wait for the initial hash to be propagated to the VirtualMCPServer Eventually(func() bool { - fetched := &mcpv1alpha1.VirtualMCPServer{} + fetched := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: virtualMCPServer.Name, Namespace: namespace, @@ -259,7 +259,7 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", It("should update telemetryConfigHash when MCPTelemetryConfig spec changes", func() { // Update the MCPTelemetryConfig endpoint to trigger a hash change Eventually(func() error { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: telemetryConfig.Name, Namespace: namespace, @@ -272,7 +272,7 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", // Verify the VirtualMCPServer's telemetryConfigHash changes Eventually(func() bool { - fetched := &mcpv1alpha1.VirtualMCPServer{} + fetched := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: virtualMCPServer.Name, Namespace: namespace, @@ -308,34 +308,34 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", Context("VirtualMCPServer referencing non-existent MCPTelemetryConfig", Ordered, func() { var ( - mcpGroup *mcpv1alpha1.MCPGroup - virtualMCPServer *mcpv1alpha1.VirtualMCPServer + mcpGroup *mcpv1beta1.MCPGroup + virtualMCPServer *mcpv1beta1.VirtualMCPServer ) BeforeAll(func() { - mcpGroup = &mcpv1alpha1.MCPGroup{ + mcpGroup = &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "test-group-telemetry-notfound", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test group for telemetry config not found test", }, } Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) - virtualMCPServer = &mcpv1alpha1.VirtualMCPServer{ + virtualMCPServer = &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp-telemetry-notfound", Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group-telemetry-notfound"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-telemetry-notfound"}, Config: vmcpconfig.Config{Group: "test-group-telemetry-notfound"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "nonexistent-telemetry-config", }, }, @@ -350,7 +350,7 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", It("should set TelemetryConfigRefValidated condition to False with reason TelemetryConfigRefNotFound", func() { Eventually(func() bool { - fetched := &mcpv1alpha1.VirtualMCPServer{} + fetched := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: virtualMCPServer.Name, Namespace: namespace, @@ -359,9 +359,9 @@ var _ = Describe("VirtualMCPServer TelemetryConfig Integration", return false } for _, cond := range fetched.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated { + if cond.Type == mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated { return cond.Status == metav1.ConditionFalse && - cond.Reason == mcpv1alpha1.ConditionReasonVirtualMCPServerTelemetryConfigRefNotFound + cond.Reason == mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefNotFound } } return false diff --git a/cmd/vmcp/README.md b/cmd/vmcp/README.md index 4f447b597e..7ac6481fa9 100644 --- a/cmd/vmcp/README.md +++ b/cmd/vmcp/README.md @@ -244,7 +244,7 @@ spec: When deploying vMCP via the **ToolHive operator** with Session Management V2 enabled, the HMAC secret is **automatically generated and managed** for you: ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: my-vmcp diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 07da31b419..88bdf37769 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -10,14 +10,14 @@ webhooks: service: name: webhook-service namespace: system - path: /validate-toolhive-stacklok-dev-v1alpha1-mcpexternalauthconfig + path: /validate-toolhive-stacklok-dev-v1beta1-mcpexternalauthconfig failurePolicy: Fail name: vmcpexternalauthconfig.kb.io rules: - apiGroups: - toolhive.stacklok.dev apiVersions: - - v1alpha1 + - v1beta1 operations: - CREATE - UPDATE @@ -30,14 +30,14 @@ webhooks: service: name: webhook-service namespace: system - path: /validate-toolhive-stacklok-dev-v1alpha1-virtualmcpcompositetooldefinition + path: /validate-toolhive-stacklok-dev-v1beta1-virtualmcpcompositetooldefinition failurePolicy: Fail name: vvirtualmcpcompositetooldefinition.kb.io rules: - apiGroups: - toolhive.stacklok.dev apiVersions: - - v1alpha1 + - v1beta1 operations: - CREATE - UPDATE @@ -50,14 +50,14 @@ webhooks: service: name: webhook-service namespace: system - path: /validate-toolhive-stacklok-dev-v1alpha1-virtualmcpserver + path: /validate-toolhive-stacklok-dev-v1beta1-virtualmcpserver failurePolicy: Fail name: vvirtualmcpserver.kb.io rules: - apiGroups: - toolhive.stacklok.dev apiVersions: - - v1alpha1 + - v1beta1 operations: - CREATE - UPDATE diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_embeddingservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_embeddingservers.yaml index b47cbede0c..ab99253a70 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_embeddingservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_embeddingservers.yaml @@ -35,7 +35,353 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: EmbeddingServer is the deprecated v1alpha1 version of the EmbeddingServer + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: EmbeddingServerSpec defines the desired state of EmbeddingServer + properties: + args: + description: Args are additional arguments to pass to the embedding + inference server + items: + type: string + type: array + x-kubernetes-list-type: atomic + env: + description: Env are environment variables to set in the container + items: + description: EnvVar represents an environment variable in a container + properties: + name: + description: Name of the environment variable + type: string + value: + description: Value of the environment variable + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + hfTokenSecretRef: + description: |- + HFTokenSecretRef is a reference to a Kubernetes Secret containing the huggingface token. + If provided, the secret value will be provided to the embedding server for authentication with huggingface. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + image: + default: ghcr.io/huggingface/text-embeddings-inference:cpu-latest + description: |- + Image is the container image for the embedding inference server. + Images must be from HuggingFace Text Embeddings Inference (https://github.com/huggingface/text-embeddings-inference). + type: string + imagePullPolicy: + default: IfNotPresent + description: ImagePullPolicy defines the pull policy for the container + image + enum: + - Always + - Never + - IfNotPresent + type: string + model: + default: BAAI/bge-small-en-v1.5 + description: Model is the HuggingFace embedding model to use (e.g., + "sentence-transformers/all-MiniLM-L6-v2") + type: string + modelCache: + description: |- + ModelCache configures persistent storage for downloaded models + When enabled, models are cached in a PVC and reused across pod restarts + properties: + accessMode: + default: ReadWriteOnce + description: AccessMode is the access mode for the PVC + enum: + - ReadWriteOnce + - ReadWriteMany + - ReadOnlyMany + type: string + enabled: + default: true + description: Enabled controls whether model caching is enabled + type: boolean + size: + default: 10Gi + description: Size is the size of the PVC for model caching (e.g., + "10Gi") + type: string + storageClassName: + description: |- + StorageClassName is the storage class to use for the PVC + If not specified, uses the cluster's default storage class + type: string + type: object + podTemplateSpec: + description: |- + PodTemplateSpec allows customizing the pod (node selection, tolerations, etc.) + This field accepts a PodTemplateSpec object as JSON/YAML. + Note that to modify the specific container the embedding server runs in, you must specify + the 'embedding' container name in the PodTemplateSpec. + type: object + x-kubernetes-preserve-unknown-fields: true + port: + default: 8080 + description: Port is the port to expose the embedding service on + format: int32 + maximum: 65535 + minimum: 1 + type: integer + replicas: + default: 1 + description: Replicas is the number of embedding server replicas to + run + format: int32 + minimum: 1 + type: integer + resourceOverrides: + description: ResourceOverrides allows overriding annotations and labels + for resources created by the operator + properties: + persistentVolumeClaim: + description: PersistentVolumeClaim defines overrides for the PVC + resource + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + type: object + service: + description: Service defines overrides for the Service resource + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + type: object + statefulSet: + description: StatefulSet defines overrides for the StatefulSet + resource + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + podTemplateMetadataOverrides: + description: PodTemplateMetadataOverrides defines metadata + overrides for the pod template + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + type: object + type: object + type: object + resources: + description: Resources defines compute resources for the embedding + server + properties: + limits: + description: Limits describes the maximum amount of compute resources + allowed + properties: + cpu: + description: CPU is the CPU limit in cores (e.g., "500m" for + 0.5 cores) + type: string + memory: + description: Memory is the memory limit in bytes (e.g., "64Mi" + for 64 megabytes) + type: string + type: object + requests: + description: Requests describes the minimum amount of compute + resources required + properties: + cpu: + description: CPU is the CPU limit in cores (e.g., "500m" for + 0.5 cores) + type: string + memory: + description: Memory is the memory limit in bytes (e.g., "64Mi" + for 64 megabytes) + type: string + type: object + type: object + type: object + status: + description: EmbeddingServerStatus defines the observed state of EmbeddingServer + properties: + conditions: + description: Conditions represent the latest available observations + of the EmbeddingServer's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + message: + description: Message provides additional information about the current + phase + type: string + observedGeneration: + description: ObservedGeneration reflects the generation most recently + observed by the controller + format: int64 + type: integer + phase: + description: Phase is the current phase of the EmbeddingServer + enum: + - Pending + - Downloading + - Ready + - Failed + - Terminating + type: string + readyReplicas: + description: ReadyReplicas is the number of ready replicas + format: int32 + type: integer + url: + description: URL is the URL where the embedding service can be accessed + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .spec.model + name: Model + type: string + - jsonPath: .status.readyReplicas + name: Ready + type: integer + - jsonPath: .status.url + name: URL + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: EmbeddingServer is the Schema for the embeddingservers API diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index be7e9a3dd0..7f9a3d2a7f 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -32,7 +32,1025 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPExternalAuthConfig is the deprecated v1alpha1 version of the + MCPExternalAuthConfig resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPExternalAuthConfigSpec defines the desired state of MCPExternalAuthConfig. + MCPExternalAuthConfig resources are namespace-scoped and can only be referenced by + MCPServer resources in the same namespace. + properties: + awsSts: + description: |- + AWSSts configures AWS STS authentication with SigV4 request signing + Only used when Type is "awsSts" + properties: + fallbackRoleArn: + description: |- + FallbackRoleArn is the IAM role ARN to assume when no role mappings match + Used as the default role when RoleMappings is empty or no mapping matches + At least one of FallbackRoleArn or RoleMappings must be configured (enforced by webhook) + pattern: ^arn:(aws|aws-cn|aws-us-gov):iam::\d{12}:role/[\w+=,.@\-_/]+$ + type: string + region: + description: Region is the AWS region for the STS endpoint and + service (e.g., "us-east-1", "eu-west-1") + minLength: 1 + pattern: ^[a-z]{2}(-[a-z]+)+-\d+$ + type: string + roleClaim: + default: groups + description: |- + RoleClaim is the JWT claim to use for role mapping evaluation + Defaults to "groups" to match common OIDC group claims + type: string + roleMappings: + description: |- + RoleMappings defines claim-based role selection rules + Allows mapping JWT claims (e.g., groups, roles) to specific IAM roles + Lower priority values are evaluated first (higher priority) + items: + description: |- + RoleMapping defines a rule for mapping JWT claims to IAM roles. + Mappings are evaluated in priority order (lower number = higher priority), and the first + matching rule determines which IAM role to assume. + Exactly one of Claim or Matcher must be specified. + properties: + claim: + description: |- + Claim is a simple claim value to match against + The claim type is specified by AWSStsConfig.RoleClaim + For example, if RoleClaim is "groups", this would be a group name + Internally compiled to a CEL expression: "<claim_value>" in claims["<role_claim>"] + Mutually exclusive with Matcher + minLength: 1 + type: string + matcher: + description: |- + Matcher is a CEL expression for complex matching against JWT claims + The expression has access to a "claims" variable containing all JWT claims as map[string]any + Examples: + - "admins" in claims["groups"] + - claims["sub"] == "user123" && !("act" in claims) + Mutually exclusive with Claim + minLength: 1 + type: string + priority: + description: |- + Priority determines evaluation order (lower values = higher priority) + Allows fine-grained control over role selection precedence + When omitted, this mapping has the lowest possible priority and + configuration order acts as tie-breaker via stable sort + format: int32 + minimum: 0 + type: integer + roleArn: + description: RoleArn is the IAM role ARN to assume when + this mapping matches + pattern: ^arn:(aws|aws-cn|aws-us-gov):iam::\d{12}:role/[\w+=,.@\-_/]+$ + type: string + required: + - roleArn + type: object + type: array + x-kubernetes-list-type: atomic + service: + default: aws-mcp + description: |- + Service is the AWS service name for SigV4 signing + Defaults to "aws-mcp" for AWS MCP Server endpoints + type: string + sessionDuration: + default: 3600 + description: |- + SessionDuration is the duration in seconds for the STS session + Must be between 900 (15 minutes) and 43200 (12 hours) + Defaults to 3600 (1 hour) if not specified + format: int32 + maximum: 43200 + minimum: 900 + type: integer + sessionNameClaim: + default: sub + description: |- + SessionNameClaim is the JWT claim to use for role session name + Defaults to "sub" to use the subject claim + type: string + required: + - region + type: object + bearerToken: + description: |- + BearerToken configures bearer token authentication + Only used when Type is "bearerToken" + properties: + tokenSecretRef: + description: TokenSecretRef references a Kubernetes Secret containing + the bearer token + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - tokenSecretRef + type: object + embeddedAuthServer: + description: |- + EmbeddedAuthServer configures an embedded OAuth2/OIDC authorization server + Only used when Type is "embeddedAuthServer" + properties: + authorizationEndpointBaseUrl: + description: |- + AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint + in the OAuth discovery document. When set, the discovery document will advertise + `{authorizationEndpointBaseUrl}/oauth/authorize` instead of `{issuer}/oauth/authorize`. + All other endpoints (token, registration, JWKS) remain derived from the issuer. + This is useful when the browser-facing authorization endpoint needs to be on a + different host than the issuer used for backend-to-backend calls. + Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. + pattern: ^https?://[^\s?#]+[^/\s?#]$ + type: string + hmacSecretRefs: + description: |- + HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing + authorization codes and refresh tokens (opaque tokens). + Current secret must be at least 32 bytes and cryptographically random. + Supports secret rotation via multiple entries (first is current, rest are for verification). + If not specified, an ephemeral secret will be auto-generated (development only - + auth codes and refresh tokens will be invalid after restart). + items: + description: SecretKeyRef is a reference to a key within a Secret + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + type: array + x-kubernetes-list-type: atomic + issuer: + description: |- + Issuer is the issuer identifier for this authorization server. + This will be included in the "iss" claim of issued tokens. + Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). + pattern: ^https?://[^\s?#]+[^/\s?#]$ + type: string + signingKeySecretRefs: + description: |- + SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations. + Supports key rotation by allowing multiple keys (oldest keys are used for verification only). + If not specified, an ephemeral signing key will be auto-generated (development only - + JWTs will be invalid after restart). + items: + description: SecretKeyRef is a reference to a key within a Secret + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + maxItems: 5 + type: array + x-kubernetes-list-type: atomic + storage: + description: |- + Storage configures the storage backend for the embedded auth server. + If not specified, defaults to in-memory storage. + properties: + redis: + description: |- + Redis configures the Redis storage backend. + Required when type is "redis". + properties: + aclUserConfig: + description: ACLUserConfig configures Redis ACL user authentication. + properties: + passwordSecretRef: + description: PasswordSecretRef references a Secret + containing the Redis ACL password. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + usernameSecretRef: + description: UsernameSecretRef references a Secret + containing the Redis ACL username. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - passwordSecretRef + - usernameSecretRef + type: object + dialTimeout: + default: 5s + description: |- + DialTimeout is the timeout for establishing connections. + Format: Go duration string (e.g., "5s", "1m"). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + readTimeout: + default: 3s + description: |- + ReadTimeout is the timeout for socket reads. + Format: Go duration string (e.g., "3s", "1m"). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + sentinelConfig: + description: SentinelConfig holds Redis Sentinel configuration. + properties: + db: + default: 0 + description: DB is the Redis database number. + format: int32 + type: integer + masterName: + description: MasterName is the name of the Redis master + monitored by Sentinel. + type: string + sentinelAddrs: + description: |- + SentinelAddrs is a list of Sentinel host:port addresses. + Mutually exclusive with SentinelService. + items: + type: string + type: array + x-kubernetes-list-type: atomic + sentinelService: + description: |- + SentinelService enables automatic discovery from a Kubernetes Service. + Mutually exclusive with SentinelAddrs. + properties: + name: + description: Name of the Sentinel Service. + type: string + namespace: + description: Namespace of the Sentinel Service + (defaults to same namespace). + type: string + port: + default: 26379 + description: Port of the Sentinel service. + format: int32 + type: integer + required: + - name + type: object + required: + - masterName + type: object + sentinelTls: + description: |- + SentinelTLS configures TLS for connections to Sentinel instances. + Presence of this field enables TLS. Omit to use plaintext. + When omitted, sentinel connections use plaintext (no fallback to TLS config). + properties: + caCertSecretRef: + description: |- + CACertSecretRef references a Secret containing a PEM-encoded CA certificate + for verifying the server. When not specified, system root CAs are used. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + insecureSkipVerify: + description: |- + InsecureSkipVerify skips TLS certificate verification. + Use when connecting to services with self-signed certificates. + type: boolean + type: object + tls: + description: |- + TLS configures TLS for connections to the Redis/Valkey master. + Presence of this field enables TLS. Omit to use plaintext. + properties: + caCertSecretRef: + description: |- + CACertSecretRef references a Secret containing a PEM-encoded CA certificate + for verifying the server. When not specified, system root CAs are used. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + insecureSkipVerify: + description: |- + InsecureSkipVerify skips TLS certificate verification. + Use when connecting to services with self-signed certificates. + type: boolean + type: object + writeTimeout: + default: 3s + description: |- + WriteTimeout is the timeout for socket writes. + Format: Go duration string (e.g., "3s", "1m"). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + required: + - aclUserConfig + - sentinelConfig + type: object + type: + default: memory + description: |- + Type specifies the storage backend type. + Valid values: "memory" (default), "redis". + enum: + - memory + - redis + type: string + type: object + tokenLifespans: + description: |- + TokenLifespans configures the duration that various tokens are valid. + If not specified, defaults are applied (access: 1h, refresh: 7d, authCode: 10m). + properties: + accessTokenLifespan: + description: |- + AccessTokenLifespan is the duration that access tokens are valid. + Format: Go duration string (e.g., "1h", "30m", "24h"). + If empty, defaults to 1 hour. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + authCodeLifespan: + description: |- + AuthCodeLifespan is the duration that authorization codes are valid. + Format: Go duration string (e.g., "10m", "5m"). + If empty, defaults to 10 minutes. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + refreshTokenLifespan: + description: |- + RefreshTokenLifespan is the duration that refresh tokens are valid. + Format: Go duration string (e.g., "168h", "7d" as "168h"). + If empty, defaults to 7 days (168h). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + type: object + upstreamProviders: + description: |- + UpstreamProviders configures connections to upstream Identity Providers. + The embedded auth server delegates authentication to these providers. + MCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple. + items: + description: UpstreamProviderConfig defines configuration for + an upstream Identity Provider. + properties: + name: + description: |- + Name uniquely identifies this upstream provider. + Used for routing decisions and session binding in multi-upstream scenarios. + Must be lowercase alphanumeric with hyphens (DNS-label-like). + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ + type: string + oauth2Config: + description: |- + OAuth2Config contains OAuth 2.0-specific configuration. + Required when Type is "oauth2", must be nil when Type is "oidc". + properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object + authorizationEndpoint: + description: AuthorizationEndpoint is the URL for the + OAuth authorization endpoint. + pattern: ^https?://.*$ + type: string + clientId: + description: ClientID is the OAuth 2.0 client identifier + registered with the upstream IDP. + type: string + clientSecretRef: + description: |- + ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret. + Optional for public clients using PKCE instead of client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + redirectUri: + description: |- + RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the + URL associated with the resource (e.g., MCPServer or vMCP) using this config. + type: string + scopes: + description: Scopes are the OAuth scopes to request + from the upstream IDP. + items: + type: string + type: array + x-kubernetes-list-type: atomic + tokenEndpoint: + description: TokenEndpoint is the URL for the OAuth + token endpoint. + pattern: ^https?://.*$ + type: string + tokenResponseMapping: + description: |- + TokenResponseMapping configures custom field extraction from non-standard token responses. + Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths + instead of returning them at the top level. When set, ToolHive performs the token + exchange HTTP call directly and extracts fields using the configured dot-notation paths. + If nil, standard OAuth 2.0 token response parsing is used. + properties: + accessTokenPath: + description: |- + AccessTokenPath is the dot-notation path to the access token in the response. + Example: "authed_user.access_token" + minLength: 1 + type: string + expiresInPath: + description: |- + ExpiresInPath is the dot-notation path to the expires_in value (in seconds). + If not specified, defaults to "expires_in". + type: string + refreshTokenPath: + description: |- + RefreshTokenPath is the dot-notation path to the refresh token in the response. + If not specified, defaults to "refresh_token". + type: string + scopePath: + description: |- + ScopePath is the dot-notation path to the scope string in the response. + If not specified, defaults to "scope". + type: string + required: + - accessTokenPath + type: object + userInfo: + description: |- + UserInfo contains configuration for fetching user information from the upstream provider. + Required for OAuth2 providers to resolve user identity. + properties: + additionalHeaders: + additionalProperties: + type: string + description: |- + AdditionalHeaders contains extra headers to include in the userinfo request. + Useful for providers that require specific headers (e.g., GitHub's Accept header). + type: object + endpointUrl: + description: EndpointURL is the URL of the userinfo + endpoint. + pattern: ^https?://.*$ + type: string + fieldMapping: + description: |- + FieldMapping contains custom field mapping configuration for non-standard providers. + If nil, standard OIDC field names are used ("sub", "name", "email"). + properties: + emailFields: + description: |- + EmailFields is an ordered list of field names to try for the email address. + The first non-empty value found will be used. + Default: ["email"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + nameFields: + description: |- + NameFields is an ordered list of field names to try for the display name. + The first non-empty value found will be used. + Default: ["name"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectFields: + description: |- + SubjectFields is an ordered list of field names to try for the user ID. + The first non-empty value found will be used. + Default: ["sub"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpMethod: + description: |- + HTTPMethod is the HTTP method to use for the userinfo request. + If not specified, defaults to GET. + enum: + - GET + - POST + type: string + required: + - endpointUrl + type: object + required: + - authorizationEndpoint + - clientId + - tokenEndpoint + - userInfo + type: object + oidcConfig: + description: |- + OIDCConfig contains OIDC-specific configuration. + Required when Type is "oidc", must be nil when Type is "oauth2". + properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Note: when using access_type=offline, also set explicit scopes to avoid + the default offline_access scope being sent alongside it. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object + clientId: + description: ClientID is the OAuth 2.0 client identifier + registered with the upstream IDP. + type: string + clientSecretRef: + description: |- + ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret. + Optional for public clients using PKCE instead of client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + issuerUrl: + description: |- + IssuerURL is the OIDC issuer URL for automatic endpoint discovery. + Must be a valid HTTPS URL. + pattern: ^https://.*$ + type: string + redirectUri: + description: |- + RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the + URL associated with the resource (e.g., MCPServer or vMCP) using this config. + type: string + scopes: + description: |- + Scopes are the OAuth scopes to request from the upstream IDP. + If not specified, defaults to ["openid", "offline_access"]. + When using additionalAuthorizationParams with provider-specific refresh token + mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid + sending both offline_access and the provider-specific parameter. + items: + type: string + type: array + x-kubernetes-list-type: atomic + userInfoOverride: + description: |- + UserInfoOverride allows customizing UserInfo fetching behavior for OIDC providers. + By default, the UserInfo endpoint is discovered automatically via OIDC discovery. + Use this to override the endpoint URL, HTTP method, or field mappings for providers + that return non-standard claim names in their UserInfo response. + properties: + additionalHeaders: + additionalProperties: + type: string + description: |- + AdditionalHeaders contains extra headers to include in the userinfo request. + Useful for providers that require specific headers (e.g., GitHub's Accept header). + type: object + endpointUrl: + description: EndpointURL is the URL of the userinfo + endpoint. + pattern: ^https?://.*$ + type: string + fieldMapping: + description: |- + FieldMapping contains custom field mapping configuration for non-standard providers. + If nil, standard OIDC field names are used ("sub", "name", "email"). + properties: + emailFields: + description: |- + EmailFields is an ordered list of field names to try for the email address. + The first non-empty value found will be used. + Default: ["email"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + nameFields: + description: |- + NameFields is an ordered list of field names to try for the display name. + The first non-empty value found will be used. + Default: ["name"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectFields: + description: |- + SubjectFields is an ordered list of field names to try for the user ID. + The first non-empty value found will be used. + Default: ["sub"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpMethod: + description: |- + HTTPMethod is the HTTP method to use for the userinfo request. + If not specified, defaults to GET. + enum: + - GET + - POST + type: string + required: + - endpointUrl + type: object + required: + - clientId + - issuerUrl + type: object + type: + description: 'Type specifies the provider type: "oidc" or + "oauth2"' + enum: + - oidc + - oauth2 + type: string + required: + - name + - type + type: object + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + required: + - issuer + - upstreamProviders + type: object + headerInjection: + description: |- + HeaderInjection configures custom HTTP header injection + Only used when Type is "headerInjection" + properties: + headerName: + description: HeaderName is the name of the HTTP header to inject + minLength: 1 + type: string + valueSecretRef: + description: ValueSecretRef references a Kubernetes Secret containing + the header value + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - headerName + - valueSecretRef + type: object + tokenExchange: + description: |- + TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange + Only used when Type is "tokenExchange" + properties: + audience: + description: Audience is the target audience for the exchanged + token + type: string + clientId: + description: |- + ClientID is the OAuth 2.0 client identifier + Optional for some token exchange flows (e.g., Google Cloud Workforce Identity) + type: string + clientSecretRef: + description: |- + ClientSecretRef is a reference to a secret containing the OAuth 2.0 client secret + Optional for some token exchange flows (e.g., Google Cloud Workforce Identity) + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + externalTokenHeaderName: + description: |- + ExternalTokenHeaderName is the name of the custom header to use for the exchanged token. + If set, the exchanged token will be added to this custom header (e.g., "X-Upstream-Token"). + If empty or not set, the exchanged token will replace the Authorization header (default behavior). + type: string + scopes: + description: Scopes is a list of OAuth 2.0 scopes to request for + the exchanged token + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectProviderName: + description: |- + SubjectProviderName is the name of the upstream provider whose token is used as the + RFC 8693 subject token instead of identity.Token when performing token exchange. + When left empty and an embedded authorization server is configured on the VirtualMCPServer, + the controller automatically populates this field with the first configured upstream + provider name. Set it explicitly to override that default or to select a specific + provider when multiple upstreams are configured. + type: string + subjectTokenType: + description: |- + SubjectTokenType is the type of the incoming subject token. + Accepts short forms: "access_token" (default), "id_token", "jwt" + Or full URNs: "urn:ietf:params:oauth:token-type:access_token", + "urn:ietf:params:oauth:token-type:id_token", + "urn:ietf:params:oauth:token-type:jwt" + For Google Workload Identity Federation with OIDC providers (like Okta), use "id_token" + pattern: ^(access_token|id_token|jwt|urn:ietf:params:oauth:token-type:(access_token|id_token|jwt))?$ + type: string + tokenUrl: + description: TokenURL is the OAuth 2.0 token endpoint URL for + token exchange + type: string + required: + - audience + - tokenUrl + type: object + type: + description: Type is the type of external authentication to configure + enum: + - tokenExchange + - headerInjection + - bearerToken + - unauthenticated + - embeddedAuthServer + - awsSts + - upstreamInject + type: string + upstreamInject: + description: |- + UpstreamInject configures upstream token injection for backend requests. + Only used when Type is "upstreamInject". + properties: + providerName: + description: |- + ProviderName is the name of the upstream IDP provider whose access token + should be injected as the Authorization: Bearer header. + minLength: 1 + type: string + required: + - providerName + type: object + required: + - type + type: object + x-kubernetes-validations: + - message: tokenExchange configuration must be set if and only if type + is 'tokenExchange' + rule: 'self.type == ''tokenExchange'' ? has(self.tokenExchange) : !has(self.tokenExchange)' + - message: headerInjection configuration must be set if and only if type + is 'headerInjection' + rule: 'self.type == ''headerInjection'' ? has(self.headerInjection) + : !has(self.headerInjection)' + - message: bearerToken configuration must be set if and only if type is + 'bearerToken' + rule: 'self.type == ''bearerToken'' ? has(self.bearerToken) : !has(self.bearerToken)' + - message: embeddedAuthServer configuration must be set if and only if + type is 'embeddedAuthServer' + rule: 'self.type == ''embeddedAuthServer'' ? has(self.embeddedAuthServer) + : !has(self.embeddedAuthServer)' + - message: awsSts configuration must be set if and only if type is 'awsSts' + rule: 'self.type == ''awsSts'' ? has(self.awsSts) : !has(self.awsSts)' + - message: upstreamInject configuration must be set if and only if type + is 'upstreamInject' + rule: 'self.type == ''upstreamInject'' ? has(self.upstreamInject) : + !has(self.upstreamInject)' + - message: no configuration must be set when type is 'unauthenticated' + rule: 'self.type == ''unauthenticated'' ? (!has(self.tokenExchange) + && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) + && !has(self.awsSts) && !has(self.upstreamInject)) : true' + status: + description: MCPExternalAuthConfigStatus defines the observed state of + MCPExternalAuthConfig + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPExternalAuthConfig's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + configHash: + description: ConfigHash is a hash of the current configuration for + change detection + type: string + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this MCPExternalAuthConfig. + It corresponds to the MCPExternalAuthConfig's generation, which is updated on mutation by the API Server. + format: int64 + type: integer + referencingWorkloads: + description: |- + ReferencingWorkloads is a list of workload resources that reference this MCPExternalAuthConfig. + Each entry identifies the workload by kind and name. + items: + description: |- + WorkloadReference identifies a workload that references a shared configuration resource. + Namespace is implicit — cross-namespace references are not supported. + properties: + kind: + description: Kind is the type of workload resource + enum: + - MCPServer + - VirtualMCPServer + - MCPRemoteProxy + type: string + name: + description: Name is the name of the workload resource + minLength: 1 + type: string + required: + - kind + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .spec.type + name: Type + type: string + - jsonPath: .status.conditions[?(@.type=='Valid')].status + name: Valid + type: string + - jsonPath: .status.referencingWorkloads + name: References + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpgroups.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpgroups.yaml index 9fae43685c..4270eb0334 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpgroups.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpgroups.yaml @@ -32,7 +32,163 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPGroup is the deprecated v1alpha1 version of the MCPGroup resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: MCPGroupSpec defines the desired state of MCPGroup + properties: + description: + description: Description provides human-readable context + type: string + type: object + status: + description: MCPGroupStatus defines observed state + properties: + conditions: + description: Conditions represent observations + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + entries: + description: Entries lists MCPServerEntry names in this group + items: + type: string + type: array + x-kubernetes-list-type: set + entryCount: + description: EntryCount is the number of MCPServerEntries + format: int32 + type: integer + observedGeneration: + description: ObservedGeneration reflects the generation most recently + observed by the controller + format: int64 + type: integer + phase: + default: Pending + description: Phase indicates current state + enum: + - Ready + - Pending + - Failed + type: string + remoteProxies: + description: RemoteProxies lists MCPRemoteProxy names in this group + items: + type: string + type: array + x-kubernetes-list-type: set + remoteProxyCount: + description: RemoteProxyCount is the number of MCPRemoteProxies + format: int32 + type: integer + serverCount: + description: ServerCount is the number of MCPServers + format: int32 + type: integer + servers: + description: Servers lists MCPServer names in this group + items: + type: string + type: array + x-kubernetes-list-type: set + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.serverCount + name: Servers + type: integer + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.conditions[?(@.type=='MCPServersChecked')].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: MCPGroup is the Schema for the mcpgroups API diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpoidcconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpoidcconfigs.yaml index acf6ea3887..8890392814 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpoidcconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpoidcconfigs.yaml @@ -31,7 +31,302 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPOIDCConfig is the deprecated v1alpha1 version of the MCPOIDCConfig + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPOIDCConfigSpec defines the desired state of MCPOIDCConfig. + MCPOIDCConfig resources are namespace-scoped and can only be referenced by + MCPServer resources in the same namespace. + properties: + inline: + description: |- + Inline contains direct OIDC configuration. + Only used when Type is "inline". + properties: + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing the CA certificate bundle. + When specified, ToolHive auto-mounts the ConfigMap and auto-computes ThvCABundlePath. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + If Key is not specified, it defaults to "ca.crt". + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + clientId: + description: ClientID is the OIDC client ID + type: string + clientSecretRef: + description: ClientSecretRef is a reference to a Kubernetes Secret + containing the client secret + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + insecureAllowHTTP: + default: false + description: |- + InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing. + WARNING: This is insecure and should NEVER be used in production. + type: boolean + introspectionUrl: + description: IntrospectionURL is the URL for token introspection + endpoint + type: string + issuer: + description: Issuer is the OIDC issuer URL + type: string + jwksAllowPrivateIP: + default: false + description: |- + JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. + Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). + type: boolean + jwksAuthTokenPath: + description: JWKSAuthTokenPath is the path to file containing + bearer token for JWKS/OIDC requests + type: string + jwksUrl: + description: JWKSURL is the URL to fetch the JWKS from + type: string + protectedResourceAllowPrivateIP: + default: false + description: |- + ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. + Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). + type: boolean + required: + - issuer + type: object + kubernetesServiceAccount: + description: |- + KubernetesServiceAccount configures OIDC for Kubernetes service account token validation. + Only used when Type is "kubernetesServiceAccount". + properties: + introspectionUrl: + description: |- + IntrospectionURL is the URL for token introspection endpoint. + If empty, OIDC discovery will be used to automatically determine the introspection URL. + type: string + issuer: + default: https://kubernetes.default.svc + description: Issuer is the OIDC issuer URL. + type: string + jwksUrl: + description: |- + JWKSURL is the URL to fetch the JWKS from. + If empty, OIDC discovery will be used to automatically determine the JWKS URL. + type: string + namespace: + description: |- + Namespace is the namespace of the service account. + If empty, uses the MCPServer's namespace. + type: string + serviceAccount: + description: |- + ServiceAccount is the name of the service account to validate tokens for. + If empty, uses the pod's service account. + type: string + useClusterAuth: + description: |- + UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token. + When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification + and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication. + Defaults to true if not specified. + type: boolean + type: object + type: + description: Type is the type of OIDC configuration source + enum: + - kubernetesServiceAccount + - inline + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: kubernetesServiceAccount must be set when type is 'kubernetesServiceAccount', + and must not be set otherwise + rule: 'self.type == ''kubernetesServiceAccount'' ? has(self.kubernetesServiceAccount) + : !has(self.kubernetesServiceAccount)' + - message: inline must be set when type is 'inline', and must not be set + otherwise + rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + status: + description: MCPOIDCConfigStatus defines the observed state of MCPOIDCConfig + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPOIDCConfig's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + configHash: + description: ConfigHash is a hash of the current configuration for + change detection + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + for this MCPOIDCConfig. + format: int64 + type: integer + referencingWorkloads: + description: |- + ReferencingWorkloads is a list of workload resources that reference this MCPOIDCConfig. + Each entry identifies the workload by kind and name. + items: + description: |- + WorkloadReference identifies a workload that references a shared configuration resource. + Namespace is implicit — cross-namespace references are not supported. + properties: + kind: + description: Kind is the type of workload resource + enum: + - MCPServer + - VirtualMCPServer + - MCPRemoteProxy + type: string + name: + description: Name is the name of the workload resource + minLength: 1 + type: string + required: + - kind + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .spec.type + name: Source + type: string + - jsonPath: .status.conditions[?(@.type=='Valid')].status + name: Valid + type: string + - jsonPath: .status.referencingWorkloads + name: References + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpregistries.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpregistries.yaml index 01c5b2342a..fea184018f 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpregistries.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpregistries.yaml @@ -35,7 +35,246 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPRegistry is the deprecated v1alpha1 version of the MCPRegistry + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: MCPRegistrySpec defines the desired state of MCPRegistry + properties: + configYAML: + description: |- + ConfigYAML is the complete registry server config.yaml content. + The operator creates a ConfigMap from this string and mounts it + at /config/config.yaml in the registry-api container. + The operator does NOT parse, validate, or transform this content — + configuration validation is the registry server's responsibility. + + Security note: this content is stored in a ConfigMap, not a Secret. + Do not inline credentials (passwords, tokens, client secrets) in this + field. Instead, reference credentials via file paths and mount the + actual secrets using the Volumes and VolumeMounts fields. For database + passwords, use PGPassSecretRef. + minLength: 1 + type: string + displayName: + description: DisplayName is a human-readable name for the registry. + type: string + pgpassSecretRef: + description: "PGPassSecretRef references a Secret containing a pre-created + pgpass file.\n\nWhy this is a dedicated field instead of a regular + volume/volumeMount:\nPostgreSQL's libpq rejects pgpass files that + aren't mode 0600. Kubernetes\nsecret volumes mount files as root-owned, + and the registry-api container\nruns as non-root (UID 65532). A + root-owned 0600 file is unreadable by\nUID 65532, and using fsGroup + changes permissions to 0640 which libpq also\nrejects. The only + solution is an init container that copies the file to an\nemptyDir + as the app user and runs chmod 0600. This cannot be expressed\nthrough + volumes/volumeMounts alone -- it requires an init container, two\nextra + volumes (secret + emptyDir), a subPath mount, and an environment\nvariable, + all wired together correctly.\n\nWhen specified, the operator generates + all of that plumbing invisibly.\nThe user creates the Secret with + pgpass-formatted content; the operator\nhandles only the Kubernetes + permission mechanics.\n\nExample Secret:\n\n\tapiVersion: v1\n\tkind: + Secret\n\tmetadata:\n\t name: my-pgpass\n\tstringData:\n\t .pgpass: + |\n\t postgres:5432:registry:db_app:mypassword\n\t postgres:5432:registry:db_migrator:otherpassword\n\nThen + reference it:\n\n\tpgpassSecretRef:\n\t name: my-pgpass\n\t key: + .pgpass" + properties: + key: + description: The key of the secret to select from. Must be a + valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + podTemplateSpec: + description: |- + PodTemplateSpec defines the pod template to use for the registry API server. + This allows for customizing the pod configuration beyond what is provided by the other fields. + Note that to modify the specific container the registry API server runs in, you must specify + the `registry-api` container name in the PodTemplateSpec. + This field accepts a PodTemplateSpec object as JSON/YAML. + type: object + x-kubernetes-preserve-unknown-fields: true + volumeMounts: + description: |- + VolumeMounts defines additional volume mounts for the registry-api container. + Each entry is a standard Kubernetes VolumeMount object (JSON/YAML). + The operator appends them to the container's volume mounts alongside the config mount. + + Mount paths must match the file paths referenced in configYAML. + For example, if configYAML references passwordFile: /secrets/git-creds/token, + a corresponding volume mount must exist with mountPath: /secrets/git-creds. + items: + x-kubernetes-preserve-unknown-fields: true + type: array + x-kubernetes-list-type: atomic + x-kubernetes-preserve-unknown-fields: true + volumes: + description: |- + Volumes defines additional volumes to add to the registry API pod. + Each entry is a standard Kubernetes Volume object (JSON/YAML). + The operator appends them to the pod spec alongside its own config volume. + + Use these to mount: + - Secrets (git auth tokens, OAuth client secrets, CA certs) + - ConfigMaps (registry data files) + - PersistentVolumeClaims (registry data on persistent storage) + - Any other volume type the registry server needs + items: + x-kubernetes-preserve-unknown-fields: true + type: array + x-kubernetes-list-type: atomic + x-kubernetes-preserve-unknown-fields: true + required: + - configYAML + type: object + status: + description: MCPRegistryStatus defines the observed state of MCPRegistry + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPRegistry's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + message: + description: Message provides additional information about the current + phase + type: string + observedGeneration: + description: ObservedGeneration reflects the generation most recently + observed by the controller + format: int64 + type: integer + phase: + description: Phase represents the current overall phase of the MCPRegistry + enum: + - Pending + - Ready + - Failed + - Terminating + type: string + readyReplicas: + description: ReadyReplicas is the number of ready registry API replicas + format: int32 + type: integer + url: + description: URL is the URL where the registry API can be accessed + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .status.readyReplicas + name: Replicas + type: integer + - jsonPath: .status.url + name: URL + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: MCPRegistry is the Schema for the mcpregistries API diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml index 7313692b1e..915417ddd9 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -35,7 +35,574 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPRemoteProxy is the deprecated v1alpha1 version of the MCPRemoteProxy + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: MCPRemoteProxySpec defines the desired state of MCPRemoteProxy + properties: + audit: + description: Audit defines audit logging configuration for the proxy + properties: + enabled: + default: false + description: |- + Enabled controls whether audit logging is enabled + When true, enables audit logging with default configuration + type: boolean + type: object + authServerRef: + description: |- + AuthServerRef optionally references a resource that configures an embedded + OAuth 2.0/OIDC authorization server to authenticate MCP clients. + Currently the only supported kind is MCPExternalAuthConfig (type: embeddedAuthServer). + properties: + kind: + default: MCPExternalAuthConfig + description: Kind identifies the type of the referenced resource. + enum: + - MCPExternalAuthConfig + type: string + name: + description: Name is the name of the referenced resource in the + same namespace. + minLength: 1 + type: string + required: + - kind + - name + type: object + authzConfig: + description: AuthzConfig defines authorization policy configuration + for the proxy + properties: + configMap: + description: |- + ConfigMap references a ConfigMap containing authorization configuration + Only used when Type is "configMap" + properties: + key: + default: authz.json + description: Key is the key in the ConfigMap that contains + the authorization configuration + type: string + name: + description: Name is the name of the ConfigMap + type: string + required: + - name + type: object + inline: + description: |- + Inline contains direct authorization configuration + Only used when Type is "inline" + properties: + entitiesJson: + default: '[]' + description: EntitiesJSON is a JSON string representing Cedar + entities + type: string + policies: + description: Policies is a list of Cedar policy strings + items: + type: string + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - policies + type: object + type: + default: configMap + description: Type is the type of authorization configuration + enum: + - configMap + - inline + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: configMap must be set when type is 'configMap', and must + not be set otherwise + rule: 'self.type == ''configMap'' ? has(self.configMap) : !has(self.configMap)' + - message: inline must be set when type is 'inline', and must not + be set otherwise + rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + endpointPrefix: + description: |- + EndpointPrefix is the path prefix to prepend to SSE endpoint URLs. + This is used to handle path-based ingress routing scenarios where the ingress + strips a path prefix before forwarding to the backend. + type: string + externalAuthConfigRef: + description: |- + ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange. + When specified, the proxy will exchange validated incoming tokens for remote service tokens. + The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPRemoteProxy. + properties: + name: + description: Name is the name of the MCPExternalAuthConfig resource + type: string + required: + - name + type: object + groupRef: + description: |- + GroupRef references the MCPGroup this proxy belongs to. + The referenced MCPGroup must be in the same namespace. + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object + headerForward: + description: |- + HeaderForward configures headers to inject into requests to the remote MCP server. + Use this to add custom headers like X-Tenant-ID or correlation IDs. + properties: + addHeadersFromSecret: + description: AddHeadersFromSecret references Kubernetes Secrets + for sensitive header values. + items: + description: HeaderFromSecret defines a header whose value comes + from a Kubernetes Secret. + properties: + headerName: + description: HeaderName is the HTTP header name (e.g., "X-API-Key") + maxLength: 255 + minLength: 1 + type: string + valueSecretRef: + description: ValueSecretRef references the Secret and key + containing the header value + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - headerName + - valueSecretRef + type: object + type: array + x-kubernetes-list-map-keys: + - headerName + x-kubernetes-list-type: map + addPlaintextHeaders: + additionalProperties: + type: string + description: |- + AddPlaintextHeaders is a map of header names to literal values to inject into requests. + WARNING: Values are stored in plaintext and visible via kubectl commands. + Use addHeadersFromSecret for sensitive data like API keys or tokens. + type: object + type: object + oidcConfigRef: + description: |- + OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. + The referenced MCPOIDCConfig must exist in the same namespace as this MCPRemoteProxy. + Per-server overrides (audience, scopes) are specified here; shared provider config + lives in the MCPOIDCConfig resource. + properties: + audience: + description: |- + Audience is the expected audience for token validation. + This MUST be unique per server to prevent token replay attacks. + minLength: 1 + type: string + name: + description: Name is the name of the MCPOIDCConfig resource + minLength: 1 + type: string + resourceUrl: + description: |- + ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728). + When the server is exposed via Ingress or gateway, set this to the external + URL that MCP clients connect to. If not specified, defaults to the internal + Kubernetes service URL. + type: string + scopes: + description: |- + Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728). + If empty, defaults to ["openid"]. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - audience + - name + type: object + proxyPort: + default: 8080 + description: ProxyPort is the port to expose the MCP proxy on + format: int32 + maximum: 65535 + minimum: 1 + type: integer + remoteUrl: + description: RemoteURL is the URL of the remote MCP server to proxy + pattern: ^https?:// + type: string + resourceOverrides: + description: ResourceOverrides allows overriding annotations and labels + for resources created by the operator + properties: + proxyDeployment: + description: ProxyDeployment defines overrides for the Proxy Deployment + resource (toolhive proxy) + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + env: + description: |- + Env are environment variables to set in the proxy container (thv run process) + These affect the toolhive proxy itself, not the MCP server it manages + Use TOOLHIVE_DEBUG=true to enable debug logging in the proxy + items: + description: EnvVar represents an environment variable in + a container + properties: + name: + description: Name of the environment variable + type: string + value: + description: Value of the environment variable + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + imagePullSecrets: + description: |- + ImagePullSecrets allows specifying image pull secrets for the proxy runner + These are applied to both the Deployment and the ServiceAccount + items: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + podTemplateMetadataOverrides: + description: ResourceMetadataOverrides defines metadata overrides + for a resource + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + type: object + type: object + proxyService: + description: ProxyService defines overrides for the Proxy Service + resource (points to the proxy deployment) + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + type: object + type: object + resources: + description: Resources defines the resource requirements for the proxy + container + properties: + limits: + description: Limits describes the maximum amount of compute resources + allowed + properties: + cpu: + description: CPU is the CPU limit in cores (e.g., "500m" for + 0.5 cores) + type: string + memory: + description: Memory is the memory limit in bytes (e.g., "64Mi" + for 64 megabytes) + type: string + type: object + requests: + description: Requests describes the minimum amount of compute + resources required + properties: + cpu: + description: CPU is the CPU limit in cores (e.g., "500m" for + 0.5 cores) + type: string + memory: + description: Memory is the memory limit in bytes (e.g., "64Mi" + for 64 megabytes) + type: string + type: object + type: object + serviceAccount: + description: |- + ServiceAccount is the name of an already existing service account to use by the proxy. + If not specified, a ServiceAccount will be created automatically and used by the proxy. + type: string + sessionAffinity: + default: ClientIP + description: |- + SessionAffinity controls whether the Service routes repeated client connections to the same pod. + MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default. + Set to "None" for stateless servers or when using an external load balancer with its own affinity. + enum: + - ClientIP + - None + type: string + telemetryConfigRef: + description: |- + TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. + The referenced MCPTelemetryConfig must exist in the same namespace as this MCPRemoteProxy. + Cross-namespace references are not supported for security and isolation reasons. + properties: + name: + description: Name is the name of the MCPTelemetryConfig resource + minLength: 1 + type: string + serviceName: + description: |- + ServiceName overrides the telemetry service name for this specific server. + This MUST be unique per server for proper observability (e.g., distinguishing + traces and metrics from different servers sharing the same collector). + If empty, defaults to the server name with "thv-" prefix at runtime. + type: string + required: + - name + type: object + toolConfigRef: + description: |- + ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming. + The referenced MCPToolConfig must exist in the same namespace as this MCPRemoteProxy. + Cross-namespace references are not supported for security and isolation reasons. + If specified, this allows filtering and overriding tools from the remote MCP server. + properties: + name: + description: Name is the name of the MCPToolConfig resource in + the same namespace + type: string + required: + - name + type: object + transport: + default: streamable-http + description: Transport is the transport method for the remote proxy + (sse or streamable-http) + enum: + - sse + - streamable-http + type: string + trustProxyHeaders: + default: false + description: |- + TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies + When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, + and X-Forwarded-Prefix headers to construct endpoint URLs + type: boolean + required: + - remoteUrl + type: object + status: + description: MCPRemoteProxyStatus defines the observed state of MCPRemoteProxy + properties: + authServerConfigHash: + description: |- + AuthServerConfigHash is the hash of the referenced authServerRef spec, + used to detect configuration changes and trigger reconciliation. + type: string + conditions: + description: Conditions represent the latest available observations + of the MCPRemoteProxy's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + externalAuthConfigHash: + description: ExternalAuthConfigHash is the hash of the referenced + MCPExternalAuthConfig spec + type: string + externalUrl: + description: ExternalURL is the external URL where the proxy can be + accessed (if exposed externally) + type: string + message: + description: Message provides additional information about the current + phase + type: string + observedGeneration: + description: ObservedGeneration reflects the generation of the most + recently observed MCPRemoteProxy + format: int64 + type: integer + oidcConfigHash: + description: OIDCConfigHash is the hash of the referenced MCPOIDCConfig + spec for change detection + type: string + phase: + description: Phase is the current phase of the MCPRemoteProxy + enum: + - Pending + - Ready + - Failed + - Terminating + type: string + telemetryConfigHash: + description: TelemetryConfigHash stores the hash of the referenced + MCPTelemetryConfig for change detection + type: string + toolConfigHash: + description: ToolConfigHash stores the hash of the referenced ToolConfig + for change detection + type: string + url: + description: URL is the internal cluster URL where the proxy can be + accessed + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .spec.remoteUrl + name: Remote URL + type: string + - jsonPath: .status.url + name: URL + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpserverentries.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpserverentries.yaml index 85b4f8bf51..78c2523eca 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpserverentries.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpserverentries.yaml @@ -34,7 +34,261 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPServerEntry is the deprecated v1alpha1 version of the MCPServerEntry + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPServerEntrySpec defines the desired state of MCPServerEntry. + MCPServerEntry is a zero-infrastructure catalog entry that declares a remote MCP + server endpoint. Unlike MCPRemoteProxy, it creates no pods, services, or deployments. + properties: + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing CA certificates for TLS verification + when connecting to the remote MCP server. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + If Key is not specified, it defaults to "ca.crt". + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + externalAuthConfigRef: + description: |- + ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange + when connecting to the remote MCP server. The referenced MCPExternalAuthConfig must + exist in the same namespace as this MCPServerEntry. + properties: + name: + description: Name is the name of the MCPExternalAuthConfig resource + type: string + required: + - name + type: object + groupRef: + description: |- + GroupRef references the MCPGroup this entry belongs to. + Required — every MCPServerEntry must be part of a group for vMCP discovery. + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object + headerForward: + description: |- + HeaderForward configures headers to inject into requests to the remote MCP server. + Use this to add custom headers like API keys or correlation IDs. + properties: + addHeadersFromSecret: + description: AddHeadersFromSecret references Kubernetes Secrets + for sensitive header values. + items: + description: HeaderFromSecret defines a header whose value comes + from a Kubernetes Secret. + properties: + headerName: + description: HeaderName is the HTTP header name (e.g., "X-API-Key") + maxLength: 255 + minLength: 1 + type: string + valueSecretRef: + description: ValueSecretRef references the Secret and key + containing the header value + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - headerName + - valueSecretRef + type: object + type: array + x-kubernetes-list-map-keys: + - headerName + x-kubernetes-list-type: map + addPlaintextHeaders: + additionalProperties: + type: string + description: |- + AddPlaintextHeaders is a map of header names to literal values to inject into requests. + WARNING: Values are stored in plaintext and visible via kubectl commands. + Use addHeadersFromSecret for sensitive data like API keys or tokens. + type: object + type: object + remoteUrl: + description: |- + RemoteURL is the URL of the remote MCP server. + Both HTTP and HTTPS schemes are accepted at admission time. + pattern: ^https?:// + type: string + transport: + description: |- + Transport is the transport method for the remote server (sse or streamable-http). + No default is set (unlike MCPRemoteProxy) because MCPServerEntry points at external + servers the user doesn't control — requiring explicit transport avoids silent mismatches. + enum: + - sse + - streamable-http + type: string + required: + - groupRef + - remoteUrl + - transport + type: object + status: + description: MCPServerEntryStatus defines the observed state of MCPServerEntry. + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPServerEntry's state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: ObservedGeneration reflects the generation most recently + observed by the controller. + format: int64 + type: integer + phase: + default: Pending + description: Phase indicates the current lifecycle phase of the MCPServerEntry. + enum: + - Valid + - Pending + - Failed + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .spec.transport + name: Transport + type: string + - jsonPath: .spec.remoteUrl + name: Remote URL + type: string + - jsonPath: .spec.groupRef.name + name: Group + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml index 3c23914b94..44eb6f7a02 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml @@ -35,7 +35,859 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPServer is the deprecated v1alpha1 version of the MCPServer + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: MCPServerSpec defines the desired state of MCPServer + properties: + args: + description: Args are additional arguments to pass to the MCP server + items: + type: string + type: array + x-kubernetes-list-type: atomic + audit: + description: Audit defines audit logging configuration for the MCP + server + properties: + enabled: + default: false + description: |- + Enabled controls whether audit logging is enabled + When true, enables audit logging with default configuration + type: boolean + type: object + authServerRef: + description: |- + AuthServerRef optionally references a resource that configures an embedded + OAuth 2.0/OIDC authorization server to authenticate MCP clients. + Currently the only supported kind is MCPExternalAuthConfig (type: embeddedAuthServer). + properties: + kind: + default: MCPExternalAuthConfig + description: Kind identifies the type of the referenced resource. + enum: + - MCPExternalAuthConfig + type: string + name: + description: Name is the name of the referenced resource in the + same namespace. + minLength: 1 + type: string + required: + - kind + - name + type: object + authzConfig: + description: AuthzConfig defines authorization policy configuration + for the MCP server + properties: + configMap: + description: |- + ConfigMap references a ConfigMap containing authorization configuration + Only used when Type is "configMap" + properties: + key: + default: authz.json + description: Key is the key in the ConfigMap that contains + the authorization configuration + type: string + name: + description: Name is the name of the ConfigMap + type: string + required: + - name + type: object + inline: + description: |- + Inline contains direct authorization configuration + Only used when Type is "inline" + properties: + entitiesJson: + default: '[]' + description: EntitiesJSON is a JSON string representing Cedar + entities + type: string + policies: + description: Policies is a list of Cedar policy strings + items: + type: string + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - policies + type: object + type: + default: configMap + description: Type is the type of authorization configuration + enum: + - configMap + - inline + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: configMap must be set when type is 'configMap', and must + not be set otherwise + rule: 'self.type == ''configMap'' ? has(self.configMap) : !has(self.configMap)' + - message: inline must be set when type is 'inline', and must not + be set otherwise + rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + backendReplicas: + description: |- + BackendReplicas is the desired number of MCP server backend pod replicas. + This controls the backend Deployment (the MCP server container itself), + independent of the proxy runner controlled by Replicas. + When nil, the operator does not set Deployment.Spec.Replicas, leaving replica + management to an HPA or other external controller. + format: int32 + minimum: 0 + type: integer + endpointPrefix: + description: |- + EndpointPrefix is the path prefix to prepend to SSE endpoint URLs. + This is used to handle path-based ingress routing scenarios where the ingress + strips a path prefix before forwarding to the backend. + type: string + env: + description: Env are environment variables to set in the MCP server + container + items: + description: EnvVar represents an environment variable in a container + properties: + name: + description: Name of the environment variable + type: string + value: + description: Value of the environment variable + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + externalAuthConfigRef: + description: |- + ExternalAuthConfigRef references a MCPExternalAuthConfig resource for external authentication. + The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPServer. + properties: + name: + description: Name is the name of the MCPExternalAuthConfig resource + type: string + required: + - name + type: object + groupRef: + description: |- + GroupRef references the MCPGroup this server belongs to. + The referenced MCPGroup must be in the same namespace. + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object + image: + description: Image is the container image for the MCP server + type: string + mcpPort: + description: MCPPort is the port that MCP server listens to + format: int32 + maximum: 65535 + minimum: 1 + type: integer + oidcConfigRef: + description: |- + OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. + The referenced MCPOIDCConfig must exist in the same namespace as this MCPServer. + Per-server overrides (audience, scopes) are specified here; shared provider config + lives in the MCPOIDCConfig resource. + properties: + audience: + description: |- + Audience is the expected audience for token validation. + This MUST be unique per server to prevent token replay attacks. + minLength: 1 + type: string + name: + description: Name is the name of the MCPOIDCConfig resource + minLength: 1 + type: string + resourceUrl: + description: |- + ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728). + When the server is exposed via Ingress or gateway, set this to the external + URL that MCP clients connect to. If not specified, defaults to the internal + Kubernetes service URL. + type: string + scopes: + description: |- + Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728). + If empty, defaults to ["openid"]. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - audience + - name + type: object + permissionProfile: + description: PermissionProfile defines the permission profile to use + properties: + key: + description: |- + Key is the key in the ConfigMap that contains the permission profile + Only used when Type is "configmap" + type: string + name: + description: |- + Name is the name of the permission profile + If Type is "builtin", Name must be one of: "none", "network" + If Type is "configmap", Name is the name of the ConfigMap + type: string + type: + default: builtin + description: Type is the type of permission profile reference + enum: + - builtin + - configmap + type: string + required: + - name + - type + type: object + podTemplateSpec: + description: |- + PodTemplateSpec defines the pod template to use for the MCP server + This allows for customizing the pod configuration beyond what is provided by the other fields. + Note that to modify the specific container the MCP server runs in, you must specify + the `mcp` container name in the PodTemplateSpec. + This field accepts a PodTemplateSpec object as JSON/YAML. + type: object + x-kubernetes-preserve-unknown-fields: true + proxyMode: + default: streamable-http + description: |- + ProxyMode is the proxy mode for stdio transport (sse or streamable-http) + This setting is ONLY applicable when Transport is "stdio". + For direct transports (sse, streamable-http), this field is ignored. + The default value is applied by Kubernetes but will be ignored for non-stdio transports. + enum: + - sse + - streamable-http + type: string + proxyPort: + default: 8080 + description: ProxyPort is the port to expose the proxy runner on + format: int32 + maximum: 65535 + minimum: 1 + type: integer + rateLimiting: + description: |- + RateLimiting defines rate limiting configuration for the MCP server. + Requires Redis session storage to be configured for distributed rate limiting. + properties: + perUser: + description: |- + PerUser is a token bucket applied independently to each authenticated user + at the server level. Requires authentication to be enabled. + Each unique userID creates Redis keys that expire after 2x refillPeriod. + Memory formula: unique_users_per_TTL_window * (1 + num_tools_with_per_user_limits) keys. + properties: + maxTokens: + description: |- + MaxTokens is the maximum number of tokens (bucket capacity). + This is also the burst size: the maximum number of requests that can be served + instantaneously before the bucket is depleted. + format: int32 + minimum: 1 + type: integer + refillPeriod: + description: |- + RefillPeriod is the duration to fully refill the bucket from zero to maxTokens. + The effective refill rate is maxTokens / refillPeriod tokens per second. + Format: Go duration string (e.g., "1m0s", "30s", "1h0m0s"). + type: string + required: + - maxTokens + - refillPeriod + type: object + shared: + description: Shared is a token bucket shared across all users + for the entire server. + properties: + maxTokens: + description: |- + MaxTokens is the maximum number of tokens (bucket capacity). + This is also the burst size: the maximum number of requests that can be served + instantaneously before the bucket is depleted. + format: int32 + minimum: 1 + type: integer + refillPeriod: + description: |- + RefillPeriod is the duration to fully refill the bucket from zero to maxTokens. + The effective refill rate is maxTokens / refillPeriod tokens per second. + Format: Go duration string (e.g., "1m0s", "30s", "1h0m0s"). + type: string + required: + - maxTokens + - refillPeriod + type: object + tools: + description: |- + Tools defines per-tool rate limit overrides. + Each entry applies additional rate limits to calls targeting a specific tool name. + A request must pass both the server-level limit and the per-tool limit. + items: + description: |- + ToolRateLimitConfig defines rate limits for a specific tool. + At least one of shared or perUser must be configured. + properties: + name: + description: Name is the MCP tool name this limit applies + to. + minLength: 1 + type: string + perUser: + description: PerUser token bucket configuration for this + tool. + properties: + maxTokens: + description: |- + MaxTokens is the maximum number of tokens (bucket capacity). + This is also the burst size: the maximum number of requests that can be served + instantaneously before the bucket is depleted. + format: int32 + minimum: 1 + type: integer + refillPeriod: + description: |- + RefillPeriod is the duration to fully refill the bucket from zero to maxTokens. + The effective refill rate is maxTokens / refillPeriod tokens per second. + Format: Go duration string (e.g., "1m0s", "30s", "1h0m0s"). + type: string + required: + - maxTokens + - refillPeriod + type: object + shared: + description: Shared token bucket for this specific tool. + properties: + maxTokens: + description: |- + MaxTokens is the maximum number of tokens (bucket capacity). + This is also the burst size: the maximum number of requests that can be served + instantaneously before the bucket is depleted. + format: int32 + minimum: 1 + type: integer + refillPeriod: + description: |- + RefillPeriod is the duration to fully refill the bucket from zero to maxTokens. + The effective refill rate is maxTokens / refillPeriod tokens per second. + Format: Go duration string (e.g., "1m0s", "30s", "1h0m0s"). + type: string + required: + - maxTokens + - refillPeriod + type: object + required: + - name + type: object + x-kubernetes-validations: + - message: at least one of shared or perUser must be configured + rule: has(self.shared) || has(self.perUser) + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + x-kubernetes-validations: + - message: at least one of shared, perUser, or tools must be configured + rule: has(self.shared) || has(self.perUser) || (has(self.tools) + && size(self.tools) > 0) + replicas: + description: |- + Replicas is the desired number of proxy runner (thv run) pod replicas. + MCPServer creates two separate Deployments: one for the proxy runner and one + for the MCP server backend. This field controls the proxy runner Deployment. + When nil, the operator does not set Deployment.Spec.Replicas, leaving replica + management to an HPA or other external controller. + format: int32 + minimum: 0 + type: integer + resourceOverrides: + description: ResourceOverrides allows overriding annotations and labels + for resources created by the operator + properties: + proxyDeployment: + description: ProxyDeployment defines overrides for the Proxy Deployment + resource (toolhive proxy) + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + env: + description: |- + Env are environment variables to set in the proxy container (thv run process) + These affect the toolhive proxy itself, not the MCP server it manages + Use TOOLHIVE_DEBUG=true to enable debug logging in the proxy + items: + description: EnvVar represents an environment variable in + a container + properties: + name: + description: Name of the environment variable + type: string + value: + description: Value of the environment variable + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + imagePullSecrets: + description: |- + ImagePullSecrets allows specifying image pull secrets for the proxy runner + These are applied to both the Deployment and the ServiceAccount + items: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + podTemplateMetadataOverrides: + description: ResourceMetadataOverrides defines metadata overrides + for a resource + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + type: object + type: object + proxyService: + description: ProxyService defines overrides for the Proxy Service + resource (points to the proxy deployment) + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + type: object + type: object + resources: + description: Resources defines the resource requirements for the MCP + server container + properties: + limits: + description: Limits describes the maximum amount of compute resources + allowed + properties: + cpu: + description: CPU is the CPU limit in cores (e.g., "500m" for + 0.5 cores) + type: string + memory: + description: Memory is the memory limit in bytes (e.g., "64Mi" + for 64 megabytes) + type: string + type: object + requests: + description: Requests describes the minimum amount of compute + resources required + properties: + cpu: + description: CPU is the CPU limit in cores (e.g., "500m" for + 0.5 cores) + type: string + memory: + description: Memory is the memory limit in bytes (e.g., "64Mi" + for 64 megabytes) + type: string + type: object + type: object + secrets: + description: Secrets are references to secrets to mount in the MCP + server container + items: + description: SecretRef is a reference to a secret + properties: + key: + description: Key is the key in the secret itself + type: string + name: + description: Name is the name of the secret + type: string + targetEnvName: + description: |- + TargetEnvName is the environment variable to be used when setting up the secret in the MCP server + If left unspecified, it defaults to the key + type: string + required: + - key + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + serviceAccount: + description: |- + ServiceAccount is the name of an already existing service account to use by the MCP server. + If not specified, a ServiceAccount will be created automatically and used by the MCP server. + type: string + sessionAffinity: + default: ClientIP + description: |- + SessionAffinity controls whether the Service routes repeated client connections to the same pod. + MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default. + Set to "None" for stateless servers or when using an external load balancer with its own affinity. + enum: + - ClientIP + - None + type: string + sessionStorage: + description: |- + SessionStorage configures session storage for stateful horizontal scaling. + When nil, no session storage is configured. + properties: + address: + description: Address is the Redis server address (required when + provider is redis) + minLength: 1 + type: string + db: + default: 0 + description: DB is the Redis database number + format: int32 + minimum: 0 + type: integer + keyPrefix: + description: KeyPrefix is an optional prefix for all Redis keys + used by ToolHive + type: string + passwordRef: + description: PasswordRef is a reference to a Secret key containing + the Redis password + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + provider: + description: Provider is the session storage backend type + enum: + - memory + - redis + type: string + required: + - provider + type: object + x-kubernetes-validations: + - message: address is required + rule: 'self.provider == ''redis'' ? has(self.address) : true' + telemetryConfigRef: + description: |- + TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. + The referenced MCPTelemetryConfig must exist in the same namespace as this MCPServer. + Cross-namespace references are not supported for security and isolation reasons. + properties: + name: + description: Name is the name of the MCPTelemetryConfig resource + minLength: 1 + type: string + serviceName: + description: |- + ServiceName overrides the telemetry service name for this specific server. + This MUST be unique per server for proper observability (e.g., distinguishing + traces and metrics from different servers sharing the same collector). + If empty, defaults to the server name with "thv-" prefix at runtime. + type: string + required: + - name + type: object + toolConfigRef: + description: |- + ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming. + The referenced MCPToolConfig must exist in the same namespace as this MCPServer. + Cross-namespace references are not supported for security and isolation reasons. + properties: + name: + description: Name is the name of the MCPToolConfig resource in + the same namespace + type: string + required: + - name + type: object + transport: + default: stdio + description: Transport is the transport method for the MCP server + (stdio, streamable-http or sse) + enum: + - stdio + - streamable-http + - sse + type: string + trustProxyHeaders: + default: false + description: |- + TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies + When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, + and X-Forwarded-Prefix headers to construct endpoint URLs + type: boolean + volumes: + description: Volumes are volumes to mount in the MCP server container + items: + description: Volume represents a volume to mount in a container + properties: + hostPath: + description: HostPath is the path on the host to mount + type: string + mountPath: + description: MountPath is the path in the container to mount + to + type: string + name: + description: Name is the name of the volume + type: string + readOnly: + default: false + description: ReadOnly specifies whether the volume should be + mounted read-only + type: boolean + required: + - hostPath + - mountPath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + required: + - image + type: object + x-kubernetes-validations: + - message: rateLimiting requires sessionStorage with provider 'redis' + rule: '!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider + == ''redis'')' + - message: rateLimiting.perUser requires authentication (oidcConfigRef + or externalAuthConfigRef) + rule: '!(has(self.rateLimiting) && has(self.rateLimiting.perUser)) || + has(self.oidcConfigRef) || has(self.externalAuthConfigRef)' + - message: per-tool perUser rate limiting requires authentication (oidcConfigRef + or externalAuthConfigRef) + rule: '!has(self.rateLimiting) || !has(self.rateLimiting.tools) || self.rateLimiting.tools.all(t, + !has(t.perUser)) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)' + status: + description: MCPServerStatus defines the observed state of MCPServer + properties: + authServerConfigHash: + description: |- + AuthServerConfigHash is the hash of the referenced authServerRef spec, + used to detect configuration changes and trigger reconciliation. + type: string + conditions: + description: Conditions represent the latest available observations + of the MCPServer's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + externalAuthConfigHash: + description: ExternalAuthConfigHash is the hash of the referenced + MCPExternalAuthConfig spec + type: string + message: + description: Message provides additional information about the current + phase + type: string + observedGeneration: + description: ObservedGeneration reflects the generation most recently + observed by the controller + format: int64 + type: integer + oidcConfigHash: + description: OIDCConfigHash is the hash of the referenced MCPOIDCConfig + spec for change detection + type: string + phase: + description: Phase is the current phase of the MCPServer + enum: + - Pending + - Ready + - Failed + - Terminating + - Stopped + type: string + readyReplicas: + description: ReadyReplicas is the number of ready proxy replicas + format: int32 + type: integer + telemetryConfigHash: + description: TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig + spec for change detection + type: string + toolConfigHash: + description: ToolConfigHash stores the hash of the referenced ToolConfig + for change detection + type: string + url: + description: URL is the URL where the MCP server can be accessed + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .status.readyReplicas + name: Replicas + type: integer + - jsonPath: .status.url + name: URL + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: MCPServer is the Schema for the mcpservers API diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptelemetryconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptelemetryconfigs.yaml index bc533a6f24..9a61add0cb 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptelemetryconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptelemetryconfigs.yaml @@ -34,7 +34,307 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPTelemetryConfig is the deprecated v1alpha1 version of the + MCPTelemetryConfig resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPTelemetryConfigSpec defines the desired state of MCPTelemetryConfig. + The spec uses a nested structure with openTelemetry and prometheus sub-objects + for clear separation of concerns. + properties: + openTelemetry: + description: OpenTelemetry defines OpenTelemetry configuration (OTLP + endpoint, tracing, metrics) + properties: + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA certificate bundle for the OTLP endpoint. + When specified, the operator mounts the ConfigMap into the proxyrunner pod and configures + the OTLP exporters to trust the custom CA. This is useful when the OTLP collector uses + TLS with certificates signed by an internal or private CA. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + If Key is not specified, it defaults to "ca.crt". + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + enabled: + default: false + description: Enabled controls whether OpenTelemetry is enabled + type: boolean + endpoint: + description: Endpoint is the OTLP endpoint URL for tracing and + metrics + type: string + headers: + additionalProperties: + type: string + description: |- + Headers contains authentication headers for the OTLP endpoint. + For secret-backed credentials, use sensitiveHeaders instead. + type: object + insecure: + default: false + description: Insecure indicates whether to use HTTP instead of + HTTPS for the OTLP endpoint + type: boolean + metrics: + description: Metrics defines OpenTelemetry metrics-specific configuration + properties: + enabled: + default: false + description: Enabled controls whether OTLP metrics are sent + type: boolean + type: object + resourceAttributes: + additionalProperties: + type: string + description: |- + ResourceAttributes contains custom resource attributes to be added to all telemetry signals. + These become OTel resource attributes (e.g., deployment.environment, service.namespace). + Note: service.name is intentionally excluded — it is set per-server via + MCPTelemetryConfigReference.ServiceName. + type: object + sensitiveHeaders: + description: |- + SensitiveHeaders contains headers whose values are stored in Kubernetes Secrets. + Use this for credential headers (e.g., API keys, bearer tokens) instead of + embedding secrets in the headers field. + items: + description: |- + SensitiveHeader represents a header whose value is stored in a Kubernetes Secret. + This allows credential headers (e.g., API keys, bearer tokens) to be securely + referenced without embedding secrets inline in the MCPTelemetryConfig resource. + properties: + name: + description: Name is the header name (e.g., "Authorization", + "X-API-Key") + minLength: 1 + type: string + secretKeyRef: + description: SecretKeyRef is a reference to a Kubernetes + Secret key containing the header value + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - name + - secretKeyRef + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + tracing: + description: Tracing defines OpenTelemetry tracing configuration + properties: + enabled: + default: false + description: Enabled controls whether OTLP tracing is sent + type: boolean + samplingRate: + default: "0.05" + description: SamplingRate is the trace sampling rate (0.0-1.0) + pattern: ^(0(\.\d+)?|1(\.0+)?)$ + type: string + type: object + useLegacyAttributes: + default: true + description: |- + UseLegacyAttributes controls whether legacy attribute names are emitted alongside + the new MCP OTEL semantic convention names. Defaults to true for backward compatibility. + This will change to false in a future release and eventually be removed. + type: boolean + type: object + x-kubernetes-validations: + - message: a header name cannot appear in both headers and sensitiveHeaders + rule: '!has(self.headers) || !has(self.sensitiveHeaders) || self.sensitiveHeaders.all(sh, + !(sh.name in self.headers))' + prometheus: + description: Prometheus defines Prometheus-specific configuration + properties: + enabled: + default: false + description: Enabled controls whether Prometheus metrics endpoint + is exposed + type: boolean + type: object + type: object + status: + description: MCPTelemetryConfigStatus defines the observed state of MCPTelemetryConfig + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPTelemetryConfig's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + configHash: + description: ConfigHash is a hash of the current configuration for + change detection + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + for this MCPTelemetryConfig. + format: int64 + type: integer + referencingWorkloads: + description: ReferencingWorkloads lists workloads that reference this + MCPTelemetryConfig + items: + description: |- + WorkloadReference identifies a workload that references a shared configuration resource. + Namespace is implicit — cross-namespace references are not supported. + properties: + kind: + description: Kind is the type of workload resource + enum: + - MCPServer + - VirtualMCPServer + - MCPRemoteProxy + type: string + name: + description: Name is the name of the workload resource + minLength: 1 + type: string + required: + - kind + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .spec.openTelemetry.endpoint + name: Endpoint + type: string + - jsonPath: .status.conditions[?(@.type=='Valid')].status + name: Valid + type: string + - jsonPath: .spec.openTelemetry.tracing.enabled + name: Tracing + type: boolean + - jsonPath: .spec.openTelemetry.metrics.enabled + name: Metrics + type: boolean + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptoolconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptoolconfigs.yaml index ca54cd00fd..719026f13d 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptoolconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptoolconfigs.yaml @@ -29,7 +29,209 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPToolConfig is the deprecated v1alpha1 version of the MCPToolConfig + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPToolConfigSpec defines the desired state of MCPToolConfig. + MCPToolConfig resources are namespace-scoped and can only be referenced by + MCPServer resources in the same namespace. + properties: + toolsFilter: + description: |- + ToolsFilter is a list of tool names to filter (allow list). + Only tools in this list will be exposed by the MCP server. + If empty, all tools are exposed. + items: + type: string + type: array + x-kubernetes-list-type: set + toolsOverride: + additionalProperties: + description: |- + ToolOverride represents a tool override configuration. + Both Name and Description can be overridden independently, but + they can't be both empty. + properties: + annotations: + description: |- + Annotations overrides specific tool annotation fields. + Only specified fields are overridden; others pass through from the backend. + properties: + destructiveHint: + description: DestructiveHint overrides the destructive hint + annotation. + type: boolean + idempotentHint: + description: IdempotentHint overrides the idempotent hint + annotation. + type: boolean + openWorldHint: + description: OpenWorldHint overrides the open-world hint + annotation. + type: boolean + readOnlyHint: + description: ReadOnlyHint overrides the read-only hint annotation. + type: boolean + title: + description: Title overrides the human-readable title annotation. + type: string + type: object + description: + description: Description is the redefined description of the + tool + type: string + name: + description: Name is the redefined name of the tool + type: string + type: object + description: |- + ToolsOverride is a map from actual tool names to their overridden configuration. + This allows renaming tools and/or changing their descriptions. + type: object + type: object + status: + description: MCPToolConfigStatus defines the observed state of MCPToolConfig + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPToolConfig's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + configHash: + description: ConfigHash is a hash of the current configuration for + change detection + type: string + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this MCPToolConfig. + It corresponds to the MCPToolConfig's generation, which is updated on mutation by the API Server. + format: int64 + type: integer + referencingWorkloads: + description: |- + ReferencingWorkloads is a list of workload resources that reference this MCPToolConfig. + Each entry identifies the workload by kind and name. + items: + description: |- + WorkloadReference identifies a workload that references a shared configuration resource. + Namespace is implicit — cross-namespace references are not supported. + properties: + kind: + description: Kind is the type of workload resource + enum: + - MCPServer + - VirtualMCPServer + - MCPRemoteProxy + type: string + name: + description: Name is the name of the workload resource + minLength: 1 + type: string + required: + - kind + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=='Valid')].status + name: Valid + type: string + - jsonPath: .status.referencingWorkloads + name: References + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpcompositetooldefinitions.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpcompositetooldefinitions.yaml index d3b9326b7b..a9c17deff5 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpcompositetooldefinitions.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpcompositetooldefinitions.yaml @@ -43,7 +43,414 @@ spec: - jsonPath: .status.conditions[?(@.type=='Ready')].status name: Ready type: string + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: VirtualMCPCompositeToolDefinition is the deprecated v1alpha1 + version of the VirtualMCPCompositeToolDefinition resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + VirtualMCPCompositeToolDefinitionSpec defines the desired state of VirtualMCPCompositeToolDefinition. + This embeds the CompositeToolConfig from pkg/vmcp/config to share the configuration model + between CLI and operator usage. + properties: + description: + description: Description describes what the workflow does. + type: string + name: + description: Name is the workflow name (unique identifier). + type: string + output: + description: |- + Output defines the structured output schema for this workflow. + If not specified, the workflow returns the last step's output (backward compatible). + properties: + properties: + additionalProperties: + description: |- + OutputProperty defines a single output property. + For non-object types, Value is required. + For object types, either Value or Properties must be specified (but not both). + properties: + default: + description: |- + Default is the fallback value if template expansion fails. + Type coercion is applied to match the declared Type. + x-kubernetes-preserve-unknown-fields: true + description: + description: Description is a human-readable description + exposed to clients and models + type: string + properties: + description: |- + Properties defines nested properties for object types. + Each nested property has full metadata (type, description, value/properties). + type: object + x-kubernetes-preserve-unknown-fields: true + type: + description: 'Type is the JSON Schema type: "string", "integer", + "number", "boolean", "object", "array"' + enum: + - string + - integer + - number + - boolean + - object + - array + type: string + value: + description: |- + Value is a template string for constructing the runtime value. + For object types, this can be a JSON string that will be deserialized. + Supports template syntax: {{.steps.step_id.output.field}}, {{.params.param_name}} + type: string + required: + - type + type: object + description: |- + Properties defines the output properties. + Map key is the property name, value is the property definition. + type: object + required: + description: Required lists property names that must be present + in the output. + items: + type: string + type: array + required: + - properties + type: object + parameters: + description: |- + Parameters defines input parameter schema in JSON Schema format. + Should be a JSON Schema object with "type": "object" and "properties". + Example: + { + "type": "object", + "properties": { + "param1": {"type": "string", "default": "value"}, + "param2": {"type": "integer"} + }, + "required": ["param2"] + } + + We use json.Map rather than a typed struct because JSON Schema is highly + flexible with many optional fields (default, enum, minimum, maximum, pattern, + items, additionalProperties, oneOf, anyOf, allOf, etc.). Using json.Map + allows full JSON Schema compatibility without needing to define every possible + field, and matches how the MCP SDK handles inputSchema. + type: object + x-kubernetes-preserve-unknown-fields: true + steps: + description: Steps are the workflow steps to execute. + items: + description: |- + WorkflowStepConfig defines a single workflow step. + This matches the proposal's step configuration (lines 180-255). + properties: + arguments: + description: |- + Arguments is a map of argument values with template expansion support. + Supports Go template syntax with .params and .steps for string values. + Non-string values (integers, booleans, arrays, objects) are passed as-is. + Note: the templating is only supported on the first level of the key-value pairs. + type: object + x-kubernetes-preserve-unknown-fields: true + collection: + description: |- + Collection is a Go template expression that resolves to a JSON array or a slice. + Only used when Type is "forEach". + type: string + condition: + description: Condition is a template expression that determines + if the step should execute + type: string + defaultResults: + description: |- + DefaultResults provides fallback output values when this step is skipped + (due to condition evaluating to false) or fails (when onError.action is "continue"). + Each key corresponds to an output field name referenced by downstream steps. + Required if the step may be skipped AND downstream steps reference this step's output. + x-kubernetes-preserve-unknown-fields: true + dependsOn: + description: DependsOn lists step IDs that must complete before + this step + items: + type: string + type: array + id: + description: ID is the unique identifier for this step. + type: string + itemVar: + description: |- + ItemVar is the variable name used to reference the current item in forEach templates. + Defaults to "item" if not specified. + Only used when Type is "forEach". + type: string + maxIterations: + description: |- + MaxIterations limits the number of items that can be iterated over. + Defaults to 100, hard cap at 1000. + Only used when Type is "forEach". + type: integer + maxParallel: + description: |- + MaxParallel limits the number of concurrent iterations in a forEach step. + Defaults to the DAG executor's maxParallel (10). + Only used when Type is "forEach". + type: integer + message: + description: |- + Message is the elicitation message + Only used when Type is "elicitation" + type: string + onCancel: + description: |- + OnCancel defines the action to take when the user cancels/dismisses the elicitation + Only used when Type is "elicitation" + properties: + action: + default: abort + description: |- + Action defines the action to take when the user declines or cancels + - skip_remaining: Skip remaining steps in the workflow + - abort: Abort the entire workflow execution + - continue: Continue to the next step + enum: + - skip_remaining + - abort + - continue + type: string + type: object + onDecline: + description: |- + OnDecline defines the action to take when the user explicitly declines the elicitation + Only used when Type is "elicitation" + properties: + action: + default: abort + description: |- + Action defines the action to take when the user declines or cancels + - skip_remaining: Skip remaining steps in the workflow + - abort: Abort the entire workflow execution + - continue: Continue to the next step + enum: + - skip_remaining + - abort + - continue + type: string + type: object + onError: + description: OnError defines error handling behavior + properties: + action: + default: abort + description: Action defines the action to take on error + enum: + - abort + - continue + - retry + type: string + retryCount: + description: |- + RetryCount is the maximum number of retries + Only used when Action is "retry" + type: integer + retryDelay: + description: |- + RetryDelay is the delay between retry attempts + Only used when Action is "retry" + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + type: object + schema: + description: Schema defines the expected response schema for + elicitation + type: object + x-kubernetes-preserve-unknown-fields: true + step: + description: |- + InnerStep defines the step to execute for each item in the collection. + Only used when Type is "forEach". Only tool-type inner steps are supported. + type: object + x-kubernetes-preserve-unknown-fields: true + timeout: + description: Timeout is the maximum execution time for this + step + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + tool: + description: |- + Tool is the tool to call (format: "workload.tool_name") + Only used when Type is "tool" + type: string + type: + default: tool + description: Type is the step type (tool, elicitation, etc.) + enum: + - tool + - elicitation + - forEach + type: string + required: + - id + type: object + type: array + timeout: + description: Timeout is the maximum workflow execution time. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + required: + - name + - steps + type: object + status: + description: VirtualMCPCompositeToolDefinitionStatus defines the observed + state of VirtualMCPCompositeToolDefinition + properties: + conditions: + description: Conditions represent the latest available observations + of the workflow's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this VirtualMCPCompositeToolDefinition + It corresponds to the resource's generation, which is updated on mutation by the API Server + format: int64 + type: integer + referencingVirtualServers: + description: |- + ReferencingVirtualServers lists VirtualMCPServer resources that reference this workflow + This helps track which servers need to be reconciled when this workflow changes + items: + type: string + type: array + x-kubernetes-list-type: set + validationErrors: + description: ValidationErrors contains validation error messages if + ValidationStatus is Invalid + items: + type: string + type: array + x-kubernetes-list-type: atomic + validationStatus: + description: |- + ValidationStatus indicates the validation state of the workflow + - Valid: Workflow structure is valid + - Invalid: Workflow has validation errors + enum: + - Valid + - Invalid + - Unknown + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - description: Workflow name + jsonPath: .spec.name + name: Workflow + type: string + - description: Number of steps + jsonPath: .spec.steps[*] + name: Steps + type: integer + - description: Validation status + jsonPath: .status.validationStatus + name: Status + type: string + - description: Refs + jsonPath: .status.referencingVirtualServers[*] + name: Refs + type: integer + - description: Age + jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 7502cd9021..411d84cdc3 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -39,7 +39,2295 @@ spec: - jsonPath: .status.conditions[?(@.type=='Ready')].status name: Ready type: string + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: VirtualMCPServer is the deprecated v1alpha1 version of the VirtualMCPServer + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: VirtualMCPServerSpec defines the desired state of VirtualMCPServer + properties: + authServerConfig: + description: |- + AuthServerConfig configures an embedded OAuth authorization server. + When set, the vMCP server acts as an OIDC issuer, drives users through + upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the + IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfigRef + so that tokens it issues are accepted by the vMCP's incoming auth middleware. + When nil, IncomingAuth uses an external IDP and behavior is unchanged. + properties: + authorizationEndpointBaseUrl: + description: |- + AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint + in the OAuth discovery document. When set, the discovery document will advertise + `{authorizationEndpointBaseUrl}/oauth/authorize` instead of `{issuer}/oauth/authorize`. + All other endpoints (token, registration, JWKS) remain derived from the issuer. + This is useful when the browser-facing authorization endpoint needs to be on a + different host than the issuer used for backend-to-backend calls. + Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. + pattern: ^https?://[^\s?#]+[^/\s?#]$ + type: string + hmacSecretRefs: + description: |- + HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing + authorization codes and refresh tokens (opaque tokens). + Current secret must be at least 32 bytes and cryptographically random. + Supports secret rotation via multiple entries (first is current, rest are for verification). + If not specified, an ephemeral secret will be auto-generated (development only - + auth codes and refresh tokens will be invalid after restart). + items: + description: SecretKeyRef is a reference to a key within a Secret + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + type: array + x-kubernetes-list-type: atomic + issuer: + description: |- + Issuer is the issuer identifier for this authorization server. + This will be included in the "iss" claim of issued tokens. + Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). + pattern: ^https?://[^\s?#]+[^/\s?#]$ + type: string + signingKeySecretRefs: + description: |- + SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations. + Supports key rotation by allowing multiple keys (oldest keys are used for verification only). + If not specified, an ephemeral signing key will be auto-generated (development only - + JWTs will be invalid after restart). + items: + description: SecretKeyRef is a reference to a key within a Secret + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + maxItems: 5 + type: array + x-kubernetes-list-type: atomic + storage: + description: |- + Storage configures the storage backend for the embedded auth server. + If not specified, defaults to in-memory storage. + properties: + redis: + description: |- + Redis configures the Redis storage backend. + Required when type is "redis". + properties: + aclUserConfig: + description: ACLUserConfig configures Redis ACL user authentication. + properties: + passwordSecretRef: + description: PasswordSecretRef references a Secret + containing the Redis ACL password. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + usernameSecretRef: + description: UsernameSecretRef references a Secret + containing the Redis ACL username. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - passwordSecretRef + - usernameSecretRef + type: object + dialTimeout: + default: 5s + description: |- + DialTimeout is the timeout for establishing connections. + Format: Go duration string (e.g., "5s", "1m"). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + readTimeout: + default: 3s + description: |- + ReadTimeout is the timeout for socket reads. + Format: Go duration string (e.g., "3s", "1m"). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + sentinelConfig: + description: SentinelConfig holds Redis Sentinel configuration. + properties: + db: + default: 0 + description: DB is the Redis database number. + format: int32 + type: integer + masterName: + description: MasterName is the name of the Redis master + monitored by Sentinel. + type: string + sentinelAddrs: + description: |- + SentinelAddrs is a list of Sentinel host:port addresses. + Mutually exclusive with SentinelService. + items: + type: string + type: array + x-kubernetes-list-type: atomic + sentinelService: + description: |- + SentinelService enables automatic discovery from a Kubernetes Service. + Mutually exclusive with SentinelAddrs. + properties: + name: + description: Name of the Sentinel Service. + type: string + namespace: + description: Namespace of the Sentinel Service + (defaults to same namespace). + type: string + port: + default: 26379 + description: Port of the Sentinel service. + format: int32 + type: integer + required: + - name + type: object + required: + - masterName + type: object + sentinelTls: + description: |- + SentinelTLS configures TLS for connections to Sentinel instances. + Presence of this field enables TLS. Omit to use plaintext. + When omitted, sentinel connections use plaintext (no fallback to TLS config). + properties: + caCertSecretRef: + description: |- + CACertSecretRef references a Secret containing a PEM-encoded CA certificate + for verifying the server. When not specified, system root CAs are used. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + insecureSkipVerify: + description: |- + InsecureSkipVerify skips TLS certificate verification. + Use when connecting to services with self-signed certificates. + type: boolean + type: object + tls: + description: |- + TLS configures TLS for connections to the Redis/Valkey master. + Presence of this field enables TLS. Omit to use plaintext. + properties: + caCertSecretRef: + description: |- + CACertSecretRef references a Secret containing a PEM-encoded CA certificate + for verifying the server. When not specified, system root CAs are used. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + insecureSkipVerify: + description: |- + InsecureSkipVerify skips TLS certificate verification. + Use when connecting to services with self-signed certificates. + type: boolean + type: object + writeTimeout: + default: 3s + description: |- + WriteTimeout is the timeout for socket writes. + Format: Go duration string (e.g., "3s", "1m"). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + required: + - aclUserConfig + - sentinelConfig + type: object + type: + default: memory + description: |- + Type specifies the storage backend type. + Valid values: "memory" (default), "redis". + enum: + - memory + - redis + type: string + type: object + tokenLifespans: + description: |- + TokenLifespans configures the duration that various tokens are valid. + If not specified, defaults are applied (access: 1h, refresh: 7d, authCode: 10m). + properties: + accessTokenLifespan: + description: |- + AccessTokenLifespan is the duration that access tokens are valid. + Format: Go duration string (e.g., "1h", "30m", "24h"). + If empty, defaults to 1 hour. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + authCodeLifespan: + description: |- + AuthCodeLifespan is the duration that authorization codes are valid. + Format: Go duration string (e.g., "10m", "5m"). + If empty, defaults to 10 minutes. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + refreshTokenLifespan: + description: |- + RefreshTokenLifespan is the duration that refresh tokens are valid. + Format: Go duration string (e.g., "168h", "7d" as "168h"). + If empty, defaults to 7 days (168h). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + type: object + upstreamProviders: + description: |- + UpstreamProviders configures connections to upstream Identity Providers. + The embedded auth server delegates authentication to these providers. + MCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple. + items: + description: UpstreamProviderConfig defines configuration for + an upstream Identity Provider. + properties: + name: + description: |- + Name uniquely identifies this upstream provider. + Used for routing decisions and session binding in multi-upstream scenarios. + Must be lowercase alphanumeric with hyphens (DNS-label-like). + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ + type: string + oauth2Config: + description: |- + OAuth2Config contains OAuth 2.0-specific configuration. + Required when Type is "oauth2", must be nil when Type is "oidc". + properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object + authorizationEndpoint: + description: AuthorizationEndpoint is the URL for the + OAuth authorization endpoint. + pattern: ^https?://.*$ + type: string + clientId: + description: ClientID is the OAuth 2.0 client identifier + registered with the upstream IDP. + type: string + clientSecretRef: + description: |- + ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret. + Optional for public clients using PKCE instead of client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + redirectUri: + description: |- + RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the + URL associated with the resource (e.g., MCPServer or vMCP) using this config. + type: string + scopes: + description: Scopes are the OAuth scopes to request + from the upstream IDP. + items: + type: string + type: array + x-kubernetes-list-type: atomic + tokenEndpoint: + description: TokenEndpoint is the URL for the OAuth + token endpoint. + pattern: ^https?://.*$ + type: string + tokenResponseMapping: + description: |- + TokenResponseMapping configures custom field extraction from non-standard token responses. + Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths + instead of returning them at the top level. When set, ToolHive performs the token + exchange HTTP call directly and extracts fields using the configured dot-notation paths. + If nil, standard OAuth 2.0 token response parsing is used. + properties: + accessTokenPath: + description: |- + AccessTokenPath is the dot-notation path to the access token in the response. + Example: "authed_user.access_token" + minLength: 1 + type: string + expiresInPath: + description: |- + ExpiresInPath is the dot-notation path to the expires_in value (in seconds). + If not specified, defaults to "expires_in". + type: string + refreshTokenPath: + description: |- + RefreshTokenPath is the dot-notation path to the refresh token in the response. + If not specified, defaults to "refresh_token". + type: string + scopePath: + description: |- + ScopePath is the dot-notation path to the scope string in the response. + If not specified, defaults to "scope". + type: string + required: + - accessTokenPath + type: object + userInfo: + description: |- + UserInfo contains configuration for fetching user information from the upstream provider. + Required for OAuth2 providers to resolve user identity. + properties: + additionalHeaders: + additionalProperties: + type: string + description: |- + AdditionalHeaders contains extra headers to include in the userinfo request. + Useful for providers that require specific headers (e.g., GitHub's Accept header). + type: object + endpointUrl: + description: EndpointURL is the URL of the userinfo + endpoint. + pattern: ^https?://.*$ + type: string + fieldMapping: + description: |- + FieldMapping contains custom field mapping configuration for non-standard providers. + If nil, standard OIDC field names are used ("sub", "name", "email"). + properties: + emailFields: + description: |- + EmailFields is an ordered list of field names to try for the email address. + The first non-empty value found will be used. + Default: ["email"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + nameFields: + description: |- + NameFields is an ordered list of field names to try for the display name. + The first non-empty value found will be used. + Default: ["name"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectFields: + description: |- + SubjectFields is an ordered list of field names to try for the user ID. + The first non-empty value found will be used. + Default: ["sub"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpMethod: + description: |- + HTTPMethod is the HTTP method to use for the userinfo request. + If not specified, defaults to GET. + enum: + - GET + - POST + type: string + required: + - endpointUrl + type: object + required: + - authorizationEndpoint + - clientId + - tokenEndpoint + - userInfo + type: object + oidcConfig: + description: |- + OIDCConfig contains OIDC-specific configuration. + Required when Type is "oidc", must be nil when Type is "oauth2". + properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Note: when using access_type=offline, also set explicit scopes to avoid + the default offline_access scope being sent alongside it. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object + clientId: + description: ClientID is the OAuth 2.0 client identifier + registered with the upstream IDP. + type: string + clientSecretRef: + description: |- + ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret. + Optional for public clients using PKCE instead of client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + issuerUrl: + description: |- + IssuerURL is the OIDC issuer URL for automatic endpoint discovery. + Must be a valid HTTPS URL. + pattern: ^https://.*$ + type: string + redirectUri: + description: |- + RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the + URL associated with the resource (e.g., MCPServer or vMCP) using this config. + type: string + scopes: + description: |- + Scopes are the OAuth scopes to request from the upstream IDP. + If not specified, defaults to ["openid", "offline_access"]. + When using additionalAuthorizationParams with provider-specific refresh token + mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid + sending both offline_access and the provider-specific parameter. + items: + type: string + type: array + x-kubernetes-list-type: atomic + userInfoOverride: + description: |- + UserInfoOverride allows customizing UserInfo fetching behavior for OIDC providers. + By default, the UserInfo endpoint is discovered automatically via OIDC discovery. + Use this to override the endpoint URL, HTTP method, or field mappings for providers + that return non-standard claim names in their UserInfo response. + properties: + additionalHeaders: + additionalProperties: + type: string + description: |- + AdditionalHeaders contains extra headers to include in the userinfo request. + Useful for providers that require specific headers (e.g., GitHub's Accept header). + type: object + endpointUrl: + description: EndpointURL is the URL of the userinfo + endpoint. + pattern: ^https?://.*$ + type: string + fieldMapping: + description: |- + FieldMapping contains custom field mapping configuration for non-standard providers. + If nil, standard OIDC field names are used ("sub", "name", "email"). + properties: + emailFields: + description: |- + EmailFields is an ordered list of field names to try for the email address. + The first non-empty value found will be used. + Default: ["email"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + nameFields: + description: |- + NameFields is an ordered list of field names to try for the display name. + The first non-empty value found will be used. + Default: ["name"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectFields: + description: |- + SubjectFields is an ordered list of field names to try for the user ID. + The first non-empty value found will be used. + Default: ["sub"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpMethod: + description: |- + HTTPMethod is the HTTP method to use for the userinfo request. + If not specified, defaults to GET. + enum: + - GET + - POST + type: string + required: + - endpointUrl + type: object + required: + - clientId + - issuerUrl + type: object + type: + description: 'Type specifies the provider type: "oidc" or + "oauth2"' + enum: + - oidc + - oauth2 + type: string + required: + - name + - type + type: object + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + required: + - issuer + - upstreamProviders + type: object + config: + description: |- + Config is the Virtual MCP server configuration. + The audit config from here is also supported, but not required. + properties: + aggregation: + description: |- + Aggregation defines tool aggregation and conflict resolution strategies. + Supports ToolConfigRef for Kubernetes-native MCPToolConfig resource references. + properties: + conflictResolution: + default: prefix + description: |- + ConflictResolution defines the strategy for resolving tool name conflicts. + - prefix: Automatically prefix tool names with workload identifier + - priority: First workload in priority order wins + - manual: Explicitly define overrides for all conflicts + enum: + - prefix + - priority + - manual + type: string + conflictResolutionConfig: + description: ConflictResolutionConfig provides configuration + for the chosen strategy. + properties: + prefixFormat: + default: '{workload}_' + description: |- + PrefixFormat defines the prefix format for the "prefix" strategy. + Supports placeholders: {workload}, {workload}_, {workload}. + type: string + priorityOrder: + description: PriorityOrder defines the workload priority + order for the "priority" strategy. + items: + type: string + type: array + type: object + excludeAllTools: + description: |- + ExcludeAllTools hides all backend tools from MCP clients when true. + Hidden tools are NOT advertised in tools/list responses, but they ARE + available in the routing table for composite tools to use. + This enables the use case where you want to hide raw backend tools from + direct client access while exposing curated composite tool workflows. + type: boolean + tools: + description: Tools defines per-workload tool filtering and + overrides. + items: + description: WorkloadToolConfig defines tool filtering and + overrides for a specific workload. + properties: + excludeAll: + description: |- + ExcludeAll hides all tools from this workload from MCP clients when true. + Hidden tools are NOT advertised in tools/list responses, but they ARE + available in the routing table for composite tools to use. + This enables the use case where you want to hide raw backend tools from + direct client access while exposing curated composite tool workflows. + type: boolean + filter: + description: |- + Filter is an allow-list of tool names to advertise to MCP clients. + Tools NOT in this list are hidden from clients (not in tools/list response) + but remain available in the routing table for composite tools to use. + This enables selective exposure of backend tools while allowing composite + workflows to orchestrate all backend capabilities. + Only used if ToolConfigRef is not specified. + items: + type: string + type: array + overrides: + additionalProperties: + description: ToolOverride defines tool name, description, + and annotation overrides. + properties: + annotations: + description: |- + Annotations overrides specific tool annotation fields. + Only specified fields are overridden; others pass through from the backend. + properties: + destructiveHint: + description: DestructiveHint overrides the + destructive hint annotation. + type: boolean + idempotentHint: + description: IdempotentHint overrides the + idempotent hint annotation. + type: boolean + openWorldHint: + description: OpenWorldHint overrides the open-world + hint annotation. + type: boolean + readOnlyHint: + description: ReadOnlyHint overrides the read-only + hint annotation. + type: boolean + title: + description: Title overrides the human-readable + title annotation. + type: string + type: object + description: + description: Description is the new tool description. + type: string + name: + description: Name is the new tool name (for renaming). + type: string + type: object + description: |- + Overrides is an inline map of tool overrides for renaming and description changes. + Overrides are applied to tools before conflict resolution and affect both + advertising and routing (the overridden name is used everywhere). + Only used if ToolConfigRef is not specified. + type: object + toolConfigRef: + description: |- + ToolConfigRef references an MCPToolConfig resource for tool filtering and renaming. + If specified, Filter and Overrides are ignored. + Only used when running in Kubernetes with the operator. + properties: + name: + description: Name is the name of the MCPToolConfig + resource in the same namespace. + type: string + required: + - name + type: object + workload: + description: Workload is the name of the backend MCPServer + workload. + type: string + required: + - workload + type: object + type: array + type: object + audit: + description: |- + Audit configures audit logging for the Virtual MCP server. + When present, audit logs include MCP protocol operations. + See audit.Config for available configuration options. + properties: + component: + description: Component is the component name to use in audit + events. + type: string + detectApplicationErrors: + default: true + description: |- + DetectApplicationErrors controls whether the audit middleware inspects + JSON-RPC response bodies for application-level errors when the HTTP + status code indicates success (2xx). When enabled, a small prefix of + the response body is buffered to detect JSON-RPC error fields, + independent of the IncludeResponseData setting. + type: boolean + enabled: + default: false + description: |- + Enabled controls whether audit logging is enabled. + When true, enables audit logging with the configured options. + type: boolean + eventTypes: + description: EventTypes specifies which event types to audit. + If empty, all events are audited. + items: + type: string + type: array + excludeEventTypes: + description: |- + ExcludeEventTypes specifies which event types to exclude from auditing. + This takes precedence over EventTypes. + items: + type: string + type: array + includeRequestData: + default: false + description: IncludeRequestData determines whether to include + request data in audit logs. + type: boolean + includeResponseData: + default: false + description: IncludeResponseData determines whether to include + response data in audit logs. + type: boolean + logFile: + description: LogFile specifies the file path for audit logs. + If empty, logs to stdout. + type: string + maxDataSize: + default: 1024 + description: MaxDataSize limits the size of request/response + data included in audit logs (in bytes). + type: integer + type: object + backends: + description: |- + Backends defines pre-configured backend servers for static mode. + When OutgoingAuth.Source is "inline", this field contains the full list of backend + servers with their URLs and transport types, eliminating the need for K8s API access. + When OutgoingAuth.Source is "discovered", this field is empty and backends are + discovered at runtime via Kubernetes API. + items: + description: |- + StaticBackendConfig defines a pre-configured backend server for static mode. + This allows vMCP to operate without Kubernetes API access by embedding all backend + information directly in the configuration. + properties: + caBundlePath: + description: |- + CABundlePath is the file path to a custom CA certificate bundle for TLS verification. + Only valid when Type is "entry". The operator mounts CA bundles at + /etc/toolhive/ca-bundles/<name>/ca.crt. + type: string + metadata: + additionalProperties: + type: string + description: |- + Metadata is a custom key-value map for storing additional backend information + such as labels, tags, or other arbitrary data (e.g., "env": "prod", "region": "us-east-1"). + This is NOT Kubernetes ObjectMeta - it's a simple string map for user-defined metadata. + Reserved keys: "group" is automatically set by vMCP and any user-provided value will be overridden. + type: object + name: + description: |- + Name is the backend identifier. + Must match the backend name from the MCPGroup for auth config resolution. + type: string + transport: + description: |- + Transport is the MCP transport protocol: "sse" or "streamable-http" + Only network transports supported by vMCP client are allowed. + enum: + - sse + - streamable-http + type: string + type: + description: |- + Type is the backend workload type: "entry" for MCPServerEntry backends, or empty + for container/proxy backends. Entry backends connect directly to remote MCP servers. + enum: + - entry + - "" + type: string + url: + description: URL is the backend's MCP server base URL. + pattern: ^https?:// + type: string + required: + - name + - transport + - url + type: object + type: array + compositeToolRefs: + description: |- + CompositeToolRefs references VirtualMCPCompositeToolDefinition resources + for complex, reusable workflows. Only applicable when running in Kubernetes. + Referenced resources must be in the same namespace as the VirtualMCPServer. + items: + description: |- + CompositeToolRef defines a reference to a VirtualMCPCompositeToolDefinition resource. + The referenced resource must be in the same namespace as the VirtualMCPServer. + properties: + name: + description: Name is the name of the VirtualMCPCompositeToolDefinition + resource in the same namespace. + type: string + required: + - name + type: object + type: array + compositeTools: + description: |- + CompositeTools defines inline composite tool workflows. + Full workflow definitions are embedded in the configuration. + For Kubernetes, complex workflows can also reference VirtualMCPCompositeToolDefinition CRDs. + items: + description: |- + CompositeToolConfig defines a composite tool workflow. + This matches the YAML structure from the proposal (lines 173-255). + properties: + description: + description: Description describes what the workflow does. + type: string + name: + description: Name is the workflow name (unique identifier). + type: string + output: + description: |- + Output defines the structured output schema for this workflow. + If not specified, the workflow returns the last step's output (backward compatible). + properties: + properties: + additionalProperties: + description: |- + OutputProperty defines a single output property. + For non-object types, Value is required. + For object types, either Value or Properties must be specified (but not both). + properties: + default: + description: |- + Default is the fallback value if template expansion fails. + Type coercion is applied to match the declared Type. + x-kubernetes-preserve-unknown-fields: true + description: + description: Description is a human-readable description + exposed to clients and models + type: string + properties: + description: |- + Properties defines nested properties for object types. + Each nested property has full metadata (type, description, value/properties). + type: object + x-kubernetes-preserve-unknown-fields: true + type: + description: 'Type is the JSON Schema type: "string", + "integer", "number", "boolean", "object", "array"' + enum: + - string + - integer + - number + - boolean + - object + - array + type: string + value: + description: |- + Value is a template string for constructing the runtime value. + For object types, this can be a JSON string that will be deserialized. + Supports template syntax: {{.steps.step_id.output.field}}, {{.params.param_name}} + type: string + required: + - type + type: object + description: |- + Properties defines the output properties. + Map key is the property name, value is the property definition. + type: object + required: + description: Required lists property names that must + be present in the output. + items: + type: string + type: array + required: + - properties + type: object + parameters: + description: |- + Parameters defines input parameter schema in JSON Schema format. + Should be a JSON Schema object with "type": "object" and "properties". + Example: + { + "type": "object", + "properties": { + "param1": {"type": "string", "default": "value"}, + "param2": {"type": "integer"} + }, + "required": ["param2"] + } + + We use json.Map rather than a typed struct because JSON Schema is highly + flexible with many optional fields (default, enum, minimum, maximum, pattern, + items, additionalProperties, oneOf, anyOf, allOf, etc.). Using json.Map + allows full JSON Schema compatibility without needing to define every possible + field, and matches how the MCP SDK handles inputSchema. + type: object + x-kubernetes-preserve-unknown-fields: true + steps: + description: Steps are the workflow steps to execute. + items: + description: |- + WorkflowStepConfig defines a single workflow step. + This matches the proposal's step configuration (lines 180-255). + properties: + arguments: + description: |- + Arguments is a map of argument values with template expansion support. + Supports Go template syntax with .params and .steps for string values. + Non-string values (integers, booleans, arrays, objects) are passed as-is. + Note: the templating is only supported on the first level of the key-value pairs. + type: object + x-kubernetes-preserve-unknown-fields: true + collection: + description: |- + Collection is a Go template expression that resolves to a JSON array or a slice. + Only used when Type is "forEach". + type: string + condition: + description: Condition is a template expression that + determines if the step should execute + type: string + defaultResults: + description: |- + DefaultResults provides fallback output values when this step is skipped + (due to condition evaluating to false) or fails (when onError.action is "continue"). + Each key corresponds to an output field name referenced by downstream steps. + Required if the step may be skipped AND downstream steps reference this step's output. + x-kubernetes-preserve-unknown-fields: true + dependsOn: + description: DependsOn lists step IDs that must complete + before this step + items: + type: string + type: array + id: + description: ID is the unique identifier for this + step. + type: string + itemVar: + description: |- + ItemVar is the variable name used to reference the current item in forEach templates. + Defaults to "item" if not specified. + Only used when Type is "forEach". + type: string + maxIterations: + description: |- + MaxIterations limits the number of items that can be iterated over. + Defaults to 100, hard cap at 1000. + Only used when Type is "forEach". + type: integer + maxParallel: + description: |- + MaxParallel limits the number of concurrent iterations in a forEach step. + Defaults to the DAG executor's maxParallel (10). + Only used when Type is "forEach". + type: integer + message: + description: |- + Message is the elicitation message + Only used when Type is "elicitation" + type: string + onCancel: + description: |- + OnCancel defines the action to take when the user cancels/dismisses the elicitation + Only used when Type is "elicitation" + properties: + action: + default: abort + description: |- + Action defines the action to take when the user declines or cancels + - skip_remaining: Skip remaining steps in the workflow + - abort: Abort the entire workflow execution + - continue: Continue to the next step + enum: + - skip_remaining + - abort + - continue + type: string + type: object + onDecline: + description: |- + OnDecline defines the action to take when the user explicitly declines the elicitation + Only used when Type is "elicitation" + properties: + action: + default: abort + description: |- + Action defines the action to take when the user declines or cancels + - skip_remaining: Skip remaining steps in the workflow + - abort: Abort the entire workflow execution + - continue: Continue to the next step + enum: + - skip_remaining + - abort + - continue + type: string + type: object + onError: + description: OnError defines error handling behavior + properties: + action: + default: abort + description: Action defines the action to take + on error + enum: + - abort + - continue + - retry + type: string + retryCount: + description: |- + RetryCount is the maximum number of retries + Only used when Action is "retry" + type: integer + retryDelay: + description: |- + RetryDelay is the delay between retry attempts + Only used when Action is "retry" + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + type: object + schema: + description: Schema defines the expected response + schema for elicitation + type: object + x-kubernetes-preserve-unknown-fields: true + step: + description: |- + InnerStep defines the step to execute for each item in the collection. + Only used when Type is "forEach". Only tool-type inner steps are supported. + type: object + x-kubernetes-preserve-unknown-fields: true + timeout: + description: Timeout is the maximum execution time + for this step + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + tool: + description: |- + Tool is the tool to call (format: "workload.tool_name") + Only used when Type is "tool" + type: string + type: + default: tool + description: Type is the step type (tool, elicitation, + etc.) + enum: + - tool + - elicitation + - forEach + type: string + required: + - id + type: object + type: array + timeout: + description: Timeout is the maximum workflow execution time. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + required: + - name + - steps + type: object + type: array + groupRef: + description: |- + Group references an existing MCPGroup that defines backend workloads. + In standalone CLI mode, this is set from the YAML config file. + In Kubernetes, the operator populates this from spec.groupRef during conversion. + type: string + incomingAuth: + description: |- + IncomingAuth configures how clients authenticate to the virtual MCP server. + When using the Kubernetes operator, this is populated by the converter from + VirtualMCPServerSpec.IncomingAuth and any values set here will be superseded. + properties: + authz: + description: Authz contains authorization configuration (optional). + properties: + policies: + description: Policies contains Cedar policy definitions + (when Type = "cedar"). + items: + type: string + type: array + primaryUpstreamProvider: + description: |- + PrimaryUpstreamProvider names the upstream IDP provider whose access + token should be used as the source of JWT claims for Cedar evaluation. + When empty, claims from the ToolHive-issued token are used. + Must match an upstream provider name configured in the embedded auth server + (e.g. "default", "github"). Only relevant when the embedded auth server is active. + type: string + type: + description: 'Type is the authz type: "cedar", "none"' + type: string + required: + - type + type: object + oidc: + description: OIDC contains OIDC configuration (when Type = + "oidc"). + properties: + audience: + description: Audience is the required token audience. + type: string + clientId: + description: ClientID is the OAuth client ID. + type: string + clientSecretEnv: + description: |- + ClientSecretEnv is the name of the environment variable containing the client secret. + This is the secure way to reference secrets - the actual secret value is never stored + in configuration files, only the environment variable name. + The secret value will be resolved from this environment variable at runtime. + type: string + insecureAllowHttp: + description: |- + InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing + WARNING: This is insecure and should NEVER be used in production + type: boolean + introspectionUrl: + description: |- + IntrospectionURL is the token introspection endpoint URL (RFC 7662). + When set, enables token introspection for opaque (non-JWT) tokens. + type: string + issuer: + description: Issuer is the OIDC issuer URL. + pattern: ^https?:// + type: string + jwksAllowPrivateIp: + description: |- + JwksAllowPrivateIP allows OIDC discovery and JWKS fetches to private IP addresses. + Enable when the embedded auth server runs on a loopback address and + the OIDC middleware needs to fetch its JWKS from that address. + Use with caution - only enable for trusted internal IDPs or testing. + type: boolean + jwksUrl: + description: |- + JWKSURL is the explicit JWKS endpoint URL. + When set, skips OIDC discovery and fetches the JWKS directly from this URL. + This is useful when the OIDC issuer does not serve a /.well-known/openid-configuration. + type: string + protectedResourceAllowPrivateIp: + description: |- + ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses + Use with caution - only enable for trusted internal IDPs or testing + type: boolean + resource: + description: |- + Resource is the OAuth 2.0 resource indicator (RFC 8707). + Used in WWW-Authenticate header and OAuth discovery metadata (RFC 9728). + If not specified, defaults to Audience. + type: string + scopes: + description: Scopes are the required OAuth scopes. + items: + type: string + type: array + required: + - audience + - clientId + - issuer + type: object + type: + description: 'Type is the auth type: "oidc", "local", "anonymous"' + type: string + required: + - type + type: object + metadata: + additionalProperties: + type: string + description: Metadata stores additional configuration metadata. + type: object + name: + description: Name is the virtual MCP server name. + type: string + operational: + description: Operational configures operational settings. + properties: + failureHandling: + description: FailureHandling configures failure handling behavior. + properties: + circuitBreaker: + description: CircuitBreaker configures circuit breaker + behavior. + properties: + enabled: + default: false + description: Enabled controls whether circuit breaker + is enabled. + type: boolean + failureThreshold: + default: 5 + description: |- + FailureThreshold is the number of failures before opening the circuit. + Must be >= 1. + minimum: 1 + type: integer + timeout: + default: 60s + description: |- + Timeout is the duration to wait before attempting to close the circuit. + Must be >= 1s to prevent thrashing. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + x-kubernetes-validations: + - message: timeout must be >= 1s + rule: self == '' || duration(self) >= duration('1s') + type: object + healthCheckInterval: + default: 30s + description: HealthCheckInterval is the interval between + health checks. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + healthCheckTimeout: + default: 10s + description: |- + HealthCheckTimeout is the maximum duration for a single health check operation. + Should be less than HealthCheckInterval to prevent checks from queuing up. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + partialFailureMode: + default: fail + description: |- + PartialFailureMode defines behavior when some backends are unavailable. + - fail: Fail entire request if any backend is unavailable + - best_effort: Continue with available backends + enum: + - fail + - best_effort + type: string + statusReportingInterval: + default: 30s + description: |- + StatusReportingInterval is the interval for reporting status updates to Kubernetes. + This controls how often the vMCP runtime reports backend health and phase changes. + Lower values provide faster status updates but increase API server load. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + unhealthyThreshold: + default: 3 + description: UnhealthyThreshold is the number of consecutive + failures before marking unhealthy. + type: integer + type: object + logLevel: + description: |- + LogLevel sets the logging level for the Virtual MCP server. + The only valid value is "debug" to enable debug logging. + When omitted or empty, the server uses info level logging. + enum: + - debug + type: string + timeouts: + description: Timeouts configures timeout settings. + properties: + default: + default: 30s + description: Default is the default timeout for backend + requests. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + perWorkload: + additionalProperties: + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + description: PerWorkload defines per-workload timeout + overrides. + type: object + type: object + type: object + optimizer: + description: |- + Optimizer configures the MCP optimizer for context optimization on large toolsets. + When enabled, vMCP exposes only find_tool and call_tool operations to clients + instead of all backend tools directly. This reduces token usage by allowing + LLMs to discover relevant tools on demand rather than receiving all tool definitions. + properties: + embeddingService: + description: |- + EmbeddingService is the full base URL of the embedding service endpoint + (e.g., http://my-embedding.default.svc.cluster.local:8080) for semantic + tool discovery. + + In a Kubernetes environment, it is more convenient to use the + VirtualMCPServerSpec.EmbeddingServerRef field instead of setting this + directly. EmbeddingServerRef references an EmbeddingServer CRD by name, + and the operator automatically resolves the referenced resource's + Status.URL to populate this field. This provides managed lifecycle + (the operator watches the EmbeddingServer for readiness and URL changes) + and avoids hardcoding service URLs in the config. If both + EmbeddingServerRef and this field are set, EmbeddingServerRef takes + precedence and this value is overridden with a warning. + type: string + embeddingServiceTimeout: + default: 30s + description: |- + EmbeddingServiceTimeout is the HTTP request timeout for calls to the embedding service. + Defaults to 30s if not specified. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + hybridSearchSemanticRatio: + description: |- + HybridSearchSemanticRatio controls the balance between semantic (meaning-based) + and keyword search results. 0.0 = all keyword, 1.0 = all semantic. + Defaults to "0.5" if not specified or empty. + Serialized as a string because CRDs do not support float types portably. + pattern: ^([0-9]*[.])?[0-9]+$ + type: string + maxToolsToReturn: + description: |- + MaxToolsToReturn is the maximum number of tool results returned by a search query. + Defaults to 8 if not specified or zero. + maximum: 50 + minimum: 1 + type: integer + semanticDistanceThreshold: + description: |- + SemanticDistanceThreshold is the maximum distance for semantic search results. + Results exceeding this threshold are filtered out from semantic search. + This threshold does not apply to keyword search. + Range: 0 = identical, 2 = completely unrelated. + Defaults to "1.0" if not specified or empty. + Serialized as a string because CRDs do not support float types portably. + pattern: ^([0-9]*[.])?[0-9]+$ + type: string + type: object + outgoingAuth: + description: |- + OutgoingAuth configures how the virtual MCP server authenticates to backends. + When using the Kubernetes operator, this is populated by the converter from + VirtualMCPServerSpec.OutgoingAuth and any values set here will be superseded. + properties: + backends: + additionalProperties: + description: |- + BackendAuthStrategy defines how to authenticate to a specific backend. + + This struct provides type-safe configuration for different authentication strategies + using HeaderInjection or TokenExchange fields based on the Type field. + properties: + headerInjection: + description: |- + HeaderInjection contains configuration for header injection auth strategy. + Used when Type = "header_injection". + properties: + headerName: + description: HeaderName is the name of the header + to inject (e.g., "Authorization"). + type: string + headerValue: + description: |- + HeaderValue is the static header value to inject. + Either HeaderValue or HeaderValueEnv should be set, not both. + type: string + headerValueEnv: + description: |- + HeaderValueEnv is the environment variable name containing the header value. + The value will be resolved at runtime from this environment variable. + Either HeaderValue or HeaderValueEnv should be set, not both. + type: string + required: + - headerName + type: object + tokenExchange: + description: |- + TokenExchange contains configuration for token exchange auth strategy. + Used when Type = "token_exchange". + properties: + audience: + description: Audience is the target audience for + the exchanged token. + type: string + clientId: + description: ClientID is the OAuth client ID for + the token exchange request. + type: string + clientSecret: + description: ClientSecret is the OAuth client secret + (use ClientSecretEnv for security). + type: string + clientSecretEnv: + description: |- + ClientSecretEnv is the environment variable name containing the client secret. + The value will be resolved at runtime from this environment variable. + type: string + scopes: + description: Scopes are the requested scopes for + the exchanged token. + items: + type: string + type: array + subjectProviderName: + description: |- + SubjectProviderName selects which upstream provider's token to use as the + subject token. When set, the token is looked up from Identity.UpstreamTokens + instead of using Identity.Token. + When left empty and an embedded authorization server is configured, the system + automatically populates this field with the first configured upstream provider name. + Set it explicitly to override that default or to select a specific provider when + multiple upstreams are configured. + type: string + subjectTokenType: + description: |- + SubjectTokenType is the token type of the incoming subject token. + Defaults to "urn:ietf:params:oauth:token-type:access_token" if not specified. + type: string + tokenUrl: + description: TokenURL is the OAuth token endpoint + URL for token exchange. + type: string + required: + - tokenUrl + type: object + type: + description: 'Type is the auth strategy: "unauthenticated", + "header_injection", "token_exchange", "upstream_inject"' + type: string + upstreamInject: + description: |- + UpstreamInject contains configuration for upstream inject auth strategy. + Used when Type = "upstream_inject". + properties: + providerName: + description: |- + ProviderName is the name of the upstream provider configured in the + embedded authorization server. Must match an entry in AuthServer.Upstreams. + type: string + required: + - providerName + type: object + required: + - type + type: object + description: Backends contains per-backend auth configuration. + type: object + default: + description: Default is the default auth strategy for backends + without explicit config. + properties: + headerInjection: + description: |- + HeaderInjection contains configuration for header injection auth strategy. + Used when Type = "header_injection". + properties: + headerName: + description: HeaderName is the name of the header + to inject (e.g., "Authorization"). + type: string + headerValue: + description: |- + HeaderValue is the static header value to inject. + Either HeaderValue or HeaderValueEnv should be set, not both. + type: string + headerValueEnv: + description: |- + HeaderValueEnv is the environment variable name containing the header value. + The value will be resolved at runtime from this environment variable. + Either HeaderValue or HeaderValueEnv should be set, not both. + type: string + required: + - headerName + type: object + tokenExchange: + description: |- + TokenExchange contains configuration for token exchange auth strategy. + Used when Type = "token_exchange". + properties: + audience: + description: Audience is the target audience for the + exchanged token. + type: string + clientId: + description: ClientID is the OAuth client ID for the + token exchange request. + type: string + clientSecret: + description: ClientSecret is the OAuth client secret + (use ClientSecretEnv for security). + type: string + clientSecretEnv: + description: |- + ClientSecretEnv is the environment variable name containing the client secret. + The value will be resolved at runtime from this environment variable. + type: string + scopes: + description: Scopes are the requested scopes for the + exchanged token. + items: + type: string + type: array + subjectProviderName: + description: |- + SubjectProviderName selects which upstream provider's token to use as the + subject token. When set, the token is looked up from Identity.UpstreamTokens + instead of using Identity.Token. + When left empty and an embedded authorization server is configured, the system + automatically populates this field with the first configured upstream provider name. + Set it explicitly to override that default or to select a specific provider when + multiple upstreams are configured. + type: string + subjectTokenType: + description: |- + SubjectTokenType is the token type of the incoming subject token. + Defaults to "urn:ietf:params:oauth:token-type:access_token" if not specified. + type: string + tokenUrl: + description: TokenURL is the OAuth token endpoint + URL for token exchange. + type: string + required: + - tokenUrl + type: object + type: + description: 'Type is the auth strategy: "unauthenticated", + "header_injection", "token_exchange", "upstream_inject"' + type: string + upstreamInject: + description: |- + UpstreamInject contains configuration for upstream inject auth strategy. + Used when Type = "upstream_inject". + properties: + providerName: + description: |- + ProviderName is the name of the upstream provider configured in the + embedded authorization server. Must match an entry in AuthServer.Upstreams. + type: string + required: + - providerName + type: object + required: + - type + type: object + source: + description: |- + Source defines how to discover backend auth: "inline", "discovered" + - inline: Explicit configuration in OutgoingAuth + - discovered: Auto-discover from backend MCPServer.externalAuthConfigRef (Kubernetes only) + type: string + required: + - source + type: object + sessionStorage: + description: |- + SessionStorage configures session storage for stateful horizontal scaling. + When provider is "redis", the operator injects Redis connection parameters + (address, db, keyPrefix) here. The Redis password is provided separately via + the THV_SESSION_REDIS_PASSWORD environment variable. + properties: + address: + description: Address is the Redis server address (required + when provider is redis). + type: string + db: + default: 0 + description: DB is the Redis database number. + format: int32 + minimum: 0 + type: integer + keyPrefix: + description: KeyPrefix is an optional prefix for all Redis + keys used by ToolHive. + type: string + provider: + description: Provider is the session storage backend type. + enum: + - memory + - redis + type: string + required: + - provider + type: object + telemetry: + description: |- + Telemetry configures OpenTelemetry-based observability for the Virtual MCP server + including distributed tracing, OTLP metrics export, and Prometheus metrics endpoint. + Deprecated (Kubernetes operator only): When deploying via the operator, use + VirtualMCPServer.spec.telemetryConfigRef to reference a shared MCPTelemetryConfig + resource instead. This field remains valid for standalone (non-operator) deployments. + properties: + caCertPath: + description: |- + CACertPath is the file path to a CA certificate bundle for the OTLP endpoint. + When set, the OTLP exporters use this CA to verify the collector's TLS certificate + instead of relying solely on the system CA pool. + type: string + customAttributes: + additionalProperties: + type: string + description: |- + CustomAttributes contains custom resource attributes to be added to all telemetry signals. + These are parsed from CLI flags (--otel-custom-attributes) or environment variables + (OTEL_RESOURCE_ATTRIBUTES) as key=value pairs. + type: object + enablePrometheusMetricsPath: + default: false + description: |- + EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint. + The metrics are served on the main transport port at /metrics. + This is separate from OTLP metrics which are sent to the Endpoint. + type: boolean + endpoint: + description: Endpoint is the OTLP endpoint URL + type: string + environmentVariables: + description: |- + EnvironmentVariables is a list of environment variable names that should be + included in telemetry spans as attributes. Only variables in this list will + be read from the host machine and included in spans for observability. + Example: ["NODE_ENV", "DEPLOYMENT_ENV", "SERVICE_VERSION"] + items: + type: string + type: array + headers: + additionalProperties: + type: string + description: Headers contains authentication headers for the + OTLP endpoint. + type: object + insecure: + default: false + description: Insecure indicates whether to use HTTP instead + of HTTPS for the OTLP endpoint. + type: boolean + metricsEnabled: + default: false + description: |- + MetricsEnabled controls whether OTLP metrics are enabled. + When false, OTLP metrics are not sent even if an endpoint is configured. + This is independent of EnablePrometheusMetricsPath. + type: boolean + samplingRate: + default: "0.05" + description: |- + SamplingRate is the trace sampling rate (0.0-1.0) as a string. + Only used when TracingEnabled is true. + Example: "0.05" for 5% sampling. + type: string + serviceName: + description: |- + ServiceName is the service name for telemetry. + When omitted, defaults to the server name (e.g., VirtualMCPServer name). + type: string + serviceVersion: + description: |- + ServiceVersion is the service version for telemetry. + When omitted, defaults to the ToolHive version. + type: string + tracingEnabled: + default: false + description: |- + TracingEnabled controls whether distributed tracing is enabled. + When false, no tracer provider is created even if an endpoint is configured. + type: boolean + useLegacyAttributes: + default: true + description: |- + UseLegacyAttributes controls whether legacy (pre-MCP OTEL semconv) attribute names + are emitted alongside the new standard attribute names. When true, spans include both + old and new attribute names for backward compatibility with existing dashboards. + Currently defaults to true; this will change to false in a future release. + type: boolean + type: object + type: object + x-kubernetes-preserve-unknown-fields: true + embeddingServerRef: + description: |- + EmbeddingServerRef references an existing EmbeddingServer resource by name. + When the optimizer is enabled, this field is required to point to a ready EmbeddingServer + that provides embedding capabilities. + The referenced EmbeddingServer must exist in the same namespace and be ready. + properties: + name: + description: Name is the name of the EmbeddingServer resource + type: string + required: + - name + type: object + groupRef: + description: |- + GroupRef references the MCPGroup that defines backend workloads. + The referenced MCPGroup must exist in the same namespace. + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object + incomingAuth: + description: |- + IncomingAuth configures authentication for clients connecting to the Virtual MCP server. + Must be explicitly set - use "anonymous" type when no authentication is required. + This field takes precedence over config.IncomingAuth and should be preferred because it + supports Kubernetes-native secret references (SecretKeyRef, ConfigMapRef) for secure + dynamic discovery of credentials, rather than requiring secrets to be embedded in config. + properties: + authzConfig: + description: |- + AuthzConfig defines authorization policy configuration + Reuses MCPServer authz patterns + properties: + configMap: + description: |- + ConfigMap references a ConfigMap containing authorization configuration + Only used when Type is "configMap" + properties: + key: + default: authz.json + description: Key is the key in the ConfigMap that contains + the authorization configuration + type: string + name: + description: Name is the name of the ConfigMap + type: string + required: + - name + type: object + inline: + description: |- + Inline contains direct authorization configuration + Only used when Type is "inline" + properties: + entitiesJson: + default: '[]' + description: EntitiesJSON is a JSON string representing + Cedar entities + type: string + policies: + description: Policies is a list of Cedar policy strings + items: + type: string + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - policies + type: object + type: + default: configMap + description: Type is the type of authorization configuration + enum: + - configMap + - inline + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: configMap must be set when type is 'configMap', and + must not be set otherwise + rule: 'self.type == ''configMap'' ? has(self.configMap) : !has(self.configMap)' + - message: inline must be set when type is 'inline', and must + not be set otherwise + rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + oidcConfigRef: + description: |- + OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. + The referenced MCPOIDCConfig must exist in the same namespace as this VirtualMCPServer. + Per-server overrides (audience, scopes) are specified here; shared provider config + lives in the MCPOIDCConfig resource. + properties: + audience: + description: |- + Audience is the expected audience for token validation. + This MUST be unique per server to prevent token replay attacks. + minLength: 1 + type: string + name: + description: Name is the name of the MCPOIDCConfig resource + minLength: 1 + type: string + resourceUrl: + description: |- + ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728). + When the server is exposed via Ingress or gateway, set this to the external + URL that MCP clients connect to. If not specified, defaults to the internal + Kubernetes service URL. + type: string + scopes: + description: |- + Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728). + If empty, defaults to ["openid"]. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - audience + - name + type: object + type: + description: |- + Type defines the authentication type: anonymous or oidc + When no authentication is required, explicitly set this to "anonymous" + enum: + - anonymous + - oidc + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: spec.incomingAuth.oidcConfigRef is required when type is + oidc + rule: 'self.type == ''oidc'' ? has(self.oidcConfigRef) : true' + outgoingAuth: + description: |- + OutgoingAuth configures authentication from Virtual MCP to backend MCPServers. + This field takes precedence over config.OutgoingAuth and should be preferred because it + supports Kubernetes-native secret references (SecretKeyRef, ConfigMapRef) for secure + dynamic discovery of credentials, rather than requiring secrets to be embedded in config. + properties: + backends: + additionalProperties: + description: BackendAuthConfig defines authentication configuration + for a backend MCPServer + properties: + externalAuthConfigRef: + description: |- + ExternalAuthConfigRef references an MCPExternalAuthConfig resource + Only used when Type is "externalAuthConfigRef" + properties: + name: + description: Name is the name of the MCPExternalAuthConfig + resource + type: string + required: + - name + type: object + type: + description: Type defines the authentication type + enum: + - discovered + - externalAuthConfigRef + type: string + required: + - type + type: object + description: |- + Backends defines per-backend authentication overrides + Works in all modes (discovered, inline) + type: object + default: + description: Default defines default behavior for backends without + explicit auth config + properties: + externalAuthConfigRef: + description: |- + ExternalAuthConfigRef references an MCPExternalAuthConfig resource + Only used when Type is "externalAuthConfigRef" + properties: + name: + description: Name is the name of the MCPExternalAuthConfig + resource + type: string + required: + - name + type: object + type: + description: Type defines the authentication type + enum: + - discovered + - externalAuthConfigRef + type: string + required: + - type + type: object + source: + default: discovered + description: |- + Source defines how backend authentication configurations are determined + - discovered: Automatically discover from backend's MCPServer.spec.externalAuthConfigRef + - inline: Explicit per-backend configuration in VirtualMCPServer + enum: + - discovered + - inline + type: string + type: object + podTemplateSpec: + description: |- + PodTemplateSpec defines the pod template to use for the Virtual MCP server + This allows for customizing the pod configuration beyond what is provided by the other fields. + Note that to modify the specific container the Virtual MCP server runs in, you must specify + the 'vmcp' container name in the PodTemplateSpec. + This field accepts a PodTemplateSpec object as JSON/YAML. + type: object + x-kubernetes-preserve-unknown-fields: true + replicas: + description: |- + Replicas is the desired number of vMCP pod replicas. + VirtualMCPServer creates a single Deployment for the vMCP aggregator process, + so there is only one replicas field (unlike MCPServer which has separate + Replicas and BackendReplicas for its two Deployments). + When nil, the operator does not set Deployment.Spec.Replicas, leaving replica + management to an HPA or other external controller. + format: int32 + minimum: 0 + type: integer + serviceAccount: + description: |- + ServiceAccount is the name of an already existing service account to use by the Virtual MCP server. + If not specified, a ServiceAccount will be created automatically and used by the Virtual MCP server. + type: string + serviceType: + default: ClusterIP + description: ServiceType specifies the Kubernetes service type for + the Virtual MCP server + enum: + - ClusterIP + - NodePort + - LoadBalancer + type: string + sessionAffinity: + default: ClientIP + description: |- + SessionAffinity controls whether the Service routes repeated client connections to the same pod. + MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default. + Set to "None" for stateless servers or when using an external load balancer with its own affinity. + enum: + - ClientIP + - None + type: string + sessionStorage: + description: |- + SessionStorage configures session storage for stateful horizontal scaling. + When nil, no session storage is configured. + properties: + address: + description: Address is the Redis server address (required when + provider is redis) + minLength: 1 + type: string + db: + default: 0 + description: DB is the Redis database number + format: int32 + minimum: 0 + type: integer + keyPrefix: + description: KeyPrefix is an optional prefix for all Redis keys + used by ToolHive + type: string + passwordRef: + description: PasswordRef is a reference to a Secret key containing + the Redis password + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + provider: + description: Provider is the session storage backend type + enum: + - memory + - redis + type: string + required: + - provider + type: object + x-kubernetes-validations: + - message: address is required + rule: 'self.provider == ''redis'' ? has(self.address) : true' + telemetryConfigRef: + description: |- + TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. + The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer. + Cross-namespace references are not supported for security and isolation reasons. + properties: + name: + description: Name is the name of the MCPTelemetryConfig resource + minLength: 1 + type: string + serviceName: + description: |- + ServiceName overrides the telemetry service name for this specific server. + This MUST be unique per server for proper observability (e.g., distinguishing + traces and metrics from different servers sharing the same collector). + If empty, defaults to the server name with "thv-" prefix at runtime. + type: string + required: + - name + type: object + required: + - groupRef + - incomingAuth + type: object + status: + description: VirtualMCPServerStatus defines the observed state of VirtualMCPServer + properties: + backendCount: + description: |- + BackendCount is the number of routable backends (ready + unauthenticated). + Excludes unavailable, degraded, and unknown backends. + format: int32 + type: integer + conditions: + description: Conditions represent the latest available observations + of the VirtualMCPServer's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + discoveredBackends: + description: DiscoveredBackends lists discovered backend configurations + from the MCPGroup + items: + description: |- + DiscoveredBackend represents a backend server discovered by vMCP runtime. + This type is shared with the Kubernetes operator CRD (VirtualMCPServer.Status.DiscoveredBackends). + properties: + authConfigRef: + description: AuthConfigRef is the name of the discovered MCPExternalAuthConfig + (if any) + type: string + authType: + description: AuthType is the type of authentication configured + type: string + circuitBreakerState: + description: |- + CircuitBreakerState is the current circuit breaker state (closed, open, half-open). + Empty when circuit breaker is disabled or not configured. + enum: + - closed + - open + - half-open + type: string + circuitLastChanged: + description: |- + CircuitLastChanged is the timestamp when the circuit breaker state last changed. + Empty when circuit breaker is disabled or has never changed state. + format: date-time + type: string + consecutiveFailures: + description: |- + ConsecutiveFailures is the current count of consecutive health check failures. + Resets to 0 when the backend becomes healthy again. + type: integer + lastHealthCheck: + description: LastHealthCheck is the timestamp of the last health + check + format: date-time + type: string + message: + description: Message provides additional information about the + backend status + type: string + name: + description: Name is the name of the backend MCPServer + type: string + status: + description: |- + Status is the current status of the backend (ready, degraded, unavailable, unauthenticated, unknown). + Use BackendHealthStatus.ToCRDStatus() to populate this field. + type: string + url: + description: URL is the URL of the backend MCPServer + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + message: + description: Message provides additional information about the current + phase + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + for this VirtualMCPServer + format: int64 + type: integer + oidcConfigHash: + description: |- + OIDCConfigHash is the hash of the referenced MCPOIDCConfig spec for change detection. + Only populated when IncomingAuth.OIDCConfigRef is set. + type: string + phase: + default: Pending + description: Phase is the current phase of the VirtualMCPServer + enum: + - Pending + - Ready + - Degraded + - Failed + type: string + telemetryConfigHash: + description: |- + TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection. + Only populated when TelemetryConfigRef is set. + type: string + url: + description: URL is the URL where the Virtual MCP server can be accessed + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - description: The phase of the VirtualMCPServer + jsonPath: .status.phase + name: Phase + type: string + - description: Virtual MCP server URL + jsonPath: .status.url + name: URL + type: string + - description: Discovered backends count + jsonPath: .status.backendCount + name: Backends + type: integer + - description: Age + jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_embeddingservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_embeddingservers.yaml index 3638540c2d..96ed64805a 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_embeddingservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_embeddingservers.yaml @@ -38,7 +38,353 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: EmbeddingServer is the deprecated v1alpha1 version of the EmbeddingServer + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: EmbeddingServerSpec defines the desired state of EmbeddingServer + properties: + args: + description: Args are additional arguments to pass to the embedding + inference server + items: + type: string + type: array + x-kubernetes-list-type: atomic + env: + description: Env are environment variables to set in the container + items: + description: EnvVar represents an environment variable in a container + properties: + name: + description: Name of the environment variable + type: string + value: + description: Value of the environment variable + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + hfTokenSecretRef: + description: |- + HFTokenSecretRef is a reference to a Kubernetes Secret containing the huggingface token. + If provided, the secret value will be provided to the embedding server for authentication with huggingface. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + image: + default: ghcr.io/huggingface/text-embeddings-inference:cpu-latest + description: |- + Image is the container image for the embedding inference server. + Images must be from HuggingFace Text Embeddings Inference (https://github.com/huggingface/text-embeddings-inference). + type: string + imagePullPolicy: + default: IfNotPresent + description: ImagePullPolicy defines the pull policy for the container + image + enum: + - Always + - Never + - IfNotPresent + type: string + model: + default: BAAI/bge-small-en-v1.5 + description: Model is the HuggingFace embedding model to use (e.g., + "sentence-transformers/all-MiniLM-L6-v2") + type: string + modelCache: + description: |- + ModelCache configures persistent storage for downloaded models + When enabled, models are cached in a PVC and reused across pod restarts + properties: + accessMode: + default: ReadWriteOnce + description: AccessMode is the access mode for the PVC + enum: + - ReadWriteOnce + - ReadWriteMany + - ReadOnlyMany + type: string + enabled: + default: true + description: Enabled controls whether model caching is enabled + type: boolean + size: + default: 10Gi + description: Size is the size of the PVC for model caching (e.g., + "10Gi") + type: string + storageClassName: + description: |- + StorageClassName is the storage class to use for the PVC + If not specified, uses the cluster's default storage class + type: string + type: object + podTemplateSpec: + description: |- + PodTemplateSpec allows customizing the pod (node selection, tolerations, etc.) + This field accepts a PodTemplateSpec object as JSON/YAML. + Note that to modify the specific container the embedding server runs in, you must specify + the 'embedding' container name in the PodTemplateSpec. + type: object + x-kubernetes-preserve-unknown-fields: true + port: + default: 8080 + description: Port is the port to expose the embedding service on + format: int32 + maximum: 65535 + minimum: 1 + type: integer + replicas: + default: 1 + description: Replicas is the number of embedding server replicas to + run + format: int32 + minimum: 1 + type: integer + resourceOverrides: + description: ResourceOverrides allows overriding annotations and labels + for resources created by the operator + properties: + persistentVolumeClaim: + description: PersistentVolumeClaim defines overrides for the PVC + resource + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + type: object + service: + description: Service defines overrides for the Service resource + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + type: object + statefulSet: + description: StatefulSet defines overrides for the StatefulSet + resource + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + podTemplateMetadataOverrides: + description: PodTemplateMetadataOverrides defines metadata + overrides for the pod template + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + type: object + type: object + type: object + resources: + description: Resources defines compute resources for the embedding + server + properties: + limits: + description: Limits describes the maximum amount of compute resources + allowed + properties: + cpu: + description: CPU is the CPU limit in cores (e.g., "500m" for + 0.5 cores) + type: string + memory: + description: Memory is the memory limit in bytes (e.g., "64Mi" + for 64 megabytes) + type: string + type: object + requests: + description: Requests describes the minimum amount of compute + resources required + properties: + cpu: + description: CPU is the CPU limit in cores (e.g., "500m" for + 0.5 cores) + type: string + memory: + description: Memory is the memory limit in bytes (e.g., "64Mi" + for 64 megabytes) + type: string + type: object + type: object + type: object + status: + description: EmbeddingServerStatus defines the observed state of EmbeddingServer + properties: + conditions: + description: Conditions represent the latest available observations + of the EmbeddingServer's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + message: + description: Message provides additional information about the current + phase + type: string + observedGeneration: + description: ObservedGeneration reflects the generation most recently + observed by the controller + format: int64 + type: integer + phase: + description: Phase is the current phase of the EmbeddingServer + enum: + - Pending + - Downloading + - Ready + - Failed + - Terminating + type: string + readyReplicas: + description: ReadyReplicas is the number of ready replicas + format: int32 + type: integer + url: + description: URL is the URL where the embedding service can be accessed + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .spec.model + name: Model + type: string + - jsonPath: .status.readyReplicas + name: Ready + type: integer + - jsonPath: .status.url + name: URL + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: EmbeddingServer is the Schema for the embeddingservers API diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index e74b98d8ee..ea6684ae6c 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -35,7 +35,1025 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPExternalAuthConfig is the deprecated v1alpha1 version of the + MCPExternalAuthConfig resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPExternalAuthConfigSpec defines the desired state of MCPExternalAuthConfig. + MCPExternalAuthConfig resources are namespace-scoped and can only be referenced by + MCPServer resources in the same namespace. + properties: + awsSts: + description: |- + AWSSts configures AWS STS authentication with SigV4 request signing + Only used when Type is "awsSts" + properties: + fallbackRoleArn: + description: |- + FallbackRoleArn is the IAM role ARN to assume when no role mappings match + Used as the default role when RoleMappings is empty or no mapping matches + At least one of FallbackRoleArn or RoleMappings must be configured (enforced by webhook) + pattern: ^arn:(aws|aws-cn|aws-us-gov):iam::\d{12}:role/[\w+=,.@\-_/]+$ + type: string + region: + description: Region is the AWS region for the STS endpoint and + service (e.g., "us-east-1", "eu-west-1") + minLength: 1 + pattern: ^[a-z]{2}(-[a-z]+)+-\d+$ + type: string + roleClaim: + default: groups + description: |- + RoleClaim is the JWT claim to use for role mapping evaluation + Defaults to "groups" to match common OIDC group claims + type: string + roleMappings: + description: |- + RoleMappings defines claim-based role selection rules + Allows mapping JWT claims (e.g., groups, roles) to specific IAM roles + Lower priority values are evaluated first (higher priority) + items: + description: |- + RoleMapping defines a rule for mapping JWT claims to IAM roles. + Mappings are evaluated in priority order (lower number = higher priority), and the first + matching rule determines which IAM role to assume. + Exactly one of Claim or Matcher must be specified. + properties: + claim: + description: |- + Claim is a simple claim value to match against + The claim type is specified by AWSStsConfig.RoleClaim + For example, if RoleClaim is "groups", this would be a group name + Internally compiled to a CEL expression: "<claim_value>" in claims["<role_claim>"] + Mutually exclusive with Matcher + minLength: 1 + type: string + matcher: + description: |- + Matcher is a CEL expression for complex matching against JWT claims + The expression has access to a "claims" variable containing all JWT claims as map[string]any + Examples: + - "admins" in claims["groups"] + - claims["sub"] == "user123" && !("act" in claims) + Mutually exclusive with Claim + minLength: 1 + type: string + priority: + description: |- + Priority determines evaluation order (lower values = higher priority) + Allows fine-grained control over role selection precedence + When omitted, this mapping has the lowest possible priority and + configuration order acts as tie-breaker via stable sort + format: int32 + minimum: 0 + type: integer + roleArn: + description: RoleArn is the IAM role ARN to assume when + this mapping matches + pattern: ^arn:(aws|aws-cn|aws-us-gov):iam::\d{12}:role/[\w+=,.@\-_/]+$ + type: string + required: + - roleArn + type: object + type: array + x-kubernetes-list-type: atomic + service: + default: aws-mcp + description: |- + Service is the AWS service name for SigV4 signing + Defaults to "aws-mcp" for AWS MCP Server endpoints + type: string + sessionDuration: + default: 3600 + description: |- + SessionDuration is the duration in seconds for the STS session + Must be between 900 (15 minutes) and 43200 (12 hours) + Defaults to 3600 (1 hour) if not specified + format: int32 + maximum: 43200 + minimum: 900 + type: integer + sessionNameClaim: + default: sub + description: |- + SessionNameClaim is the JWT claim to use for role session name + Defaults to "sub" to use the subject claim + type: string + required: + - region + type: object + bearerToken: + description: |- + BearerToken configures bearer token authentication + Only used when Type is "bearerToken" + properties: + tokenSecretRef: + description: TokenSecretRef references a Kubernetes Secret containing + the bearer token + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - tokenSecretRef + type: object + embeddedAuthServer: + description: |- + EmbeddedAuthServer configures an embedded OAuth2/OIDC authorization server + Only used when Type is "embeddedAuthServer" + properties: + authorizationEndpointBaseUrl: + description: |- + AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint + in the OAuth discovery document. When set, the discovery document will advertise + `{authorizationEndpointBaseUrl}/oauth/authorize` instead of `{issuer}/oauth/authorize`. + All other endpoints (token, registration, JWKS) remain derived from the issuer. + This is useful when the browser-facing authorization endpoint needs to be on a + different host than the issuer used for backend-to-backend calls. + Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. + pattern: ^https?://[^\s?#]+[^/\s?#]$ + type: string + hmacSecretRefs: + description: |- + HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing + authorization codes and refresh tokens (opaque tokens). + Current secret must be at least 32 bytes and cryptographically random. + Supports secret rotation via multiple entries (first is current, rest are for verification). + If not specified, an ephemeral secret will be auto-generated (development only - + auth codes and refresh tokens will be invalid after restart). + items: + description: SecretKeyRef is a reference to a key within a Secret + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + type: array + x-kubernetes-list-type: atomic + issuer: + description: |- + Issuer is the issuer identifier for this authorization server. + This will be included in the "iss" claim of issued tokens. + Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). + pattern: ^https?://[^\s?#]+[^/\s?#]$ + type: string + signingKeySecretRefs: + description: |- + SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations. + Supports key rotation by allowing multiple keys (oldest keys are used for verification only). + If not specified, an ephemeral signing key will be auto-generated (development only - + JWTs will be invalid after restart). + items: + description: SecretKeyRef is a reference to a key within a Secret + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + maxItems: 5 + type: array + x-kubernetes-list-type: atomic + storage: + description: |- + Storage configures the storage backend for the embedded auth server. + If not specified, defaults to in-memory storage. + properties: + redis: + description: |- + Redis configures the Redis storage backend. + Required when type is "redis". + properties: + aclUserConfig: + description: ACLUserConfig configures Redis ACL user authentication. + properties: + passwordSecretRef: + description: PasswordSecretRef references a Secret + containing the Redis ACL password. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + usernameSecretRef: + description: UsernameSecretRef references a Secret + containing the Redis ACL username. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - passwordSecretRef + - usernameSecretRef + type: object + dialTimeout: + default: 5s + description: |- + DialTimeout is the timeout for establishing connections. + Format: Go duration string (e.g., "5s", "1m"). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + readTimeout: + default: 3s + description: |- + ReadTimeout is the timeout for socket reads. + Format: Go duration string (e.g., "3s", "1m"). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + sentinelConfig: + description: SentinelConfig holds Redis Sentinel configuration. + properties: + db: + default: 0 + description: DB is the Redis database number. + format: int32 + type: integer + masterName: + description: MasterName is the name of the Redis master + monitored by Sentinel. + type: string + sentinelAddrs: + description: |- + SentinelAddrs is a list of Sentinel host:port addresses. + Mutually exclusive with SentinelService. + items: + type: string + type: array + x-kubernetes-list-type: atomic + sentinelService: + description: |- + SentinelService enables automatic discovery from a Kubernetes Service. + Mutually exclusive with SentinelAddrs. + properties: + name: + description: Name of the Sentinel Service. + type: string + namespace: + description: Namespace of the Sentinel Service + (defaults to same namespace). + type: string + port: + default: 26379 + description: Port of the Sentinel service. + format: int32 + type: integer + required: + - name + type: object + required: + - masterName + type: object + sentinelTls: + description: |- + SentinelTLS configures TLS for connections to Sentinel instances. + Presence of this field enables TLS. Omit to use plaintext. + When omitted, sentinel connections use plaintext (no fallback to TLS config). + properties: + caCertSecretRef: + description: |- + CACertSecretRef references a Secret containing a PEM-encoded CA certificate + for verifying the server. When not specified, system root CAs are used. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + insecureSkipVerify: + description: |- + InsecureSkipVerify skips TLS certificate verification. + Use when connecting to services with self-signed certificates. + type: boolean + type: object + tls: + description: |- + TLS configures TLS for connections to the Redis/Valkey master. + Presence of this field enables TLS. Omit to use plaintext. + properties: + caCertSecretRef: + description: |- + CACertSecretRef references a Secret containing a PEM-encoded CA certificate + for verifying the server. When not specified, system root CAs are used. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + insecureSkipVerify: + description: |- + InsecureSkipVerify skips TLS certificate verification. + Use when connecting to services with self-signed certificates. + type: boolean + type: object + writeTimeout: + default: 3s + description: |- + WriteTimeout is the timeout for socket writes. + Format: Go duration string (e.g., "3s", "1m"). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + required: + - aclUserConfig + - sentinelConfig + type: object + type: + default: memory + description: |- + Type specifies the storage backend type. + Valid values: "memory" (default), "redis". + enum: + - memory + - redis + type: string + type: object + tokenLifespans: + description: |- + TokenLifespans configures the duration that various tokens are valid. + If not specified, defaults are applied (access: 1h, refresh: 7d, authCode: 10m). + properties: + accessTokenLifespan: + description: |- + AccessTokenLifespan is the duration that access tokens are valid. + Format: Go duration string (e.g., "1h", "30m", "24h"). + If empty, defaults to 1 hour. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + authCodeLifespan: + description: |- + AuthCodeLifespan is the duration that authorization codes are valid. + Format: Go duration string (e.g., "10m", "5m"). + If empty, defaults to 10 minutes. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + refreshTokenLifespan: + description: |- + RefreshTokenLifespan is the duration that refresh tokens are valid. + Format: Go duration string (e.g., "168h", "7d" as "168h"). + If empty, defaults to 7 days (168h). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + type: object + upstreamProviders: + description: |- + UpstreamProviders configures connections to upstream Identity Providers. + The embedded auth server delegates authentication to these providers. + MCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple. + items: + description: UpstreamProviderConfig defines configuration for + an upstream Identity Provider. + properties: + name: + description: |- + Name uniquely identifies this upstream provider. + Used for routing decisions and session binding in multi-upstream scenarios. + Must be lowercase alphanumeric with hyphens (DNS-label-like). + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ + type: string + oauth2Config: + description: |- + OAuth2Config contains OAuth 2.0-specific configuration. + Required when Type is "oauth2", must be nil when Type is "oidc". + properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object + authorizationEndpoint: + description: AuthorizationEndpoint is the URL for the + OAuth authorization endpoint. + pattern: ^https?://.*$ + type: string + clientId: + description: ClientID is the OAuth 2.0 client identifier + registered with the upstream IDP. + type: string + clientSecretRef: + description: |- + ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret. + Optional for public clients using PKCE instead of client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + redirectUri: + description: |- + RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the + URL associated with the resource (e.g., MCPServer or vMCP) using this config. + type: string + scopes: + description: Scopes are the OAuth scopes to request + from the upstream IDP. + items: + type: string + type: array + x-kubernetes-list-type: atomic + tokenEndpoint: + description: TokenEndpoint is the URL for the OAuth + token endpoint. + pattern: ^https?://.*$ + type: string + tokenResponseMapping: + description: |- + TokenResponseMapping configures custom field extraction from non-standard token responses. + Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths + instead of returning them at the top level. When set, ToolHive performs the token + exchange HTTP call directly and extracts fields using the configured dot-notation paths. + If nil, standard OAuth 2.0 token response parsing is used. + properties: + accessTokenPath: + description: |- + AccessTokenPath is the dot-notation path to the access token in the response. + Example: "authed_user.access_token" + minLength: 1 + type: string + expiresInPath: + description: |- + ExpiresInPath is the dot-notation path to the expires_in value (in seconds). + If not specified, defaults to "expires_in". + type: string + refreshTokenPath: + description: |- + RefreshTokenPath is the dot-notation path to the refresh token in the response. + If not specified, defaults to "refresh_token". + type: string + scopePath: + description: |- + ScopePath is the dot-notation path to the scope string in the response. + If not specified, defaults to "scope". + type: string + required: + - accessTokenPath + type: object + userInfo: + description: |- + UserInfo contains configuration for fetching user information from the upstream provider. + Required for OAuth2 providers to resolve user identity. + properties: + additionalHeaders: + additionalProperties: + type: string + description: |- + AdditionalHeaders contains extra headers to include in the userinfo request. + Useful for providers that require specific headers (e.g., GitHub's Accept header). + type: object + endpointUrl: + description: EndpointURL is the URL of the userinfo + endpoint. + pattern: ^https?://.*$ + type: string + fieldMapping: + description: |- + FieldMapping contains custom field mapping configuration for non-standard providers. + If nil, standard OIDC field names are used ("sub", "name", "email"). + properties: + emailFields: + description: |- + EmailFields is an ordered list of field names to try for the email address. + The first non-empty value found will be used. + Default: ["email"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + nameFields: + description: |- + NameFields is an ordered list of field names to try for the display name. + The first non-empty value found will be used. + Default: ["name"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectFields: + description: |- + SubjectFields is an ordered list of field names to try for the user ID. + The first non-empty value found will be used. + Default: ["sub"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpMethod: + description: |- + HTTPMethod is the HTTP method to use for the userinfo request. + If not specified, defaults to GET. + enum: + - GET + - POST + type: string + required: + - endpointUrl + type: object + required: + - authorizationEndpoint + - clientId + - tokenEndpoint + - userInfo + type: object + oidcConfig: + description: |- + OIDCConfig contains OIDC-specific configuration. + Required when Type is "oidc", must be nil when Type is "oauth2". + properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Note: when using access_type=offline, also set explicit scopes to avoid + the default offline_access scope being sent alongside it. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object + clientId: + description: ClientID is the OAuth 2.0 client identifier + registered with the upstream IDP. + type: string + clientSecretRef: + description: |- + ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret. + Optional for public clients using PKCE instead of client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + issuerUrl: + description: |- + IssuerURL is the OIDC issuer URL for automatic endpoint discovery. + Must be a valid HTTPS URL. + pattern: ^https://.*$ + type: string + redirectUri: + description: |- + RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the + URL associated with the resource (e.g., MCPServer or vMCP) using this config. + type: string + scopes: + description: |- + Scopes are the OAuth scopes to request from the upstream IDP. + If not specified, defaults to ["openid", "offline_access"]. + When using additionalAuthorizationParams with provider-specific refresh token + mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid + sending both offline_access and the provider-specific parameter. + items: + type: string + type: array + x-kubernetes-list-type: atomic + userInfoOverride: + description: |- + UserInfoOverride allows customizing UserInfo fetching behavior for OIDC providers. + By default, the UserInfo endpoint is discovered automatically via OIDC discovery. + Use this to override the endpoint URL, HTTP method, or field mappings for providers + that return non-standard claim names in their UserInfo response. + properties: + additionalHeaders: + additionalProperties: + type: string + description: |- + AdditionalHeaders contains extra headers to include in the userinfo request. + Useful for providers that require specific headers (e.g., GitHub's Accept header). + type: object + endpointUrl: + description: EndpointURL is the URL of the userinfo + endpoint. + pattern: ^https?://.*$ + type: string + fieldMapping: + description: |- + FieldMapping contains custom field mapping configuration for non-standard providers. + If nil, standard OIDC field names are used ("sub", "name", "email"). + properties: + emailFields: + description: |- + EmailFields is an ordered list of field names to try for the email address. + The first non-empty value found will be used. + Default: ["email"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + nameFields: + description: |- + NameFields is an ordered list of field names to try for the display name. + The first non-empty value found will be used. + Default: ["name"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectFields: + description: |- + SubjectFields is an ordered list of field names to try for the user ID. + The first non-empty value found will be used. + Default: ["sub"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpMethod: + description: |- + HTTPMethod is the HTTP method to use for the userinfo request. + If not specified, defaults to GET. + enum: + - GET + - POST + type: string + required: + - endpointUrl + type: object + required: + - clientId + - issuerUrl + type: object + type: + description: 'Type specifies the provider type: "oidc" or + "oauth2"' + enum: + - oidc + - oauth2 + type: string + required: + - name + - type + type: object + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + required: + - issuer + - upstreamProviders + type: object + headerInjection: + description: |- + HeaderInjection configures custom HTTP header injection + Only used when Type is "headerInjection" + properties: + headerName: + description: HeaderName is the name of the HTTP header to inject + minLength: 1 + type: string + valueSecretRef: + description: ValueSecretRef references a Kubernetes Secret containing + the header value + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - headerName + - valueSecretRef + type: object + tokenExchange: + description: |- + TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange + Only used when Type is "tokenExchange" + properties: + audience: + description: Audience is the target audience for the exchanged + token + type: string + clientId: + description: |- + ClientID is the OAuth 2.0 client identifier + Optional for some token exchange flows (e.g., Google Cloud Workforce Identity) + type: string + clientSecretRef: + description: |- + ClientSecretRef is a reference to a secret containing the OAuth 2.0 client secret + Optional for some token exchange flows (e.g., Google Cloud Workforce Identity) + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + externalTokenHeaderName: + description: |- + ExternalTokenHeaderName is the name of the custom header to use for the exchanged token. + If set, the exchanged token will be added to this custom header (e.g., "X-Upstream-Token"). + If empty or not set, the exchanged token will replace the Authorization header (default behavior). + type: string + scopes: + description: Scopes is a list of OAuth 2.0 scopes to request for + the exchanged token + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectProviderName: + description: |- + SubjectProviderName is the name of the upstream provider whose token is used as the + RFC 8693 subject token instead of identity.Token when performing token exchange. + When left empty and an embedded authorization server is configured on the VirtualMCPServer, + the controller automatically populates this field with the first configured upstream + provider name. Set it explicitly to override that default or to select a specific + provider when multiple upstreams are configured. + type: string + subjectTokenType: + description: |- + SubjectTokenType is the type of the incoming subject token. + Accepts short forms: "access_token" (default), "id_token", "jwt" + Or full URNs: "urn:ietf:params:oauth:token-type:access_token", + "urn:ietf:params:oauth:token-type:id_token", + "urn:ietf:params:oauth:token-type:jwt" + For Google Workload Identity Federation with OIDC providers (like Okta), use "id_token" + pattern: ^(access_token|id_token|jwt|urn:ietf:params:oauth:token-type:(access_token|id_token|jwt))?$ + type: string + tokenUrl: + description: TokenURL is the OAuth 2.0 token endpoint URL for + token exchange + type: string + required: + - audience + - tokenUrl + type: object + type: + description: Type is the type of external authentication to configure + enum: + - tokenExchange + - headerInjection + - bearerToken + - unauthenticated + - embeddedAuthServer + - awsSts + - upstreamInject + type: string + upstreamInject: + description: |- + UpstreamInject configures upstream token injection for backend requests. + Only used when Type is "upstreamInject". + properties: + providerName: + description: |- + ProviderName is the name of the upstream IDP provider whose access token + should be injected as the Authorization: Bearer header. + minLength: 1 + type: string + required: + - providerName + type: object + required: + - type + type: object + x-kubernetes-validations: + - message: tokenExchange configuration must be set if and only if type + is 'tokenExchange' + rule: 'self.type == ''tokenExchange'' ? has(self.tokenExchange) : !has(self.tokenExchange)' + - message: headerInjection configuration must be set if and only if type + is 'headerInjection' + rule: 'self.type == ''headerInjection'' ? has(self.headerInjection) + : !has(self.headerInjection)' + - message: bearerToken configuration must be set if and only if type is + 'bearerToken' + rule: 'self.type == ''bearerToken'' ? has(self.bearerToken) : !has(self.bearerToken)' + - message: embeddedAuthServer configuration must be set if and only if + type is 'embeddedAuthServer' + rule: 'self.type == ''embeddedAuthServer'' ? has(self.embeddedAuthServer) + : !has(self.embeddedAuthServer)' + - message: awsSts configuration must be set if and only if type is 'awsSts' + rule: 'self.type == ''awsSts'' ? has(self.awsSts) : !has(self.awsSts)' + - message: upstreamInject configuration must be set if and only if type + is 'upstreamInject' + rule: 'self.type == ''upstreamInject'' ? has(self.upstreamInject) : + !has(self.upstreamInject)' + - message: no configuration must be set when type is 'unauthenticated' + rule: 'self.type == ''unauthenticated'' ? (!has(self.tokenExchange) + && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) + && !has(self.awsSts) && !has(self.upstreamInject)) : true' + status: + description: MCPExternalAuthConfigStatus defines the observed state of + MCPExternalAuthConfig + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPExternalAuthConfig's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + configHash: + description: ConfigHash is a hash of the current configuration for + change detection + type: string + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this MCPExternalAuthConfig. + It corresponds to the MCPExternalAuthConfig's generation, which is updated on mutation by the API Server. + format: int64 + type: integer + referencingWorkloads: + description: |- + ReferencingWorkloads is a list of workload resources that reference this MCPExternalAuthConfig. + Each entry identifies the workload by kind and name. + items: + description: |- + WorkloadReference identifies a workload that references a shared configuration resource. + Namespace is implicit — cross-namespace references are not supported. + properties: + kind: + description: Kind is the type of workload resource + enum: + - MCPServer + - VirtualMCPServer + - MCPRemoteProxy + type: string + name: + description: Name is the name of the workload resource + minLength: 1 + type: string + required: + - kind + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .spec.type + name: Type + type: string + - jsonPath: .status.conditions[?(@.type=='Valid')].status + name: Valid + type: string + - jsonPath: .status.referencingWorkloads + name: References + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpgroups.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpgroups.yaml index 42cd83d43a..5a072011b2 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpgroups.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpgroups.yaml @@ -35,7 +35,163 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPGroup is the deprecated v1alpha1 version of the MCPGroup resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: MCPGroupSpec defines the desired state of MCPGroup + properties: + description: + description: Description provides human-readable context + type: string + type: object + status: + description: MCPGroupStatus defines observed state + properties: + conditions: + description: Conditions represent observations + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + entries: + description: Entries lists MCPServerEntry names in this group + items: + type: string + type: array + x-kubernetes-list-type: set + entryCount: + description: EntryCount is the number of MCPServerEntries + format: int32 + type: integer + observedGeneration: + description: ObservedGeneration reflects the generation most recently + observed by the controller + format: int64 + type: integer + phase: + default: Pending + description: Phase indicates current state + enum: + - Ready + - Pending + - Failed + type: string + remoteProxies: + description: RemoteProxies lists MCPRemoteProxy names in this group + items: + type: string + type: array + x-kubernetes-list-type: set + remoteProxyCount: + description: RemoteProxyCount is the number of MCPRemoteProxies + format: int32 + type: integer + serverCount: + description: ServerCount is the number of MCPServers + format: int32 + type: integer + servers: + description: Servers lists MCPServer names in this group + items: + type: string + type: array + x-kubernetes-list-type: set + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.serverCount + name: Servers + type: integer + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.conditions[?(@.type=='MCPServersChecked')].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: MCPGroup is the Schema for the mcpgroups API diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpoidcconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpoidcconfigs.yaml index 76ee9d9977..280b14bea2 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpoidcconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpoidcconfigs.yaml @@ -34,7 +34,302 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPOIDCConfig is the deprecated v1alpha1 version of the MCPOIDCConfig + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPOIDCConfigSpec defines the desired state of MCPOIDCConfig. + MCPOIDCConfig resources are namespace-scoped and can only be referenced by + MCPServer resources in the same namespace. + properties: + inline: + description: |- + Inline contains direct OIDC configuration. + Only used when Type is "inline". + properties: + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing the CA certificate bundle. + When specified, ToolHive auto-mounts the ConfigMap and auto-computes ThvCABundlePath. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + If Key is not specified, it defaults to "ca.crt". + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + clientId: + description: ClientID is the OIDC client ID + type: string + clientSecretRef: + description: ClientSecretRef is a reference to a Kubernetes Secret + containing the client secret + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + insecureAllowHTTP: + default: false + description: |- + InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing. + WARNING: This is insecure and should NEVER be used in production. + type: boolean + introspectionUrl: + description: IntrospectionURL is the URL for token introspection + endpoint + type: string + issuer: + description: Issuer is the OIDC issuer URL + type: string + jwksAllowPrivateIP: + default: false + description: |- + JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses. + Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). + type: boolean + jwksAuthTokenPath: + description: JWKSAuthTokenPath is the path to file containing + bearer token for JWKS/OIDC requests + type: string + jwksUrl: + description: JWKSURL is the URL to fetch the JWKS from + type: string + protectedResourceAllowPrivateIP: + default: false + description: |- + ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses. + Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP + is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). + type: boolean + required: + - issuer + type: object + kubernetesServiceAccount: + description: |- + KubernetesServiceAccount configures OIDC for Kubernetes service account token validation. + Only used when Type is "kubernetesServiceAccount". + properties: + introspectionUrl: + description: |- + IntrospectionURL is the URL for token introspection endpoint. + If empty, OIDC discovery will be used to automatically determine the introspection URL. + type: string + issuer: + default: https://kubernetes.default.svc + description: Issuer is the OIDC issuer URL. + type: string + jwksUrl: + description: |- + JWKSURL is the URL to fetch the JWKS from. + If empty, OIDC discovery will be used to automatically determine the JWKS URL. + type: string + namespace: + description: |- + Namespace is the namespace of the service account. + If empty, uses the MCPServer's namespace. + type: string + serviceAccount: + description: |- + ServiceAccount is the name of the service account to validate tokens for. + If empty, uses the pod's service account. + type: string + useClusterAuth: + description: |- + UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token. + When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification + and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication. + Defaults to true if not specified. + type: boolean + type: object + type: + description: Type is the type of OIDC configuration source + enum: + - kubernetesServiceAccount + - inline + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: kubernetesServiceAccount must be set when type is 'kubernetesServiceAccount', + and must not be set otherwise + rule: 'self.type == ''kubernetesServiceAccount'' ? has(self.kubernetesServiceAccount) + : !has(self.kubernetesServiceAccount)' + - message: inline must be set when type is 'inline', and must not be set + otherwise + rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + status: + description: MCPOIDCConfigStatus defines the observed state of MCPOIDCConfig + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPOIDCConfig's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + configHash: + description: ConfigHash is a hash of the current configuration for + change detection + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + for this MCPOIDCConfig. + format: int64 + type: integer + referencingWorkloads: + description: |- + ReferencingWorkloads is a list of workload resources that reference this MCPOIDCConfig. + Each entry identifies the workload by kind and name. + items: + description: |- + WorkloadReference identifies a workload that references a shared configuration resource. + Namespace is implicit — cross-namespace references are not supported. + properties: + kind: + description: Kind is the type of workload resource + enum: + - MCPServer + - VirtualMCPServer + - MCPRemoteProxy + type: string + name: + description: Name is the name of the workload resource + minLength: 1 + type: string + required: + - kind + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .spec.type + name: Source + type: string + - jsonPath: .status.conditions[?(@.type=='Valid')].status + name: Valid + type: string + - jsonPath: .status.referencingWorkloads + name: References + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpregistries.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpregistries.yaml index 8bf50dfcad..6e55bc9de4 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpregistries.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpregistries.yaml @@ -38,7 +38,246 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPRegistry is the deprecated v1alpha1 version of the MCPRegistry + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: MCPRegistrySpec defines the desired state of MCPRegistry + properties: + configYAML: + description: |- + ConfigYAML is the complete registry server config.yaml content. + The operator creates a ConfigMap from this string and mounts it + at /config/config.yaml in the registry-api container. + The operator does NOT parse, validate, or transform this content — + configuration validation is the registry server's responsibility. + + Security note: this content is stored in a ConfigMap, not a Secret. + Do not inline credentials (passwords, tokens, client secrets) in this + field. Instead, reference credentials via file paths and mount the + actual secrets using the Volumes and VolumeMounts fields. For database + passwords, use PGPassSecretRef. + minLength: 1 + type: string + displayName: + description: DisplayName is a human-readable name for the registry. + type: string + pgpassSecretRef: + description: "PGPassSecretRef references a Secret containing a pre-created + pgpass file.\n\nWhy this is a dedicated field instead of a regular + volume/volumeMount:\nPostgreSQL's libpq rejects pgpass files that + aren't mode 0600. Kubernetes\nsecret volumes mount files as root-owned, + and the registry-api container\nruns as non-root (UID 65532). A + root-owned 0600 file is unreadable by\nUID 65532, and using fsGroup + changes permissions to 0640 which libpq also\nrejects. The only + solution is an init container that copies the file to an\nemptyDir + as the app user and runs chmod 0600. This cannot be expressed\nthrough + volumes/volumeMounts alone -- it requires an init container, two\nextra + volumes (secret + emptyDir), a subPath mount, and an environment\nvariable, + all wired together correctly.\n\nWhen specified, the operator generates + all of that plumbing invisibly.\nThe user creates the Secret with + pgpass-formatted content; the operator\nhandles only the Kubernetes + permission mechanics.\n\nExample Secret:\n\n\tapiVersion: v1\n\tkind: + Secret\n\tmetadata:\n\t name: my-pgpass\n\tstringData:\n\t .pgpass: + |\n\t postgres:5432:registry:db_app:mypassword\n\t postgres:5432:registry:db_migrator:otherpassword\n\nThen + reference it:\n\n\tpgpassSecretRef:\n\t name: my-pgpass\n\t key: + .pgpass" + properties: + key: + description: The key of the secret to select from. Must be a + valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + podTemplateSpec: + description: |- + PodTemplateSpec defines the pod template to use for the registry API server. + This allows for customizing the pod configuration beyond what is provided by the other fields. + Note that to modify the specific container the registry API server runs in, you must specify + the `registry-api` container name in the PodTemplateSpec. + This field accepts a PodTemplateSpec object as JSON/YAML. + type: object + x-kubernetes-preserve-unknown-fields: true + volumeMounts: + description: |- + VolumeMounts defines additional volume mounts for the registry-api container. + Each entry is a standard Kubernetes VolumeMount object (JSON/YAML). + The operator appends them to the container's volume mounts alongside the config mount. + + Mount paths must match the file paths referenced in configYAML. + For example, if configYAML references passwordFile: /secrets/git-creds/token, + a corresponding volume mount must exist with mountPath: /secrets/git-creds. + items: + x-kubernetes-preserve-unknown-fields: true + type: array + x-kubernetes-list-type: atomic + x-kubernetes-preserve-unknown-fields: true + volumes: + description: |- + Volumes defines additional volumes to add to the registry API pod. + Each entry is a standard Kubernetes Volume object (JSON/YAML). + The operator appends them to the pod spec alongside its own config volume. + + Use these to mount: + - Secrets (git auth tokens, OAuth client secrets, CA certs) + - ConfigMaps (registry data files) + - PersistentVolumeClaims (registry data on persistent storage) + - Any other volume type the registry server needs + items: + x-kubernetes-preserve-unknown-fields: true + type: array + x-kubernetes-list-type: atomic + x-kubernetes-preserve-unknown-fields: true + required: + - configYAML + type: object + status: + description: MCPRegistryStatus defines the observed state of MCPRegistry + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPRegistry's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + message: + description: Message provides additional information about the current + phase + type: string + observedGeneration: + description: ObservedGeneration reflects the generation most recently + observed by the controller + format: int64 + type: integer + phase: + description: Phase represents the current overall phase of the MCPRegistry + enum: + - Pending + - Ready + - Failed + - Terminating + type: string + readyReplicas: + description: ReadyReplicas is the number of ready registry API replicas + format: int32 + type: integer + url: + description: URL is the URL where the registry API can be accessed + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .status.readyReplicas + name: Replicas + type: integer + - jsonPath: .status.url + name: URL + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: MCPRegistry is the Schema for the mcpregistries API diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml index 32391e4c45..79154701fb 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -38,7 +38,574 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPRemoteProxy is the deprecated v1alpha1 version of the MCPRemoteProxy + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: MCPRemoteProxySpec defines the desired state of MCPRemoteProxy + properties: + audit: + description: Audit defines audit logging configuration for the proxy + properties: + enabled: + default: false + description: |- + Enabled controls whether audit logging is enabled + When true, enables audit logging with default configuration + type: boolean + type: object + authServerRef: + description: |- + AuthServerRef optionally references a resource that configures an embedded + OAuth 2.0/OIDC authorization server to authenticate MCP clients. + Currently the only supported kind is MCPExternalAuthConfig (type: embeddedAuthServer). + properties: + kind: + default: MCPExternalAuthConfig + description: Kind identifies the type of the referenced resource. + enum: + - MCPExternalAuthConfig + type: string + name: + description: Name is the name of the referenced resource in the + same namespace. + minLength: 1 + type: string + required: + - kind + - name + type: object + authzConfig: + description: AuthzConfig defines authorization policy configuration + for the proxy + properties: + configMap: + description: |- + ConfigMap references a ConfigMap containing authorization configuration + Only used when Type is "configMap" + properties: + key: + default: authz.json + description: Key is the key in the ConfigMap that contains + the authorization configuration + type: string + name: + description: Name is the name of the ConfigMap + type: string + required: + - name + type: object + inline: + description: |- + Inline contains direct authorization configuration + Only used when Type is "inline" + properties: + entitiesJson: + default: '[]' + description: EntitiesJSON is a JSON string representing Cedar + entities + type: string + policies: + description: Policies is a list of Cedar policy strings + items: + type: string + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - policies + type: object + type: + default: configMap + description: Type is the type of authorization configuration + enum: + - configMap + - inline + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: configMap must be set when type is 'configMap', and must + not be set otherwise + rule: 'self.type == ''configMap'' ? has(self.configMap) : !has(self.configMap)' + - message: inline must be set when type is 'inline', and must not + be set otherwise + rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + endpointPrefix: + description: |- + EndpointPrefix is the path prefix to prepend to SSE endpoint URLs. + This is used to handle path-based ingress routing scenarios where the ingress + strips a path prefix before forwarding to the backend. + type: string + externalAuthConfigRef: + description: |- + ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange. + When specified, the proxy will exchange validated incoming tokens for remote service tokens. + The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPRemoteProxy. + properties: + name: + description: Name is the name of the MCPExternalAuthConfig resource + type: string + required: + - name + type: object + groupRef: + description: |- + GroupRef references the MCPGroup this proxy belongs to. + The referenced MCPGroup must be in the same namespace. + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object + headerForward: + description: |- + HeaderForward configures headers to inject into requests to the remote MCP server. + Use this to add custom headers like X-Tenant-ID or correlation IDs. + properties: + addHeadersFromSecret: + description: AddHeadersFromSecret references Kubernetes Secrets + for sensitive header values. + items: + description: HeaderFromSecret defines a header whose value comes + from a Kubernetes Secret. + properties: + headerName: + description: HeaderName is the HTTP header name (e.g., "X-API-Key") + maxLength: 255 + minLength: 1 + type: string + valueSecretRef: + description: ValueSecretRef references the Secret and key + containing the header value + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - headerName + - valueSecretRef + type: object + type: array + x-kubernetes-list-map-keys: + - headerName + x-kubernetes-list-type: map + addPlaintextHeaders: + additionalProperties: + type: string + description: |- + AddPlaintextHeaders is a map of header names to literal values to inject into requests. + WARNING: Values are stored in plaintext and visible via kubectl commands. + Use addHeadersFromSecret for sensitive data like API keys or tokens. + type: object + type: object + oidcConfigRef: + description: |- + OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. + The referenced MCPOIDCConfig must exist in the same namespace as this MCPRemoteProxy. + Per-server overrides (audience, scopes) are specified here; shared provider config + lives in the MCPOIDCConfig resource. + properties: + audience: + description: |- + Audience is the expected audience for token validation. + This MUST be unique per server to prevent token replay attacks. + minLength: 1 + type: string + name: + description: Name is the name of the MCPOIDCConfig resource + minLength: 1 + type: string + resourceUrl: + description: |- + ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728). + When the server is exposed via Ingress or gateway, set this to the external + URL that MCP clients connect to. If not specified, defaults to the internal + Kubernetes service URL. + type: string + scopes: + description: |- + Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728). + If empty, defaults to ["openid"]. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - audience + - name + type: object + proxyPort: + default: 8080 + description: ProxyPort is the port to expose the MCP proxy on + format: int32 + maximum: 65535 + minimum: 1 + type: integer + remoteUrl: + description: RemoteURL is the URL of the remote MCP server to proxy + pattern: ^https?:// + type: string + resourceOverrides: + description: ResourceOverrides allows overriding annotations and labels + for resources created by the operator + properties: + proxyDeployment: + description: ProxyDeployment defines overrides for the Proxy Deployment + resource (toolhive proxy) + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + env: + description: |- + Env are environment variables to set in the proxy container (thv run process) + These affect the toolhive proxy itself, not the MCP server it manages + Use TOOLHIVE_DEBUG=true to enable debug logging in the proxy + items: + description: EnvVar represents an environment variable in + a container + properties: + name: + description: Name of the environment variable + type: string + value: + description: Value of the environment variable + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + imagePullSecrets: + description: |- + ImagePullSecrets allows specifying image pull secrets for the proxy runner + These are applied to both the Deployment and the ServiceAccount + items: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + podTemplateMetadataOverrides: + description: ResourceMetadataOverrides defines metadata overrides + for a resource + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + type: object + type: object + proxyService: + description: ProxyService defines overrides for the Proxy Service + resource (points to the proxy deployment) + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + type: object + type: object + resources: + description: Resources defines the resource requirements for the proxy + container + properties: + limits: + description: Limits describes the maximum amount of compute resources + allowed + properties: + cpu: + description: CPU is the CPU limit in cores (e.g., "500m" for + 0.5 cores) + type: string + memory: + description: Memory is the memory limit in bytes (e.g., "64Mi" + for 64 megabytes) + type: string + type: object + requests: + description: Requests describes the minimum amount of compute + resources required + properties: + cpu: + description: CPU is the CPU limit in cores (e.g., "500m" for + 0.5 cores) + type: string + memory: + description: Memory is the memory limit in bytes (e.g., "64Mi" + for 64 megabytes) + type: string + type: object + type: object + serviceAccount: + description: |- + ServiceAccount is the name of an already existing service account to use by the proxy. + If not specified, a ServiceAccount will be created automatically and used by the proxy. + type: string + sessionAffinity: + default: ClientIP + description: |- + SessionAffinity controls whether the Service routes repeated client connections to the same pod. + MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default. + Set to "None" for stateless servers or when using an external load balancer with its own affinity. + enum: + - ClientIP + - None + type: string + telemetryConfigRef: + description: |- + TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. + The referenced MCPTelemetryConfig must exist in the same namespace as this MCPRemoteProxy. + Cross-namespace references are not supported for security and isolation reasons. + properties: + name: + description: Name is the name of the MCPTelemetryConfig resource + minLength: 1 + type: string + serviceName: + description: |- + ServiceName overrides the telemetry service name for this specific server. + This MUST be unique per server for proper observability (e.g., distinguishing + traces and metrics from different servers sharing the same collector). + If empty, defaults to the server name with "thv-" prefix at runtime. + type: string + required: + - name + type: object + toolConfigRef: + description: |- + ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming. + The referenced MCPToolConfig must exist in the same namespace as this MCPRemoteProxy. + Cross-namespace references are not supported for security and isolation reasons. + If specified, this allows filtering and overriding tools from the remote MCP server. + properties: + name: + description: Name is the name of the MCPToolConfig resource in + the same namespace + type: string + required: + - name + type: object + transport: + default: streamable-http + description: Transport is the transport method for the remote proxy + (sse or streamable-http) + enum: + - sse + - streamable-http + type: string + trustProxyHeaders: + default: false + description: |- + TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies + When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, + and X-Forwarded-Prefix headers to construct endpoint URLs + type: boolean + required: + - remoteUrl + type: object + status: + description: MCPRemoteProxyStatus defines the observed state of MCPRemoteProxy + properties: + authServerConfigHash: + description: |- + AuthServerConfigHash is the hash of the referenced authServerRef spec, + used to detect configuration changes and trigger reconciliation. + type: string + conditions: + description: Conditions represent the latest available observations + of the MCPRemoteProxy's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + externalAuthConfigHash: + description: ExternalAuthConfigHash is the hash of the referenced + MCPExternalAuthConfig spec + type: string + externalUrl: + description: ExternalURL is the external URL where the proxy can be + accessed (if exposed externally) + type: string + message: + description: Message provides additional information about the current + phase + type: string + observedGeneration: + description: ObservedGeneration reflects the generation of the most + recently observed MCPRemoteProxy + format: int64 + type: integer + oidcConfigHash: + description: OIDCConfigHash is the hash of the referenced MCPOIDCConfig + spec for change detection + type: string + phase: + description: Phase is the current phase of the MCPRemoteProxy + enum: + - Pending + - Ready + - Failed + - Terminating + type: string + telemetryConfigHash: + description: TelemetryConfigHash stores the hash of the referenced + MCPTelemetryConfig for change detection + type: string + toolConfigHash: + description: ToolConfigHash stores the hash of the referenced ToolConfig + for change detection + type: string + url: + description: URL is the internal cluster URL where the proxy can be + accessed + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .spec.remoteUrl + name: Remote URL + type: string + - jsonPath: .status.url + name: URL + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml index 7b08cd0223..2cff1d9a86 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml @@ -37,7 +37,261 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPServerEntry is the deprecated v1alpha1 version of the MCPServerEntry + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPServerEntrySpec defines the desired state of MCPServerEntry. + MCPServerEntry is a zero-infrastructure catalog entry that declares a remote MCP + server endpoint. Unlike MCPRemoteProxy, it creates no pods, services, or deployments. + properties: + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing CA certificates for TLS verification + when connecting to the remote MCP server. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + If Key is not specified, it defaults to "ca.crt". + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + externalAuthConfigRef: + description: |- + ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange + when connecting to the remote MCP server. The referenced MCPExternalAuthConfig must + exist in the same namespace as this MCPServerEntry. + properties: + name: + description: Name is the name of the MCPExternalAuthConfig resource + type: string + required: + - name + type: object + groupRef: + description: |- + GroupRef references the MCPGroup this entry belongs to. + Required — every MCPServerEntry must be part of a group for vMCP discovery. + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object + headerForward: + description: |- + HeaderForward configures headers to inject into requests to the remote MCP server. + Use this to add custom headers like API keys or correlation IDs. + properties: + addHeadersFromSecret: + description: AddHeadersFromSecret references Kubernetes Secrets + for sensitive header values. + items: + description: HeaderFromSecret defines a header whose value comes + from a Kubernetes Secret. + properties: + headerName: + description: HeaderName is the HTTP header name (e.g., "X-API-Key") + maxLength: 255 + minLength: 1 + type: string + valueSecretRef: + description: ValueSecretRef references the Secret and key + containing the header value + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - headerName + - valueSecretRef + type: object + type: array + x-kubernetes-list-map-keys: + - headerName + x-kubernetes-list-type: map + addPlaintextHeaders: + additionalProperties: + type: string + description: |- + AddPlaintextHeaders is a map of header names to literal values to inject into requests. + WARNING: Values are stored in plaintext and visible via kubectl commands. + Use addHeadersFromSecret for sensitive data like API keys or tokens. + type: object + type: object + remoteUrl: + description: |- + RemoteURL is the URL of the remote MCP server. + Both HTTP and HTTPS schemes are accepted at admission time. + pattern: ^https?:// + type: string + transport: + description: |- + Transport is the transport method for the remote server (sse or streamable-http). + No default is set (unlike MCPRemoteProxy) because MCPServerEntry points at external + servers the user doesn't control — requiring explicit transport avoids silent mismatches. + enum: + - sse + - streamable-http + type: string + required: + - groupRef + - remoteUrl + - transport + type: object + status: + description: MCPServerEntryStatus defines the observed state of MCPServerEntry. + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPServerEntry's state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: ObservedGeneration reflects the generation most recently + observed by the controller. + format: int64 + type: integer + phase: + default: Pending + description: Phase indicates the current lifecycle phase of the MCPServerEntry. + enum: + - Valid + - Pending + - Failed + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .spec.transport + name: Transport + type: string + - jsonPath: .spec.remoteUrl + name: Remote URL + type: string + - jsonPath: .spec.groupRef.name + name: Group + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml index c5be16186a..a248508d41 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml @@ -38,7 +38,859 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPServer is the deprecated v1alpha1 version of the MCPServer + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: MCPServerSpec defines the desired state of MCPServer + properties: + args: + description: Args are additional arguments to pass to the MCP server + items: + type: string + type: array + x-kubernetes-list-type: atomic + audit: + description: Audit defines audit logging configuration for the MCP + server + properties: + enabled: + default: false + description: |- + Enabled controls whether audit logging is enabled + When true, enables audit logging with default configuration + type: boolean + type: object + authServerRef: + description: |- + AuthServerRef optionally references a resource that configures an embedded + OAuth 2.0/OIDC authorization server to authenticate MCP clients. + Currently the only supported kind is MCPExternalAuthConfig (type: embeddedAuthServer). + properties: + kind: + default: MCPExternalAuthConfig + description: Kind identifies the type of the referenced resource. + enum: + - MCPExternalAuthConfig + type: string + name: + description: Name is the name of the referenced resource in the + same namespace. + minLength: 1 + type: string + required: + - kind + - name + type: object + authzConfig: + description: AuthzConfig defines authorization policy configuration + for the MCP server + properties: + configMap: + description: |- + ConfigMap references a ConfigMap containing authorization configuration + Only used when Type is "configMap" + properties: + key: + default: authz.json + description: Key is the key in the ConfigMap that contains + the authorization configuration + type: string + name: + description: Name is the name of the ConfigMap + type: string + required: + - name + type: object + inline: + description: |- + Inline contains direct authorization configuration + Only used when Type is "inline" + properties: + entitiesJson: + default: '[]' + description: EntitiesJSON is a JSON string representing Cedar + entities + type: string + policies: + description: Policies is a list of Cedar policy strings + items: + type: string + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - policies + type: object + type: + default: configMap + description: Type is the type of authorization configuration + enum: + - configMap + - inline + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: configMap must be set when type is 'configMap', and must + not be set otherwise + rule: 'self.type == ''configMap'' ? has(self.configMap) : !has(self.configMap)' + - message: inline must be set when type is 'inline', and must not + be set otherwise + rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + backendReplicas: + description: |- + BackendReplicas is the desired number of MCP server backend pod replicas. + This controls the backend Deployment (the MCP server container itself), + independent of the proxy runner controlled by Replicas. + When nil, the operator does not set Deployment.Spec.Replicas, leaving replica + management to an HPA or other external controller. + format: int32 + minimum: 0 + type: integer + endpointPrefix: + description: |- + EndpointPrefix is the path prefix to prepend to SSE endpoint URLs. + This is used to handle path-based ingress routing scenarios where the ingress + strips a path prefix before forwarding to the backend. + type: string + env: + description: Env are environment variables to set in the MCP server + container + items: + description: EnvVar represents an environment variable in a container + properties: + name: + description: Name of the environment variable + type: string + value: + description: Value of the environment variable + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + externalAuthConfigRef: + description: |- + ExternalAuthConfigRef references a MCPExternalAuthConfig resource for external authentication. + The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPServer. + properties: + name: + description: Name is the name of the MCPExternalAuthConfig resource + type: string + required: + - name + type: object + groupRef: + description: |- + GroupRef references the MCPGroup this server belongs to. + The referenced MCPGroup must be in the same namespace. + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object + image: + description: Image is the container image for the MCP server + type: string + mcpPort: + description: MCPPort is the port that MCP server listens to + format: int32 + maximum: 65535 + minimum: 1 + type: integer + oidcConfigRef: + description: |- + OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. + The referenced MCPOIDCConfig must exist in the same namespace as this MCPServer. + Per-server overrides (audience, scopes) are specified here; shared provider config + lives in the MCPOIDCConfig resource. + properties: + audience: + description: |- + Audience is the expected audience for token validation. + This MUST be unique per server to prevent token replay attacks. + minLength: 1 + type: string + name: + description: Name is the name of the MCPOIDCConfig resource + minLength: 1 + type: string + resourceUrl: + description: |- + ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728). + When the server is exposed via Ingress or gateway, set this to the external + URL that MCP clients connect to. If not specified, defaults to the internal + Kubernetes service URL. + type: string + scopes: + description: |- + Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728). + If empty, defaults to ["openid"]. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - audience + - name + type: object + permissionProfile: + description: PermissionProfile defines the permission profile to use + properties: + key: + description: |- + Key is the key in the ConfigMap that contains the permission profile + Only used when Type is "configmap" + type: string + name: + description: |- + Name is the name of the permission profile + If Type is "builtin", Name must be one of: "none", "network" + If Type is "configmap", Name is the name of the ConfigMap + type: string + type: + default: builtin + description: Type is the type of permission profile reference + enum: + - builtin + - configmap + type: string + required: + - name + - type + type: object + podTemplateSpec: + description: |- + PodTemplateSpec defines the pod template to use for the MCP server + This allows for customizing the pod configuration beyond what is provided by the other fields. + Note that to modify the specific container the MCP server runs in, you must specify + the `mcp` container name in the PodTemplateSpec. + This field accepts a PodTemplateSpec object as JSON/YAML. + type: object + x-kubernetes-preserve-unknown-fields: true + proxyMode: + default: streamable-http + description: |- + ProxyMode is the proxy mode for stdio transport (sse or streamable-http) + This setting is ONLY applicable when Transport is "stdio". + For direct transports (sse, streamable-http), this field is ignored. + The default value is applied by Kubernetes but will be ignored for non-stdio transports. + enum: + - sse + - streamable-http + type: string + proxyPort: + default: 8080 + description: ProxyPort is the port to expose the proxy runner on + format: int32 + maximum: 65535 + minimum: 1 + type: integer + rateLimiting: + description: |- + RateLimiting defines rate limiting configuration for the MCP server. + Requires Redis session storage to be configured for distributed rate limiting. + properties: + perUser: + description: |- + PerUser is a token bucket applied independently to each authenticated user + at the server level. Requires authentication to be enabled. + Each unique userID creates Redis keys that expire after 2x refillPeriod. + Memory formula: unique_users_per_TTL_window * (1 + num_tools_with_per_user_limits) keys. + properties: + maxTokens: + description: |- + MaxTokens is the maximum number of tokens (bucket capacity). + This is also the burst size: the maximum number of requests that can be served + instantaneously before the bucket is depleted. + format: int32 + minimum: 1 + type: integer + refillPeriod: + description: |- + RefillPeriod is the duration to fully refill the bucket from zero to maxTokens. + The effective refill rate is maxTokens / refillPeriod tokens per second. + Format: Go duration string (e.g., "1m0s", "30s", "1h0m0s"). + type: string + required: + - maxTokens + - refillPeriod + type: object + shared: + description: Shared is a token bucket shared across all users + for the entire server. + properties: + maxTokens: + description: |- + MaxTokens is the maximum number of tokens (bucket capacity). + This is also the burst size: the maximum number of requests that can be served + instantaneously before the bucket is depleted. + format: int32 + minimum: 1 + type: integer + refillPeriod: + description: |- + RefillPeriod is the duration to fully refill the bucket from zero to maxTokens. + The effective refill rate is maxTokens / refillPeriod tokens per second. + Format: Go duration string (e.g., "1m0s", "30s", "1h0m0s"). + type: string + required: + - maxTokens + - refillPeriod + type: object + tools: + description: |- + Tools defines per-tool rate limit overrides. + Each entry applies additional rate limits to calls targeting a specific tool name. + A request must pass both the server-level limit and the per-tool limit. + items: + description: |- + ToolRateLimitConfig defines rate limits for a specific tool. + At least one of shared or perUser must be configured. + properties: + name: + description: Name is the MCP tool name this limit applies + to. + minLength: 1 + type: string + perUser: + description: PerUser token bucket configuration for this + tool. + properties: + maxTokens: + description: |- + MaxTokens is the maximum number of tokens (bucket capacity). + This is also the burst size: the maximum number of requests that can be served + instantaneously before the bucket is depleted. + format: int32 + minimum: 1 + type: integer + refillPeriod: + description: |- + RefillPeriod is the duration to fully refill the bucket from zero to maxTokens. + The effective refill rate is maxTokens / refillPeriod tokens per second. + Format: Go duration string (e.g., "1m0s", "30s", "1h0m0s"). + type: string + required: + - maxTokens + - refillPeriod + type: object + shared: + description: Shared token bucket for this specific tool. + properties: + maxTokens: + description: |- + MaxTokens is the maximum number of tokens (bucket capacity). + This is also the burst size: the maximum number of requests that can be served + instantaneously before the bucket is depleted. + format: int32 + minimum: 1 + type: integer + refillPeriod: + description: |- + RefillPeriod is the duration to fully refill the bucket from zero to maxTokens. + The effective refill rate is maxTokens / refillPeriod tokens per second. + Format: Go duration string (e.g., "1m0s", "30s", "1h0m0s"). + type: string + required: + - maxTokens + - refillPeriod + type: object + required: + - name + type: object + x-kubernetes-validations: + - message: at least one of shared or perUser must be configured + rule: has(self.shared) || has(self.perUser) + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + x-kubernetes-validations: + - message: at least one of shared, perUser, or tools must be configured + rule: has(self.shared) || has(self.perUser) || (has(self.tools) + && size(self.tools) > 0) + replicas: + description: |- + Replicas is the desired number of proxy runner (thv run) pod replicas. + MCPServer creates two separate Deployments: one for the proxy runner and one + for the MCP server backend. This field controls the proxy runner Deployment. + When nil, the operator does not set Deployment.Spec.Replicas, leaving replica + management to an HPA or other external controller. + format: int32 + minimum: 0 + type: integer + resourceOverrides: + description: ResourceOverrides allows overriding annotations and labels + for resources created by the operator + properties: + proxyDeployment: + description: ProxyDeployment defines overrides for the Proxy Deployment + resource (toolhive proxy) + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + env: + description: |- + Env are environment variables to set in the proxy container (thv run process) + These affect the toolhive proxy itself, not the MCP server it manages + Use TOOLHIVE_DEBUG=true to enable debug logging in the proxy + items: + description: EnvVar represents an environment variable in + a container + properties: + name: + description: Name of the environment variable + type: string + value: + description: Value of the environment variable + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + imagePullSecrets: + description: |- + ImagePullSecrets allows specifying image pull secrets for the proxy runner + These are applied to both the Deployment and the ServiceAccount + items: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + podTemplateMetadataOverrides: + description: ResourceMetadataOverrides defines metadata overrides + for a resource + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + type: object + type: object + proxyService: + description: ProxyService defines overrides for the Proxy Service + resource (points to the proxy deployment) + properties: + annotations: + additionalProperties: + type: string + description: Annotations to add or override on the resource + type: object + labels: + additionalProperties: + type: string + description: Labels to add or override on the resource + type: object + type: object + type: object + resources: + description: Resources defines the resource requirements for the MCP + server container + properties: + limits: + description: Limits describes the maximum amount of compute resources + allowed + properties: + cpu: + description: CPU is the CPU limit in cores (e.g., "500m" for + 0.5 cores) + type: string + memory: + description: Memory is the memory limit in bytes (e.g., "64Mi" + for 64 megabytes) + type: string + type: object + requests: + description: Requests describes the minimum amount of compute + resources required + properties: + cpu: + description: CPU is the CPU limit in cores (e.g., "500m" for + 0.5 cores) + type: string + memory: + description: Memory is the memory limit in bytes (e.g., "64Mi" + for 64 megabytes) + type: string + type: object + type: object + secrets: + description: Secrets are references to secrets to mount in the MCP + server container + items: + description: SecretRef is a reference to a secret + properties: + key: + description: Key is the key in the secret itself + type: string + name: + description: Name is the name of the secret + type: string + targetEnvName: + description: |- + TargetEnvName is the environment variable to be used when setting up the secret in the MCP server + If left unspecified, it defaults to the key + type: string + required: + - key + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + serviceAccount: + description: |- + ServiceAccount is the name of an already existing service account to use by the MCP server. + If not specified, a ServiceAccount will be created automatically and used by the MCP server. + type: string + sessionAffinity: + default: ClientIP + description: |- + SessionAffinity controls whether the Service routes repeated client connections to the same pod. + MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default. + Set to "None" for stateless servers or when using an external load balancer with its own affinity. + enum: + - ClientIP + - None + type: string + sessionStorage: + description: |- + SessionStorage configures session storage for stateful horizontal scaling. + When nil, no session storage is configured. + properties: + address: + description: Address is the Redis server address (required when + provider is redis) + minLength: 1 + type: string + db: + default: 0 + description: DB is the Redis database number + format: int32 + minimum: 0 + type: integer + keyPrefix: + description: KeyPrefix is an optional prefix for all Redis keys + used by ToolHive + type: string + passwordRef: + description: PasswordRef is a reference to a Secret key containing + the Redis password + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + provider: + description: Provider is the session storage backend type + enum: + - memory + - redis + type: string + required: + - provider + type: object + x-kubernetes-validations: + - message: address is required + rule: 'self.provider == ''redis'' ? has(self.address) : true' + telemetryConfigRef: + description: |- + TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. + The referenced MCPTelemetryConfig must exist in the same namespace as this MCPServer. + Cross-namespace references are not supported for security and isolation reasons. + properties: + name: + description: Name is the name of the MCPTelemetryConfig resource + minLength: 1 + type: string + serviceName: + description: |- + ServiceName overrides the telemetry service name for this specific server. + This MUST be unique per server for proper observability (e.g., distinguishing + traces and metrics from different servers sharing the same collector). + If empty, defaults to the server name with "thv-" prefix at runtime. + type: string + required: + - name + type: object + toolConfigRef: + description: |- + ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming. + The referenced MCPToolConfig must exist in the same namespace as this MCPServer. + Cross-namespace references are not supported for security and isolation reasons. + properties: + name: + description: Name is the name of the MCPToolConfig resource in + the same namespace + type: string + required: + - name + type: object + transport: + default: stdio + description: Transport is the transport method for the MCP server + (stdio, streamable-http or sse) + enum: + - stdio + - streamable-http + - sse + type: string + trustProxyHeaders: + default: false + description: |- + TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies + When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, + and X-Forwarded-Prefix headers to construct endpoint URLs + type: boolean + volumes: + description: Volumes are volumes to mount in the MCP server container + items: + description: Volume represents a volume to mount in a container + properties: + hostPath: + description: HostPath is the path on the host to mount + type: string + mountPath: + description: MountPath is the path in the container to mount + to + type: string + name: + description: Name is the name of the volume + type: string + readOnly: + default: false + description: ReadOnly specifies whether the volume should be + mounted read-only + type: boolean + required: + - hostPath + - mountPath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + required: + - image + type: object + x-kubernetes-validations: + - message: rateLimiting requires sessionStorage with provider 'redis' + rule: '!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider + == ''redis'')' + - message: rateLimiting.perUser requires authentication (oidcConfigRef + or externalAuthConfigRef) + rule: '!(has(self.rateLimiting) && has(self.rateLimiting.perUser)) || + has(self.oidcConfigRef) || has(self.externalAuthConfigRef)' + - message: per-tool perUser rate limiting requires authentication (oidcConfigRef + or externalAuthConfigRef) + rule: '!has(self.rateLimiting) || !has(self.rateLimiting.tools) || self.rateLimiting.tools.all(t, + !has(t.perUser)) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)' + status: + description: MCPServerStatus defines the observed state of MCPServer + properties: + authServerConfigHash: + description: |- + AuthServerConfigHash is the hash of the referenced authServerRef spec, + used to detect configuration changes and trigger reconciliation. + type: string + conditions: + description: Conditions represent the latest available observations + of the MCPServer's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + externalAuthConfigHash: + description: ExternalAuthConfigHash is the hash of the referenced + MCPExternalAuthConfig spec + type: string + message: + description: Message provides additional information about the current + phase + type: string + observedGeneration: + description: ObservedGeneration reflects the generation most recently + observed by the controller + format: int64 + type: integer + oidcConfigHash: + description: OIDCConfigHash is the hash of the referenced MCPOIDCConfig + spec for change detection + type: string + phase: + description: Phase is the current phase of the MCPServer + enum: + - Pending + - Ready + - Failed + - Terminating + - Stopped + type: string + readyReplicas: + description: ReadyReplicas is the number of ready proxy replicas + format: int32 + type: integer + telemetryConfigHash: + description: TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig + spec for change detection + type: string + toolConfigHash: + description: ToolConfigHash stores the hash of the referenced ToolConfig + for change detection + type: string + url: + description: URL is the URL where the MCP server can be accessed + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .status.readyReplicas + name: Replicas + type: integer + - jsonPath: .status.url + name: URL + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: MCPServer is the Schema for the mcpservers API diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptelemetryconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptelemetryconfigs.yaml index b57de877f6..ca666e2693 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptelemetryconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptelemetryconfigs.yaml @@ -37,7 +37,307 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPTelemetryConfig is the deprecated v1alpha1 version of the + MCPTelemetryConfig resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPTelemetryConfigSpec defines the desired state of MCPTelemetryConfig. + The spec uses a nested structure with openTelemetry and prometheus sub-objects + for clear separation of concerns. + properties: + openTelemetry: + description: OpenTelemetry defines OpenTelemetry configuration (OTLP + endpoint, tracing, metrics) + properties: + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA certificate bundle for the OTLP endpoint. + When specified, the operator mounts the ConfigMap into the proxyrunner pod and configures + the OTLP exporters to trust the custom CA. This is useful when the OTLP collector uses + TLS with certificates signed by an internal or private CA. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + If Key is not specified, it defaults to "ca.crt". + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + enabled: + default: false + description: Enabled controls whether OpenTelemetry is enabled + type: boolean + endpoint: + description: Endpoint is the OTLP endpoint URL for tracing and + metrics + type: string + headers: + additionalProperties: + type: string + description: |- + Headers contains authentication headers for the OTLP endpoint. + For secret-backed credentials, use sensitiveHeaders instead. + type: object + insecure: + default: false + description: Insecure indicates whether to use HTTP instead of + HTTPS for the OTLP endpoint + type: boolean + metrics: + description: Metrics defines OpenTelemetry metrics-specific configuration + properties: + enabled: + default: false + description: Enabled controls whether OTLP metrics are sent + type: boolean + type: object + resourceAttributes: + additionalProperties: + type: string + description: |- + ResourceAttributes contains custom resource attributes to be added to all telemetry signals. + These become OTel resource attributes (e.g., deployment.environment, service.namespace). + Note: service.name is intentionally excluded — it is set per-server via + MCPTelemetryConfigReference.ServiceName. + type: object + sensitiveHeaders: + description: |- + SensitiveHeaders contains headers whose values are stored in Kubernetes Secrets. + Use this for credential headers (e.g., API keys, bearer tokens) instead of + embedding secrets in the headers field. + items: + description: |- + SensitiveHeader represents a header whose value is stored in a Kubernetes Secret. + This allows credential headers (e.g., API keys, bearer tokens) to be securely + referenced without embedding secrets inline in the MCPTelemetryConfig resource. + properties: + name: + description: Name is the header name (e.g., "Authorization", + "X-API-Key") + minLength: 1 + type: string + secretKeyRef: + description: SecretKeyRef is a reference to a Kubernetes + Secret key containing the header value + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - name + - secretKeyRef + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + tracing: + description: Tracing defines OpenTelemetry tracing configuration + properties: + enabled: + default: false + description: Enabled controls whether OTLP tracing is sent + type: boolean + samplingRate: + default: "0.05" + description: SamplingRate is the trace sampling rate (0.0-1.0) + pattern: ^(0(\.\d+)?|1(\.0+)?)$ + type: string + type: object + useLegacyAttributes: + default: true + description: |- + UseLegacyAttributes controls whether legacy attribute names are emitted alongside + the new MCP OTEL semantic convention names. Defaults to true for backward compatibility. + This will change to false in a future release and eventually be removed. + type: boolean + type: object + x-kubernetes-validations: + - message: a header name cannot appear in both headers and sensitiveHeaders + rule: '!has(self.headers) || !has(self.sensitiveHeaders) || self.sensitiveHeaders.all(sh, + !(sh.name in self.headers))' + prometheus: + description: Prometheus defines Prometheus-specific configuration + properties: + enabled: + default: false + description: Enabled controls whether Prometheus metrics endpoint + is exposed + type: boolean + type: object + type: object + status: + description: MCPTelemetryConfigStatus defines the observed state of MCPTelemetryConfig + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPTelemetryConfig's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + configHash: + description: ConfigHash is a hash of the current configuration for + change detection + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + for this MCPTelemetryConfig. + format: int64 + type: integer + referencingWorkloads: + description: ReferencingWorkloads lists workloads that reference this + MCPTelemetryConfig + items: + description: |- + WorkloadReference identifies a workload that references a shared configuration resource. + Namespace is implicit — cross-namespace references are not supported. + properties: + kind: + description: Kind is the type of workload resource + enum: + - MCPServer + - VirtualMCPServer + - MCPRemoteProxy + type: string + name: + description: Name is the name of the workload resource + minLength: 1 + type: string + required: + - kind + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .spec.openTelemetry.endpoint + name: Endpoint + type: string + - jsonPath: .status.conditions[?(@.type=='Valid')].status + name: Valid + type: string + - jsonPath: .spec.openTelemetry.tracing.enabled + name: Tracing + type: boolean + - jsonPath: .spec.openTelemetry.metrics.enabled + name: Metrics + type: boolean + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptoolconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptoolconfigs.yaml index e6af6eee64..7df79aba95 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptoolconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptoolconfigs.yaml @@ -32,7 +32,209 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPToolConfig is the deprecated v1alpha1 version of the MCPToolConfig + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPToolConfigSpec defines the desired state of MCPToolConfig. + MCPToolConfig resources are namespace-scoped and can only be referenced by + MCPServer resources in the same namespace. + properties: + toolsFilter: + description: |- + ToolsFilter is a list of tool names to filter (allow list). + Only tools in this list will be exposed by the MCP server. + If empty, all tools are exposed. + items: + type: string + type: array + x-kubernetes-list-type: set + toolsOverride: + additionalProperties: + description: |- + ToolOverride represents a tool override configuration. + Both Name and Description can be overridden independently, but + they can't be both empty. + properties: + annotations: + description: |- + Annotations overrides specific tool annotation fields. + Only specified fields are overridden; others pass through from the backend. + properties: + destructiveHint: + description: DestructiveHint overrides the destructive hint + annotation. + type: boolean + idempotentHint: + description: IdempotentHint overrides the idempotent hint + annotation. + type: boolean + openWorldHint: + description: OpenWorldHint overrides the open-world hint + annotation. + type: boolean + readOnlyHint: + description: ReadOnlyHint overrides the read-only hint annotation. + type: boolean + title: + description: Title overrides the human-readable title annotation. + type: string + type: object + description: + description: Description is the redefined description of the + tool + type: string + name: + description: Name is the redefined name of the tool + type: string + type: object + description: |- + ToolsOverride is a map from actual tool names to their overridden configuration. + This allows renaming tools and/or changing their descriptions. + type: object + type: object + status: + description: MCPToolConfigStatus defines the observed state of MCPToolConfig + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPToolConfig's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + configHash: + description: ConfigHash is a hash of the current configuration for + change detection + type: string + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this MCPToolConfig. + It corresponds to the MCPToolConfig's generation, which is updated on mutation by the API Server. + format: int64 + type: integer + referencingWorkloads: + description: |- + ReferencingWorkloads is a list of workload resources that reference this MCPToolConfig. + Each entry identifies the workload by kind and name. + items: + description: |- + WorkloadReference identifies a workload that references a shared configuration resource. + Namespace is implicit — cross-namespace references are not supported. + properties: + kind: + description: Kind is the type of workload resource + enum: + - MCPServer + - VirtualMCPServer + - MCPRemoteProxy + type: string + name: + description: Name is the name of the workload resource + minLength: 1 + type: string + required: + - kind + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=='Valid')].status + name: Valid + type: string + - jsonPath: .status.referencingWorkloads + name: References + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpcompositetooldefinitions.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpcompositetooldefinitions.yaml index a7575c2a67..347455410d 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpcompositetooldefinitions.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpcompositetooldefinitions.yaml @@ -46,7 +46,414 @@ spec: - jsonPath: .status.conditions[?(@.type=='Ready')].status name: Ready type: string + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: VirtualMCPCompositeToolDefinition is the deprecated v1alpha1 + version of the VirtualMCPCompositeToolDefinition resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + VirtualMCPCompositeToolDefinitionSpec defines the desired state of VirtualMCPCompositeToolDefinition. + This embeds the CompositeToolConfig from pkg/vmcp/config to share the configuration model + between CLI and operator usage. + properties: + description: + description: Description describes what the workflow does. + type: string + name: + description: Name is the workflow name (unique identifier). + type: string + output: + description: |- + Output defines the structured output schema for this workflow. + If not specified, the workflow returns the last step's output (backward compatible). + properties: + properties: + additionalProperties: + description: |- + OutputProperty defines a single output property. + For non-object types, Value is required. + For object types, either Value or Properties must be specified (but not both). + properties: + default: + description: |- + Default is the fallback value if template expansion fails. + Type coercion is applied to match the declared Type. + x-kubernetes-preserve-unknown-fields: true + description: + description: Description is a human-readable description + exposed to clients and models + type: string + properties: + description: |- + Properties defines nested properties for object types. + Each nested property has full metadata (type, description, value/properties). + type: object + x-kubernetes-preserve-unknown-fields: true + type: + description: 'Type is the JSON Schema type: "string", "integer", + "number", "boolean", "object", "array"' + enum: + - string + - integer + - number + - boolean + - object + - array + type: string + value: + description: |- + Value is a template string for constructing the runtime value. + For object types, this can be a JSON string that will be deserialized. + Supports template syntax: {{ "{{" }}.steps.step_id.output.field{{ "}}" }}, {{ "{{" }}.params.param_name{{ "}}" }} + type: string + required: + - type + type: object + description: |- + Properties defines the output properties. + Map key is the property name, value is the property definition. + type: object + required: + description: Required lists property names that must be present + in the output. + items: + type: string + type: array + required: + - properties + type: object + parameters: + description: |- + Parameters defines input parameter schema in JSON Schema format. + Should be a JSON Schema object with "type": "object" and "properties". + Example: + { + "type": "object", + "properties": { + "param1": {"type": "string", "default": "value"}, + "param2": {"type": "integer"} + }, + "required": ["param2"] + } + + We use json.Map rather than a typed struct because JSON Schema is highly + flexible with many optional fields (default, enum, minimum, maximum, pattern, + items, additionalProperties, oneOf, anyOf, allOf, etc.). Using json.Map + allows full JSON Schema compatibility without needing to define every possible + field, and matches how the MCP SDK handles inputSchema. + type: object + x-kubernetes-preserve-unknown-fields: true + steps: + description: Steps are the workflow steps to execute. + items: + description: |- + WorkflowStepConfig defines a single workflow step. + This matches the proposal's step configuration (lines 180-255). + properties: + arguments: + description: |- + Arguments is a map of argument values with template expansion support. + Supports Go template syntax with .params and .steps for string values. + Non-string values (integers, booleans, arrays, objects) are passed as-is. + Note: the templating is only supported on the first level of the key-value pairs. + type: object + x-kubernetes-preserve-unknown-fields: true + collection: + description: |- + Collection is a Go template expression that resolves to a JSON array or a slice. + Only used when Type is "forEach". + type: string + condition: + description: Condition is a template expression that determines + if the step should execute + type: string + defaultResults: + description: |- + DefaultResults provides fallback output values when this step is skipped + (due to condition evaluating to false) or fails (when onError.action is "continue"). + Each key corresponds to an output field name referenced by downstream steps. + Required if the step may be skipped AND downstream steps reference this step's output. + x-kubernetes-preserve-unknown-fields: true + dependsOn: + description: DependsOn lists step IDs that must complete before + this step + items: + type: string + type: array + id: + description: ID is the unique identifier for this step. + type: string + itemVar: + description: |- + ItemVar is the variable name used to reference the current item in forEach templates. + Defaults to "item" if not specified. + Only used when Type is "forEach". + type: string + maxIterations: + description: |- + MaxIterations limits the number of items that can be iterated over. + Defaults to 100, hard cap at 1000. + Only used when Type is "forEach". + type: integer + maxParallel: + description: |- + MaxParallel limits the number of concurrent iterations in a forEach step. + Defaults to the DAG executor's maxParallel (10). + Only used when Type is "forEach". + type: integer + message: + description: |- + Message is the elicitation message + Only used when Type is "elicitation" + type: string + onCancel: + description: |- + OnCancel defines the action to take when the user cancels/dismisses the elicitation + Only used when Type is "elicitation" + properties: + action: + default: abort + description: |- + Action defines the action to take when the user declines or cancels + - skip_remaining: Skip remaining steps in the workflow + - abort: Abort the entire workflow execution + - continue: Continue to the next step + enum: + - skip_remaining + - abort + - continue + type: string + type: object + onDecline: + description: |- + OnDecline defines the action to take when the user explicitly declines the elicitation + Only used when Type is "elicitation" + properties: + action: + default: abort + description: |- + Action defines the action to take when the user declines or cancels + - skip_remaining: Skip remaining steps in the workflow + - abort: Abort the entire workflow execution + - continue: Continue to the next step + enum: + - skip_remaining + - abort + - continue + type: string + type: object + onError: + description: OnError defines error handling behavior + properties: + action: + default: abort + description: Action defines the action to take on error + enum: + - abort + - continue + - retry + type: string + retryCount: + description: |- + RetryCount is the maximum number of retries + Only used when Action is "retry" + type: integer + retryDelay: + description: |- + RetryDelay is the delay between retry attempts + Only used when Action is "retry" + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + type: object + schema: + description: Schema defines the expected response schema for + elicitation + type: object + x-kubernetes-preserve-unknown-fields: true + step: + description: |- + InnerStep defines the step to execute for each item in the collection. + Only used when Type is "forEach". Only tool-type inner steps are supported. + type: object + x-kubernetes-preserve-unknown-fields: true + timeout: + description: Timeout is the maximum execution time for this + step + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + tool: + description: |- + Tool is the tool to call (format: "workload.tool_name") + Only used when Type is "tool" + type: string + type: + default: tool + description: Type is the step type (tool, elicitation, etc.) + enum: + - tool + - elicitation + - forEach + type: string + required: + - id + type: object + type: array + timeout: + description: Timeout is the maximum workflow execution time. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + required: + - name + - steps + type: object + status: + description: VirtualMCPCompositeToolDefinitionStatus defines the observed + state of VirtualMCPCompositeToolDefinition + properties: + conditions: + description: Conditions represent the latest available observations + of the workflow's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this VirtualMCPCompositeToolDefinition + It corresponds to the resource's generation, which is updated on mutation by the API Server + format: int64 + type: integer + referencingVirtualServers: + description: |- + ReferencingVirtualServers lists VirtualMCPServer resources that reference this workflow + This helps track which servers need to be reconciled when this workflow changes + items: + type: string + type: array + x-kubernetes-list-type: set + validationErrors: + description: ValidationErrors contains validation error messages if + ValidationStatus is Invalid + items: + type: string + type: array + x-kubernetes-list-type: atomic + validationStatus: + description: |- + ValidationStatus indicates the validation state of the workflow + - Valid: Workflow structure is valid + - Invalid: Workflow has validation errors + enum: + - Valid + - Invalid + - Unknown + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - description: Workflow name + jsonPath: .spec.name + name: Workflow + type: string + - description: Number of steps + jsonPath: .spec.steps[*] + name: Steps + type: integer + - description: Validation status + jsonPath: .status.validationStatus + name: Status + type: string + - description: Refs + jsonPath: .status.referencingVirtualServers[*] + name: Refs + type: integer + - description: Age + jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 04b58af0a3..9929904d14 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -42,7 +42,2295 @@ spec: - jsonPath: .status.conditions[?(@.type=='Ready')].status name: Ready type: string + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 name: v1alpha1 + schema: + openAPIV3Schema: + description: VirtualMCPServer is the deprecated v1alpha1 version of the VirtualMCPServer + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: VirtualMCPServerSpec defines the desired state of VirtualMCPServer + properties: + authServerConfig: + description: |- + AuthServerConfig configures an embedded OAuth authorization server. + When set, the vMCP server acts as an OIDC issuer, drives users through + upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the + IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfigRef + so that tokens it issues are accepted by the vMCP's incoming auth middleware. + When nil, IncomingAuth uses an external IDP and behavior is unchanged. + properties: + authorizationEndpointBaseUrl: + description: |- + AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint + in the OAuth discovery document. When set, the discovery document will advertise + `{authorizationEndpointBaseUrl}/oauth/authorize` instead of `{issuer}/oauth/authorize`. + All other endpoints (token, registration, JWKS) remain derived from the issuer. + This is useful when the browser-facing authorization endpoint needs to be on a + different host than the issuer used for backend-to-backend calls. + Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. + pattern: ^https?://[^\s?#]+[^/\s?#]$ + type: string + hmacSecretRefs: + description: |- + HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing + authorization codes and refresh tokens (opaque tokens). + Current secret must be at least 32 bytes and cryptographically random. + Supports secret rotation via multiple entries (first is current, rest are for verification). + If not specified, an ephemeral secret will be auto-generated (development only - + auth codes and refresh tokens will be invalid after restart). + items: + description: SecretKeyRef is a reference to a key within a Secret + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + type: array + x-kubernetes-list-type: atomic + issuer: + description: |- + Issuer is the issuer identifier for this authorization server. + This will be included in the "iss" claim of issued tokens. + Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). + pattern: ^https?://[^\s?#]+[^/\s?#]$ + type: string + signingKeySecretRefs: + description: |- + SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations. + Supports key rotation by allowing multiple keys (oldest keys are used for verification only). + If not specified, an ephemeral signing key will be auto-generated (development only - + JWTs will be invalid after restart). + items: + description: SecretKeyRef is a reference to a key within a Secret + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + maxItems: 5 + type: array + x-kubernetes-list-type: atomic + storage: + description: |- + Storage configures the storage backend for the embedded auth server. + If not specified, defaults to in-memory storage. + properties: + redis: + description: |- + Redis configures the Redis storage backend. + Required when type is "redis". + properties: + aclUserConfig: + description: ACLUserConfig configures Redis ACL user authentication. + properties: + passwordSecretRef: + description: PasswordSecretRef references a Secret + containing the Redis ACL password. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + usernameSecretRef: + description: UsernameSecretRef references a Secret + containing the Redis ACL username. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + required: + - passwordSecretRef + - usernameSecretRef + type: object + dialTimeout: + default: 5s + description: |- + DialTimeout is the timeout for establishing connections. + Format: Go duration string (e.g., "5s", "1m"). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + readTimeout: + default: 3s + description: |- + ReadTimeout is the timeout for socket reads. + Format: Go duration string (e.g., "3s", "1m"). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + sentinelConfig: + description: SentinelConfig holds Redis Sentinel configuration. + properties: + db: + default: 0 + description: DB is the Redis database number. + format: int32 + type: integer + masterName: + description: MasterName is the name of the Redis master + monitored by Sentinel. + type: string + sentinelAddrs: + description: |- + SentinelAddrs is a list of Sentinel host:port addresses. + Mutually exclusive with SentinelService. + items: + type: string + type: array + x-kubernetes-list-type: atomic + sentinelService: + description: |- + SentinelService enables automatic discovery from a Kubernetes Service. + Mutually exclusive with SentinelAddrs. + properties: + name: + description: Name of the Sentinel Service. + type: string + namespace: + description: Namespace of the Sentinel Service + (defaults to same namespace). + type: string + port: + default: 26379 + description: Port of the Sentinel service. + format: int32 + type: integer + required: + - name + type: object + required: + - masterName + type: object + sentinelTls: + description: |- + SentinelTLS configures TLS for connections to Sentinel instances. + Presence of this field enables TLS. Omit to use plaintext. + When omitted, sentinel connections use plaintext (no fallback to TLS config). + properties: + caCertSecretRef: + description: |- + CACertSecretRef references a Secret containing a PEM-encoded CA certificate + for verifying the server. When not specified, system root CAs are used. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + insecureSkipVerify: + description: |- + InsecureSkipVerify skips TLS certificate verification. + Use when connecting to services with self-signed certificates. + type: boolean + type: object + tls: + description: |- + TLS configures TLS for connections to the Redis/Valkey master. + Presence of this field enables TLS. Omit to use plaintext. + properties: + caCertSecretRef: + description: |- + CACertSecretRef references a Secret containing a PEM-encoded CA certificate + for verifying the server. When not specified, system root CAs are used. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + insecureSkipVerify: + description: |- + InsecureSkipVerify skips TLS certificate verification. + Use when connecting to services with self-signed certificates. + type: boolean + type: object + writeTimeout: + default: 3s + description: |- + WriteTimeout is the timeout for socket writes. + Format: Go duration string (e.g., "3s", "1m"). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + required: + - aclUserConfig + - sentinelConfig + type: object + type: + default: memory + description: |- + Type specifies the storage backend type. + Valid values: "memory" (default), "redis". + enum: + - memory + - redis + type: string + type: object + tokenLifespans: + description: |- + TokenLifespans configures the duration that various tokens are valid. + If not specified, defaults are applied (access: 1h, refresh: 7d, authCode: 10m). + properties: + accessTokenLifespan: + description: |- + AccessTokenLifespan is the duration that access tokens are valid. + Format: Go duration string (e.g., "1h", "30m", "24h"). + If empty, defaults to 1 hour. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + authCodeLifespan: + description: |- + AuthCodeLifespan is the duration that authorization codes are valid. + Format: Go duration string (e.g., "10m", "5m"). + If empty, defaults to 10 minutes. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + refreshTokenLifespan: + description: |- + RefreshTokenLifespan is the duration that refresh tokens are valid. + Format: Go duration string (e.g., "168h", "7d" as "168h"). + If empty, defaults to 7 days (168h). + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + type: object + upstreamProviders: + description: |- + UpstreamProviders configures connections to upstream Identity Providers. + The embedded auth server delegates authentication to these providers. + MCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple. + items: + description: UpstreamProviderConfig defines configuration for + an upstream Identity Provider. + properties: + name: + description: |- + Name uniquely identifies this upstream provider. + Used for routing decisions and session binding in multi-upstream scenarios. + Must be lowercase alphanumeric with hyphens (DNS-label-like). + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ + type: string + oauth2Config: + description: |- + OAuth2Config contains OAuth 2.0-specific configuration. + Required when Type is "oauth2", must be nil when Type is "oidc". + properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object + authorizationEndpoint: + description: AuthorizationEndpoint is the URL for the + OAuth authorization endpoint. + pattern: ^https?://.*$ + type: string + clientId: + description: ClientID is the OAuth 2.0 client identifier + registered with the upstream IDP. + type: string + clientSecretRef: + description: |- + ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret. + Optional for public clients using PKCE instead of client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + redirectUri: + description: |- + RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the + URL associated with the resource (e.g., MCPServer or vMCP) using this config. + type: string + scopes: + description: Scopes are the OAuth scopes to request + from the upstream IDP. + items: + type: string + type: array + x-kubernetes-list-type: atomic + tokenEndpoint: + description: TokenEndpoint is the URL for the OAuth + token endpoint. + pattern: ^https?://.*$ + type: string + tokenResponseMapping: + description: |- + TokenResponseMapping configures custom field extraction from non-standard token responses. + Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths + instead of returning them at the top level. When set, ToolHive performs the token + exchange HTTP call directly and extracts fields using the configured dot-notation paths. + If nil, standard OAuth 2.0 token response parsing is used. + properties: + accessTokenPath: + description: |- + AccessTokenPath is the dot-notation path to the access token in the response. + Example: "authed_user.access_token" + minLength: 1 + type: string + expiresInPath: + description: |- + ExpiresInPath is the dot-notation path to the expires_in value (in seconds). + If not specified, defaults to "expires_in". + type: string + refreshTokenPath: + description: |- + RefreshTokenPath is the dot-notation path to the refresh token in the response. + If not specified, defaults to "refresh_token". + type: string + scopePath: + description: |- + ScopePath is the dot-notation path to the scope string in the response. + If not specified, defaults to "scope". + type: string + required: + - accessTokenPath + type: object + userInfo: + description: |- + UserInfo contains configuration for fetching user information from the upstream provider. + Required for OAuth2 providers to resolve user identity. + properties: + additionalHeaders: + additionalProperties: + type: string + description: |- + AdditionalHeaders contains extra headers to include in the userinfo request. + Useful for providers that require specific headers (e.g., GitHub's Accept header). + type: object + endpointUrl: + description: EndpointURL is the URL of the userinfo + endpoint. + pattern: ^https?://.*$ + type: string + fieldMapping: + description: |- + FieldMapping contains custom field mapping configuration for non-standard providers. + If nil, standard OIDC field names are used ("sub", "name", "email"). + properties: + emailFields: + description: |- + EmailFields is an ordered list of field names to try for the email address. + The first non-empty value found will be used. + Default: ["email"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + nameFields: + description: |- + NameFields is an ordered list of field names to try for the display name. + The first non-empty value found will be used. + Default: ["name"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectFields: + description: |- + SubjectFields is an ordered list of field names to try for the user ID. + The first non-empty value found will be used. + Default: ["sub"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpMethod: + description: |- + HTTPMethod is the HTTP method to use for the userinfo request. + If not specified, defaults to GET. + enum: + - GET + - POST + type: string + required: + - endpointUrl + type: object + required: + - authorizationEndpoint + - clientId + - tokenEndpoint + - userInfo + type: object + oidcConfig: + description: |- + OIDCConfig contains OIDC-specific configuration. + Required when Type is "oidc", must be nil when Type is "oauth2". + properties: + additionalAuthorizationParams: + additionalProperties: + type: string + description: |- + AdditionalAuthorizationParams are extra query parameters to include in + authorization requests sent to the upstream provider. + This is useful for providers that require custom parameters, such as + Google's access_type=offline for obtaining refresh tokens. + Note: when using access_type=offline, also set explicit scopes to avoid + the default offline_access scope being sent alongside it. + Framework-managed parameters (response_type, client_id, redirect_uri, + scope, state, code_challenge, code_challenge_method, nonce) are not allowed. + maxProperties: 16 + type: object + clientId: + description: ClientID is the OAuth 2.0 client identifier + registered with the upstream IDP. + type: string + clientSecretRef: + description: |- + ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret. + Optional for public clients using PKCE instead of client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + issuerUrl: + description: |- + IssuerURL is the OIDC issuer URL for automatic endpoint discovery. + Must be a valid HTTPS URL. + pattern: ^https://.*$ + type: string + redirectUri: + description: |- + RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the + URL associated with the resource (e.g., MCPServer or vMCP) using this config. + type: string + scopes: + description: |- + Scopes are the OAuth scopes to request from the upstream IDP. + If not specified, defaults to ["openid", "offline_access"]. + When using additionalAuthorizationParams with provider-specific refresh token + mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid + sending both offline_access and the provider-specific parameter. + items: + type: string + type: array + x-kubernetes-list-type: atomic + userInfoOverride: + description: |- + UserInfoOverride allows customizing UserInfo fetching behavior for OIDC providers. + By default, the UserInfo endpoint is discovered automatically via OIDC discovery. + Use this to override the endpoint URL, HTTP method, or field mappings for providers + that return non-standard claim names in their UserInfo response. + properties: + additionalHeaders: + additionalProperties: + type: string + description: |- + AdditionalHeaders contains extra headers to include in the userinfo request. + Useful for providers that require specific headers (e.g., GitHub's Accept header). + type: object + endpointUrl: + description: EndpointURL is the URL of the userinfo + endpoint. + pattern: ^https?://.*$ + type: string + fieldMapping: + description: |- + FieldMapping contains custom field mapping configuration for non-standard providers. + If nil, standard OIDC field names are used ("sub", "name", "email"). + properties: + emailFields: + description: |- + EmailFields is an ordered list of field names to try for the email address. + The first non-empty value found will be used. + Default: ["email"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + nameFields: + description: |- + NameFields is an ordered list of field names to try for the display name. + The first non-empty value found will be used. + Default: ["name"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectFields: + description: |- + SubjectFields is an ordered list of field names to try for the user ID. + The first non-empty value found will be used. + Default: ["sub"] + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpMethod: + description: |- + HTTPMethod is the HTTP method to use for the userinfo request. + If not specified, defaults to GET. + enum: + - GET + - POST + type: string + required: + - endpointUrl + type: object + required: + - clientId + - issuerUrl + type: object + type: + description: 'Type specifies the provider type: "oidc" or + "oauth2"' + enum: + - oidc + - oauth2 + type: string + required: + - name + - type + type: object + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + required: + - issuer + - upstreamProviders + type: object + config: + description: |- + Config is the Virtual MCP server configuration. + The audit config from here is also supported, but not required. + properties: + aggregation: + description: |- + Aggregation defines tool aggregation and conflict resolution strategies. + Supports ToolConfigRef for Kubernetes-native MCPToolConfig resource references. + properties: + conflictResolution: + default: prefix + description: |- + ConflictResolution defines the strategy for resolving tool name conflicts. + - prefix: Automatically prefix tool names with workload identifier + - priority: First workload in priority order wins + - manual: Explicitly define overrides for all conflicts + enum: + - prefix + - priority + - manual + type: string + conflictResolutionConfig: + description: ConflictResolutionConfig provides configuration + for the chosen strategy. + properties: + prefixFormat: + default: '{workload}_' + description: |- + PrefixFormat defines the prefix format for the "prefix" strategy. + Supports placeholders: {workload}, {workload}_, {workload}. + type: string + priorityOrder: + description: PriorityOrder defines the workload priority + order for the "priority" strategy. + items: + type: string + type: array + type: object + excludeAllTools: + description: |- + ExcludeAllTools hides all backend tools from MCP clients when true. + Hidden tools are NOT advertised in tools/list responses, but they ARE + available in the routing table for composite tools to use. + This enables the use case where you want to hide raw backend tools from + direct client access while exposing curated composite tool workflows. + type: boolean + tools: + description: Tools defines per-workload tool filtering and + overrides. + items: + description: WorkloadToolConfig defines tool filtering and + overrides for a specific workload. + properties: + excludeAll: + description: |- + ExcludeAll hides all tools from this workload from MCP clients when true. + Hidden tools are NOT advertised in tools/list responses, but they ARE + available in the routing table for composite tools to use. + This enables the use case where you want to hide raw backend tools from + direct client access while exposing curated composite tool workflows. + type: boolean + filter: + description: |- + Filter is an allow-list of tool names to advertise to MCP clients. + Tools NOT in this list are hidden from clients (not in tools/list response) + but remain available in the routing table for composite tools to use. + This enables selective exposure of backend tools while allowing composite + workflows to orchestrate all backend capabilities. + Only used if ToolConfigRef is not specified. + items: + type: string + type: array + overrides: + additionalProperties: + description: ToolOverride defines tool name, description, + and annotation overrides. + properties: + annotations: + description: |- + Annotations overrides specific tool annotation fields. + Only specified fields are overridden; others pass through from the backend. + properties: + destructiveHint: + description: DestructiveHint overrides the + destructive hint annotation. + type: boolean + idempotentHint: + description: IdempotentHint overrides the + idempotent hint annotation. + type: boolean + openWorldHint: + description: OpenWorldHint overrides the open-world + hint annotation. + type: boolean + readOnlyHint: + description: ReadOnlyHint overrides the read-only + hint annotation. + type: boolean + title: + description: Title overrides the human-readable + title annotation. + type: string + type: object + description: + description: Description is the new tool description. + type: string + name: + description: Name is the new tool name (for renaming). + type: string + type: object + description: |- + Overrides is an inline map of tool overrides for renaming and description changes. + Overrides are applied to tools before conflict resolution and affect both + advertising and routing (the overridden name is used everywhere). + Only used if ToolConfigRef is not specified. + type: object + toolConfigRef: + description: |- + ToolConfigRef references an MCPToolConfig resource for tool filtering and renaming. + If specified, Filter and Overrides are ignored. + Only used when running in Kubernetes with the operator. + properties: + name: + description: Name is the name of the MCPToolConfig + resource in the same namespace. + type: string + required: + - name + type: object + workload: + description: Workload is the name of the backend MCPServer + workload. + type: string + required: + - workload + type: object + type: array + type: object + audit: + description: |- + Audit configures audit logging for the Virtual MCP server. + When present, audit logs include MCP protocol operations. + See audit.Config for available configuration options. + properties: + component: + description: Component is the component name to use in audit + events. + type: string + detectApplicationErrors: + default: true + description: |- + DetectApplicationErrors controls whether the audit middleware inspects + JSON-RPC response bodies for application-level errors when the HTTP + status code indicates success (2xx). When enabled, a small prefix of + the response body is buffered to detect JSON-RPC error fields, + independent of the IncludeResponseData setting. + type: boolean + enabled: + default: false + description: |- + Enabled controls whether audit logging is enabled. + When true, enables audit logging with the configured options. + type: boolean + eventTypes: + description: EventTypes specifies which event types to audit. + If empty, all events are audited. + items: + type: string + type: array + excludeEventTypes: + description: |- + ExcludeEventTypes specifies which event types to exclude from auditing. + This takes precedence over EventTypes. + items: + type: string + type: array + includeRequestData: + default: false + description: IncludeRequestData determines whether to include + request data in audit logs. + type: boolean + includeResponseData: + default: false + description: IncludeResponseData determines whether to include + response data in audit logs. + type: boolean + logFile: + description: LogFile specifies the file path for audit logs. + If empty, logs to stdout. + type: string + maxDataSize: + default: 1024 + description: MaxDataSize limits the size of request/response + data included in audit logs (in bytes). + type: integer + type: object + backends: + description: |- + Backends defines pre-configured backend servers for static mode. + When OutgoingAuth.Source is "inline", this field contains the full list of backend + servers with their URLs and transport types, eliminating the need for K8s API access. + When OutgoingAuth.Source is "discovered", this field is empty and backends are + discovered at runtime via Kubernetes API. + items: + description: |- + StaticBackendConfig defines a pre-configured backend server for static mode. + This allows vMCP to operate without Kubernetes API access by embedding all backend + information directly in the configuration. + properties: + caBundlePath: + description: |- + CABundlePath is the file path to a custom CA certificate bundle for TLS verification. + Only valid when Type is "entry". The operator mounts CA bundles at + /etc/toolhive/ca-bundles/<name>/ca.crt. + type: string + metadata: + additionalProperties: + type: string + description: |- + Metadata is a custom key-value map for storing additional backend information + such as labels, tags, or other arbitrary data (e.g., "env": "prod", "region": "us-east-1"). + This is NOT Kubernetes ObjectMeta - it's a simple string map for user-defined metadata. + Reserved keys: "group" is automatically set by vMCP and any user-provided value will be overridden. + type: object + name: + description: |- + Name is the backend identifier. + Must match the backend name from the MCPGroup for auth config resolution. + type: string + transport: + description: |- + Transport is the MCP transport protocol: "sse" or "streamable-http" + Only network transports supported by vMCP client are allowed. + enum: + - sse + - streamable-http + type: string + type: + description: |- + Type is the backend workload type: "entry" for MCPServerEntry backends, or empty + for container/proxy backends. Entry backends connect directly to remote MCP servers. + enum: + - entry + - "" + type: string + url: + description: URL is the backend's MCP server base URL. + pattern: ^https?:// + type: string + required: + - name + - transport + - url + type: object + type: array + compositeToolRefs: + description: |- + CompositeToolRefs references VirtualMCPCompositeToolDefinition resources + for complex, reusable workflows. Only applicable when running in Kubernetes. + Referenced resources must be in the same namespace as the VirtualMCPServer. + items: + description: |- + CompositeToolRef defines a reference to a VirtualMCPCompositeToolDefinition resource. + The referenced resource must be in the same namespace as the VirtualMCPServer. + properties: + name: + description: Name is the name of the VirtualMCPCompositeToolDefinition + resource in the same namespace. + type: string + required: + - name + type: object + type: array + compositeTools: + description: |- + CompositeTools defines inline composite tool workflows. + Full workflow definitions are embedded in the configuration. + For Kubernetes, complex workflows can also reference VirtualMCPCompositeToolDefinition CRDs. + items: + description: |- + CompositeToolConfig defines a composite tool workflow. + This matches the YAML structure from the proposal (lines 173-255). + properties: + description: + description: Description describes what the workflow does. + type: string + name: + description: Name is the workflow name (unique identifier). + type: string + output: + description: |- + Output defines the structured output schema for this workflow. + If not specified, the workflow returns the last step's output (backward compatible). + properties: + properties: + additionalProperties: + description: |- + OutputProperty defines a single output property. + For non-object types, Value is required. + For object types, either Value or Properties must be specified (but not both). + properties: + default: + description: |- + Default is the fallback value if template expansion fails. + Type coercion is applied to match the declared Type. + x-kubernetes-preserve-unknown-fields: true + description: + description: Description is a human-readable description + exposed to clients and models + type: string + properties: + description: |- + Properties defines nested properties for object types. + Each nested property has full metadata (type, description, value/properties). + type: object + x-kubernetes-preserve-unknown-fields: true + type: + description: 'Type is the JSON Schema type: "string", + "integer", "number", "boolean", "object", "array"' + enum: + - string + - integer + - number + - boolean + - object + - array + type: string + value: + description: |- + Value is a template string for constructing the runtime value. + For object types, this can be a JSON string that will be deserialized. + Supports template syntax: {{ "{{" }}.steps.step_id.output.field{{ "}}" }}, {{ "{{" }}.params.param_name{{ "}}" }} + type: string + required: + - type + type: object + description: |- + Properties defines the output properties. + Map key is the property name, value is the property definition. + type: object + required: + description: Required lists property names that must + be present in the output. + items: + type: string + type: array + required: + - properties + type: object + parameters: + description: |- + Parameters defines input parameter schema in JSON Schema format. + Should be a JSON Schema object with "type": "object" and "properties". + Example: + { + "type": "object", + "properties": { + "param1": {"type": "string", "default": "value"}, + "param2": {"type": "integer"} + }, + "required": ["param2"] + } + + We use json.Map rather than a typed struct because JSON Schema is highly + flexible with many optional fields (default, enum, minimum, maximum, pattern, + items, additionalProperties, oneOf, anyOf, allOf, etc.). Using json.Map + allows full JSON Schema compatibility without needing to define every possible + field, and matches how the MCP SDK handles inputSchema. + type: object + x-kubernetes-preserve-unknown-fields: true + steps: + description: Steps are the workflow steps to execute. + items: + description: |- + WorkflowStepConfig defines a single workflow step. + This matches the proposal's step configuration (lines 180-255). + properties: + arguments: + description: |- + Arguments is a map of argument values with template expansion support. + Supports Go template syntax with .params and .steps for string values. + Non-string values (integers, booleans, arrays, objects) are passed as-is. + Note: the templating is only supported on the first level of the key-value pairs. + type: object + x-kubernetes-preserve-unknown-fields: true + collection: + description: |- + Collection is a Go template expression that resolves to a JSON array or a slice. + Only used when Type is "forEach". + type: string + condition: + description: Condition is a template expression that + determines if the step should execute + type: string + defaultResults: + description: |- + DefaultResults provides fallback output values when this step is skipped + (due to condition evaluating to false) or fails (when onError.action is "continue"). + Each key corresponds to an output field name referenced by downstream steps. + Required if the step may be skipped AND downstream steps reference this step's output. + x-kubernetes-preserve-unknown-fields: true + dependsOn: + description: DependsOn lists step IDs that must complete + before this step + items: + type: string + type: array + id: + description: ID is the unique identifier for this + step. + type: string + itemVar: + description: |- + ItemVar is the variable name used to reference the current item in forEach templates. + Defaults to "item" if not specified. + Only used when Type is "forEach". + type: string + maxIterations: + description: |- + MaxIterations limits the number of items that can be iterated over. + Defaults to 100, hard cap at 1000. + Only used when Type is "forEach". + type: integer + maxParallel: + description: |- + MaxParallel limits the number of concurrent iterations in a forEach step. + Defaults to the DAG executor's maxParallel (10). + Only used when Type is "forEach". + type: integer + message: + description: |- + Message is the elicitation message + Only used when Type is "elicitation" + type: string + onCancel: + description: |- + OnCancel defines the action to take when the user cancels/dismisses the elicitation + Only used when Type is "elicitation" + properties: + action: + default: abort + description: |- + Action defines the action to take when the user declines or cancels + - skip_remaining: Skip remaining steps in the workflow + - abort: Abort the entire workflow execution + - continue: Continue to the next step + enum: + - skip_remaining + - abort + - continue + type: string + type: object + onDecline: + description: |- + OnDecline defines the action to take when the user explicitly declines the elicitation + Only used when Type is "elicitation" + properties: + action: + default: abort + description: |- + Action defines the action to take when the user declines or cancels + - skip_remaining: Skip remaining steps in the workflow + - abort: Abort the entire workflow execution + - continue: Continue to the next step + enum: + - skip_remaining + - abort + - continue + type: string + type: object + onError: + description: OnError defines error handling behavior + properties: + action: + default: abort + description: Action defines the action to take + on error + enum: + - abort + - continue + - retry + type: string + retryCount: + description: |- + RetryCount is the maximum number of retries + Only used when Action is "retry" + type: integer + retryDelay: + description: |- + RetryDelay is the delay between retry attempts + Only used when Action is "retry" + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + type: object + schema: + description: Schema defines the expected response + schema for elicitation + type: object + x-kubernetes-preserve-unknown-fields: true + step: + description: |- + InnerStep defines the step to execute for each item in the collection. + Only used when Type is "forEach". Only tool-type inner steps are supported. + type: object + x-kubernetes-preserve-unknown-fields: true + timeout: + description: Timeout is the maximum execution time + for this step + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + tool: + description: |- + Tool is the tool to call (format: "workload.tool_name") + Only used when Type is "tool" + type: string + type: + default: tool + description: Type is the step type (tool, elicitation, + etc.) + enum: + - tool + - elicitation + - forEach + type: string + required: + - id + type: object + type: array + timeout: + description: Timeout is the maximum workflow execution time. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + required: + - name + - steps + type: object + type: array + groupRef: + description: |- + Group references an existing MCPGroup that defines backend workloads. + In standalone CLI mode, this is set from the YAML config file. + In Kubernetes, the operator populates this from spec.groupRef during conversion. + type: string + incomingAuth: + description: |- + IncomingAuth configures how clients authenticate to the virtual MCP server. + When using the Kubernetes operator, this is populated by the converter from + VirtualMCPServerSpec.IncomingAuth and any values set here will be superseded. + properties: + authz: + description: Authz contains authorization configuration (optional). + properties: + policies: + description: Policies contains Cedar policy definitions + (when Type = "cedar"). + items: + type: string + type: array + primaryUpstreamProvider: + description: |- + PrimaryUpstreamProvider names the upstream IDP provider whose access + token should be used as the source of JWT claims for Cedar evaluation. + When empty, claims from the ToolHive-issued token are used. + Must match an upstream provider name configured in the embedded auth server + (e.g. "default", "github"). Only relevant when the embedded auth server is active. + type: string + type: + description: 'Type is the authz type: "cedar", "none"' + type: string + required: + - type + type: object + oidc: + description: OIDC contains OIDC configuration (when Type = + "oidc"). + properties: + audience: + description: Audience is the required token audience. + type: string + clientId: + description: ClientID is the OAuth client ID. + type: string + clientSecretEnv: + description: |- + ClientSecretEnv is the name of the environment variable containing the client secret. + This is the secure way to reference secrets - the actual secret value is never stored + in configuration files, only the environment variable name. + The secret value will be resolved from this environment variable at runtime. + type: string + insecureAllowHttp: + description: |- + InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing + WARNING: This is insecure and should NEVER be used in production + type: boolean + introspectionUrl: + description: |- + IntrospectionURL is the token introspection endpoint URL (RFC 7662). + When set, enables token introspection for opaque (non-JWT) tokens. + type: string + issuer: + description: Issuer is the OIDC issuer URL. + pattern: ^https?:// + type: string + jwksAllowPrivateIp: + description: |- + JwksAllowPrivateIP allows OIDC discovery and JWKS fetches to private IP addresses. + Enable when the embedded auth server runs on a loopback address and + the OIDC middleware needs to fetch its JWKS from that address. + Use with caution - only enable for trusted internal IDPs or testing. + type: boolean + jwksUrl: + description: |- + JWKSURL is the explicit JWKS endpoint URL. + When set, skips OIDC discovery and fetches the JWKS directly from this URL. + This is useful when the OIDC issuer does not serve a /.well-known/openid-configuration. + type: string + protectedResourceAllowPrivateIp: + description: |- + ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses + Use with caution - only enable for trusted internal IDPs or testing + type: boolean + resource: + description: |- + Resource is the OAuth 2.0 resource indicator (RFC 8707). + Used in WWW-Authenticate header and OAuth discovery metadata (RFC 9728). + If not specified, defaults to Audience. + type: string + scopes: + description: Scopes are the required OAuth scopes. + items: + type: string + type: array + required: + - audience + - clientId + - issuer + type: object + type: + description: 'Type is the auth type: "oidc", "local", "anonymous"' + type: string + required: + - type + type: object + metadata: + additionalProperties: + type: string + description: Metadata stores additional configuration metadata. + type: object + name: + description: Name is the virtual MCP server name. + type: string + operational: + description: Operational configures operational settings. + properties: + failureHandling: + description: FailureHandling configures failure handling behavior. + properties: + circuitBreaker: + description: CircuitBreaker configures circuit breaker + behavior. + properties: + enabled: + default: false + description: Enabled controls whether circuit breaker + is enabled. + type: boolean + failureThreshold: + default: 5 + description: |- + FailureThreshold is the number of failures before opening the circuit. + Must be >= 1. + minimum: 1 + type: integer + timeout: + default: 60s + description: |- + Timeout is the duration to wait before attempting to close the circuit. + Must be >= 1s to prevent thrashing. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + x-kubernetes-validations: + - message: timeout must be >= 1s + rule: self == '' || duration(self) >= duration('1s') + type: object + healthCheckInterval: + default: 30s + description: HealthCheckInterval is the interval between + health checks. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + healthCheckTimeout: + default: 10s + description: |- + HealthCheckTimeout is the maximum duration for a single health check operation. + Should be less than HealthCheckInterval to prevent checks from queuing up. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + partialFailureMode: + default: fail + description: |- + PartialFailureMode defines behavior when some backends are unavailable. + - fail: Fail entire request if any backend is unavailable + - best_effort: Continue with available backends + enum: + - fail + - best_effort + type: string + statusReportingInterval: + default: 30s + description: |- + StatusReportingInterval is the interval for reporting status updates to Kubernetes. + This controls how often the vMCP runtime reports backend health and phase changes. + Lower values provide faster status updates but increase API server load. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + unhealthyThreshold: + default: 3 + description: UnhealthyThreshold is the number of consecutive + failures before marking unhealthy. + type: integer + type: object + logLevel: + description: |- + LogLevel sets the logging level for the Virtual MCP server. + The only valid value is "debug" to enable debug logging. + When omitted or empty, the server uses info level logging. + enum: + - debug + type: string + timeouts: + description: Timeouts configures timeout settings. + properties: + default: + default: 30s + description: Default is the default timeout for backend + requests. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + perWorkload: + additionalProperties: + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + description: PerWorkload defines per-workload timeout + overrides. + type: object + type: object + type: object + optimizer: + description: |- + Optimizer configures the MCP optimizer for context optimization on large toolsets. + When enabled, vMCP exposes only find_tool and call_tool operations to clients + instead of all backend tools directly. This reduces token usage by allowing + LLMs to discover relevant tools on demand rather than receiving all tool definitions. + properties: + embeddingService: + description: |- + EmbeddingService is the full base URL of the embedding service endpoint + (e.g., http://my-embedding.default.svc.cluster.local:8080) for semantic + tool discovery. + + In a Kubernetes environment, it is more convenient to use the + VirtualMCPServerSpec.EmbeddingServerRef field instead of setting this + directly. EmbeddingServerRef references an EmbeddingServer CRD by name, + and the operator automatically resolves the referenced resource's + Status.URL to populate this field. This provides managed lifecycle + (the operator watches the EmbeddingServer for readiness and URL changes) + and avoids hardcoding service URLs in the config. If both + EmbeddingServerRef and this field are set, EmbeddingServerRef takes + precedence and this value is overridden with a warning. + type: string + embeddingServiceTimeout: + default: 30s + description: |- + EmbeddingServiceTimeout is the HTTP request timeout for calls to the embedding service. + Defaults to 30s if not specified. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + hybridSearchSemanticRatio: + description: |- + HybridSearchSemanticRatio controls the balance between semantic (meaning-based) + and keyword search results. 0.0 = all keyword, 1.0 = all semantic. + Defaults to "0.5" if not specified or empty. + Serialized as a string because CRDs do not support float types portably. + pattern: ^([0-9]*[.])?[0-9]+$ + type: string + maxToolsToReturn: + description: |- + MaxToolsToReturn is the maximum number of tool results returned by a search query. + Defaults to 8 if not specified or zero. + maximum: 50 + minimum: 1 + type: integer + semanticDistanceThreshold: + description: |- + SemanticDistanceThreshold is the maximum distance for semantic search results. + Results exceeding this threshold are filtered out from semantic search. + This threshold does not apply to keyword search. + Range: 0 = identical, 2 = completely unrelated. + Defaults to "1.0" if not specified or empty. + Serialized as a string because CRDs do not support float types portably. + pattern: ^([0-9]*[.])?[0-9]+$ + type: string + type: object + outgoingAuth: + description: |- + OutgoingAuth configures how the virtual MCP server authenticates to backends. + When using the Kubernetes operator, this is populated by the converter from + VirtualMCPServerSpec.OutgoingAuth and any values set here will be superseded. + properties: + backends: + additionalProperties: + description: |- + BackendAuthStrategy defines how to authenticate to a specific backend. + + This struct provides type-safe configuration for different authentication strategies + using HeaderInjection or TokenExchange fields based on the Type field. + properties: + headerInjection: + description: |- + HeaderInjection contains configuration for header injection auth strategy. + Used when Type = "header_injection". + properties: + headerName: + description: HeaderName is the name of the header + to inject (e.g., "Authorization"). + type: string + headerValue: + description: |- + HeaderValue is the static header value to inject. + Either HeaderValue or HeaderValueEnv should be set, not both. + type: string + headerValueEnv: + description: |- + HeaderValueEnv is the environment variable name containing the header value. + The value will be resolved at runtime from this environment variable. + Either HeaderValue or HeaderValueEnv should be set, not both. + type: string + required: + - headerName + type: object + tokenExchange: + description: |- + TokenExchange contains configuration for token exchange auth strategy. + Used when Type = "token_exchange". + properties: + audience: + description: Audience is the target audience for + the exchanged token. + type: string + clientId: + description: ClientID is the OAuth client ID for + the token exchange request. + type: string + clientSecret: + description: ClientSecret is the OAuth client secret + (use ClientSecretEnv for security). + type: string + clientSecretEnv: + description: |- + ClientSecretEnv is the environment variable name containing the client secret. + The value will be resolved at runtime from this environment variable. + type: string + scopes: + description: Scopes are the requested scopes for + the exchanged token. + items: + type: string + type: array + subjectProviderName: + description: |- + SubjectProviderName selects which upstream provider's token to use as the + subject token. When set, the token is looked up from Identity.UpstreamTokens + instead of using Identity.Token. + When left empty and an embedded authorization server is configured, the system + automatically populates this field with the first configured upstream provider name. + Set it explicitly to override that default or to select a specific provider when + multiple upstreams are configured. + type: string + subjectTokenType: + description: |- + SubjectTokenType is the token type of the incoming subject token. + Defaults to "urn:ietf:params:oauth:token-type:access_token" if not specified. + type: string + tokenUrl: + description: TokenURL is the OAuth token endpoint + URL for token exchange. + type: string + required: + - tokenUrl + type: object + type: + description: 'Type is the auth strategy: "unauthenticated", + "header_injection", "token_exchange", "upstream_inject"' + type: string + upstreamInject: + description: |- + UpstreamInject contains configuration for upstream inject auth strategy. + Used when Type = "upstream_inject". + properties: + providerName: + description: |- + ProviderName is the name of the upstream provider configured in the + embedded authorization server. Must match an entry in AuthServer.Upstreams. + type: string + required: + - providerName + type: object + required: + - type + type: object + description: Backends contains per-backend auth configuration. + type: object + default: + description: Default is the default auth strategy for backends + without explicit config. + properties: + headerInjection: + description: |- + HeaderInjection contains configuration for header injection auth strategy. + Used when Type = "header_injection". + properties: + headerName: + description: HeaderName is the name of the header + to inject (e.g., "Authorization"). + type: string + headerValue: + description: |- + HeaderValue is the static header value to inject. + Either HeaderValue or HeaderValueEnv should be set, not both. + type: string + headerValueEnv: + description: |- + HeaderValueEnv is the environment variable name containing the header value. + The value will be resolved at runtime from this environment variable. + Either HeaderValue or HeaderValueEnv should be set, not both. + type: string + required: + - headerName + type: object + tokenExchange: + description: |- + TokenExchange contains configuration for token exchange auth strategy. + Used when Type = "token_exchange". + properties: + audience: + description: Audience is the target audience for the + exchanged token. + type: string + clientId: + description: ClientID is the OAuth client ID for the + token exchange request. + type: string + clientSecret: + description: ClientSecret is the OAuth client secret + (use ClientSecretEnv for security). + type: string + clientSecretEnv: + description: |- + ClientSecretEnv is the environment variable name containing the client secret. + The value will be resolved at runtime from this environment variable. + type: string + scopes: + description: Scopes are the requested scopes for the + exchanged token. + items: + type: string + type: array + subjectProviderName: + description: |- + SubjectProviderName selects which upstream provider's token to use as the + subject token. When set, the token is looked up from Identity.UpstreamTokens + instead of using Identity.Token. + When left empty and an embedded authorization server is configured, the system + automatically populates this field with the first configured upstream provider name. + Set it explicitly to override that default or to select a specific provider when + multiple upstreams are configured. + type: string + subjectTokenType: + description: |- + SubjectTokenType is the token type of the incoming subject token. + Defaults to "urn:ietf:params:oauth:token-type:access_token" if not specified. + type: string + tokenUrl: + description: TokenURL is the OAuth token endpoint + URL for token exchange. + type: string + required: + - tokenUrl + type: object + type: + description: 'Type is the auth strategy: "unauthenticated", + "header_injection", "token_exchange", "upstream_inject"' + type: string + upstreamInject: + description: |- + UpstreamInject contains configuration for upstream inject auth strategy. + Used when Type = "upstream_inject". + properties: + providerName: + description: |- + ProviderName is the name of the upstream provider configured in the + embedded authorization server. Must match an entry in AuthServer.Upstreams. + type: string + required: + - providerName + type: object + required: + - type + type: object + source: + description: |- + Source defines how to discover backend auth: "inline", "discovered" + - inline: Explicit configuration in OutgoingAuth + - discovered: Auto-discover from backend MCPServer.externalAuthConfigRef (Kubernetes only) + type: string + required: + - source + type: object + sessionStorage: + description: |- + SessionStorage configures session storage for stateful horizontal scaling. + When provider is "redis", the operator injects Redis connection parameters + (address, db, keyPrefix) here. The Redis password is provided separately via + the THV_SESSION_REDIS_PASSWORD environment variable. + properties: + address: + description: Address is the Redis server address (required + when provider is redis). + type: string + db: + default: 0 + description: DB is the Redis database number. + format: int32 + minimum: 0 + type: integer + keyPrefix: + description: KeyPrefix is an optional prefix for all Redis + keys used by ToolHive. + type: string + provider: + description: Provider is the session storage backend type. + enum: + - memory + - redis + type: string + required: + - provider + type: object + telemetry: + description: |- + Telemetry configures OpenTelemetry-based observability for the Virtual MCP server + including distributed tracing, OTLP metrics export, and Prometheus metrics endpoint. + Deprecated (Kubernetes operator only): When deploying via the operator, use + VirtualMCPServer.spec.telemetryConfigRef to reference a shared MCPTelemetryConfig + resource instead. This field remains valid for standalone (non-operator) deployments. + properties: + caCertPath: + description: |- + CACertPath is the file path to a CA certificate bundle for the OTLP endpoint. + When set, the OTLP exporters use this CA to verify the collector's TLS certificate + instead of relying solely on the system CA pool. + type: string + customAttributes: + additionalProperties: + type: string + description: |- + CustomAttributes contains custom resource attributes to be added to all telemetry signals. + These are parsed from CLI flags (--otel-custom-attributes) or environment variables + (OTEL_RESOURCE_ATTRIBUTES) as key=value pairs. + type: object + enablePrometheusMetricsPath: + default: false + description: |- + EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint. + The metrics are served on the main transport port at /metrics. + This is separate from OTLP metrics which are sent to the Endpoint. + type: boolean + endpoint: + description: Endpoint is the OTLP endpoint URL + type: string + environmentVariables: + description: |- + EnvironmentVariables is a list of environment variable names that should be + included in telemetry spans as attributes. Only variables in this list will + be read from the host machine and included in spans for observability. + Example: ["NODE_ENV", "DEPLOYMENT_ENV", "SERVICE_VERSION"] + items: + type: string + type: array + headers: + additionalProperties: + type: string + description: Headers contains authentication headers for the + OTLP endpoint. + type: object + insecure: + default: false + description: Insecure indicates whether to use HTTP instead + of HTTPS for the OTLP endpoint. + type: boolean + metricsEnabled: + default: false + description: |- + MetricsEnabled controls whether OTLP metrics are enabled. + When false, OTLP metrics are not sent even if an endpoint is configured. + This is independent of EnablePrometheusMetricsPath. + type: boolean + samplingRate: + default: "0.05" + description: |- + SamplingRate is the trace sampling rate (0.0-1.0) as a string. + Only used when TracingEnabled is true. + Example: "0.05" for 5% sampling. + type: string + serviceName: + description: |- + ServiceName is the service name for telemetry. + When omitted, defaults to the server name (e.g., VirtualMCPServer name). + type: string + serviceVersion: + description: |- + ServiceVersion is the service version for telemetry. + When omitted, defaults to the ToolHive version. + type: string + tracingEnabled: + default: false + description: |- + TracingEnabled controls whether distributed tracing is enabled. + When false, no tracer provider is created even if an endpoint is configured. + type: boolean + useLegacyAttributes: + default: true + description: |- + UseLegacyAttributes controls whether legacy (pre-MCP OTEL semconv) attribute names + are emitted alongside the new standard attribute names. When true, spans include both + old and new attribute names for backward compatibility with existing dashboards. + Currently defaults to true; this will change to false in a future release. + type: boolean + type: object + type: object + x-kubernetes-preserve-unknown-fields: true + embeddingServerRef: + description: |- + EmbeddingServerRef references an existing EmbeddingServer resource by name. + When the optimizer is enabled, this field is required to point to a ready EmbeddingServer + that provides embedding capabilities. + The referenced EmbeddingServer must exist in the same namespace and be ready. + properties: + name: + description: Name is the name of the EmbeddingServer resource + type: string + required: + - name + type: object + groupRef: + description: |- + GroupRef references the MCPGroup that defines backend workloads. + The referenced MCPGroup must exist in the same namespace. + properties: + name: + description: Name is the name of the MCPGroup resource in the + same namespace + minLength: 1 + type: string + required: + - name + type: object + incomingAuth: + description: |- + IncomingAuth configures authentication for clients connecting to the Virtual MCP server. + Must be explicitly set - use "anonymous" type when no authentication is required. + This field takes precedence over config.IncomingAuth and should be preferred because it + supports Kubernetes-native secret references (SecretKeyRef, ConfigMapRef) for secure + dynamic discovery of credentials, rather than requiring secrets to be embedded in config. + properties: + authzConfig: + description: |- + AuthzConfig defines authorization policy configuration + Reuses MCPServer authz patterns + properties: + configMap: + description: |- + ConfigMap references a ConfigMap containing authorization configuration + Only used when Type is "configMap" + properties: + key: + default: authz.json + description: Key is the key in the ConfigMap that contains + the authorization configuration + type: string + name: + description: Name is the name of the ConfigMap + type: string + required: + - name + type: object + inline: + description: |- + Inline contains direct authorization configuration + Only used when Type is "inline" + properties: + entitiesJson: + default: '[]' + description: EntitiesJSON is a JSON string representing + Cedar entities + type: string + policies: + description: Policies is a list of Cedar policy strings + items: + type: string + minItems: 1 + type: array + x-kubernetes-list-type: atomic + required: + - policies + type: object + type: + default: configMap + description: Type is the type of authorization configuration + enum: + - configMap + - inline + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: configMap must be set when type is 'configMap', and + must not be set otherwise + rule: 'self.type == ''configMap'' ? has(self.configMap) : !has(self.configMap)' + - message: inline must be set when type is 'inline', and must + not be set otherwise + rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + oidcConfigRef: + description: |- + OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. + The referenced MCPOIDCConfig must exist in the same namespace as this VirtualMCPServer. + Per-server overrides (audience, scopes) are specified here; shared provider config + lives in the MCPOIDCConfig resource. + properties: + audience: + description: |- + Audience is the expected audience for token validation. + This MUST be unique per server to prevent token replay attacks. + minLength: 1 + type: string + name: + description: Name is the name of the MCPOIDCConfig resource + minLength: 1 + type: string + resourceUrl: + description: |- + ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728). + When the server is exposed via Ingress or gateway, set this to the external + URL that MCP clients connect to. If not specified, defaults to the internal + Kubernetes service URL. + type: string + scopes: + description: |- + Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728). + If empty, defaults to ["openid"]. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - audience + - name + type: object + type: + description: |- + Type defines the authentication type: anonymous or oidc + When no authentication is required, explicitly set this to "anonymous" + enum: + - anonymous + - oidc + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: spec.incomingAuth.oidcConfigRef is required when type is + oidc + rule: 'self.type == ''oidc'' ? has(self.oidcConfigRef) : true' + outgoingAuth: + description: |- + OutgoingAuth configures authentication from Virtual MCP to backend MCPServers. + This field takes precedence over config.OutgoingAuth and should be preferred because it + supports Kubernetes-native secret references (SecretKeyRef, ConfigMapRef) for secure + dynamic discovery of credentials, rather than requiring secrets to be embedded in config. + properties: + backends: + additionalProperties: + description: BackendAuthConfig defines authentication configuration + for a backend MCPServer + properties: + externalAuthConfigRef: + description: |- + ExternalAuthConfigRef references an MCPExternalAuthConfig resource + Only used when Type is "externalAuthConfigRef" + properties: + name: + description: Name is the name of the MCPExternalAuthConfig + resource + type: string + required: + - name + type: object + type: + description: Type defines the authentication type + enum: + - discovered + - externalAuthConfigRef + type: string + required: + - type + type: object + description: |- + Backends defines per-backend authentication overrides + Works in all modes (discovered, inline) + type: object + default: + description: Default defines default behavior for backends without + explicit auth config + properties: + externalAuthConfigRef: + description: |- + ExternalAuthConfigRef references an MCPExternalAuthConfig resource + Only used when Type is "externalAuthConfigRef" + properties: + name: + description: Name is the name of the MCPExternalAuthConfig + resource + type: string + required: + - name + type: object + type: + description: Type defines the authentication type + enum: + - discovered + - externalAuthConfigRef + type: string + required: + - type + type: object + source: + default: discovered + description: |- + Source defines how backend authentication configurations are determined + - discovered: Automatically discover from backend's MCPServer.spec.externalAuthConfigRef + - inline: Explicit per-backend configuration in VirtualMCPServer + enum: + - discovered + - inline + type: string + type: object + podTemplateSpec: + description: |- + PodTemplateSpec defines the pod template to use for the Virtual MCP server + This allows for customizing the pod configuration beyond what is provided by the other fields. + Note that to modify the specific container the Virtual MCP server runs in, you must specify + the 'vmcp' container name in the PodTemplateSpec. + This field accepts a PodTemplateSpec object as JSON/YAML. + type: object + x-kubernetes-preserve-unknown-fields: true + replicas: + description: |- + Replicas is the desired number of vMCP pod replicas. + VirtualMCPServer creates a single Deployment for the vMCP aggregator process, + so there is only one replicas field (unlike MCPServer which has separate + Replicas and BackendReplicas for its two Deployments). + When nil, the operator does not set Deployment.Spec.Replicas, leaving replica + management to an HPA or other external controller. + format: int32 + minimum: 0 + type: integer + serviceAccount: + description: |- + ServiceAccount is the name of an already existing service account to use by the Virtual MCP server. + If not specified, a ServiceAccount will be created automatically and used by the Virtual MCP server. + type: string + serviceType: + default: ClusterIP + description: ServiceType specifies the Kubernetes service type for + the Virtual MCP server + enum: + - ClusterIP + - NodePort + - LoadBalancer + type: string + sessionAffinity: + default: ClientIP + description: |- + SessionAffinity controls whether the Service routes repeated client connections to the same pod. + MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default. + Set to "None" for stateless servers or when using an external load balancer with its own affinity. + enum: + - ClientIP + - None + type: string + sessionStorage: + description: |- + SessionStorage configures session storage for stateful horizontal scaling. + When nil, no session storage is configured. + properties: + address: + description: Address is the Redis server address (required when + provider is redis) + minLength: 1 + type: string + db: + default: 0 + description: DB is the Redis database number + format: int32 + minimum: 0 + type: integer + keyPrefix: + description: KeyPrefix is an optional prefix for all Redis keys + used by ToolHive + type: string + passwordRef: + description: PasswordRef is a reference to a Secret key containing + the Redis password + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + provider: + description: Provider is the session storage backend type + enum: + - memory + - redis + type: string + required: + - provider + type: object + x-kubernetes-validations: + - message: address is required + rule: 'self.provider == ''redis'' ? has(self.address) : true' + telemetryConfigRef: + description: |- + TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration. + The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer. + Cross-namespace references are not supported for security and isolation reasons. + properties: + name: + description: Name is the name of the MCPTelemetryConfig resource + minLength: 1 + type: string + serviceName: + description: |- + ServiceName overrides the telemetry service name for this specific server. + This MUST be unique per server for proper observability (e.g., distinguishing + traces and metrics from different servers sharing the same collector). + If empty, defaults to the server name with "thv-" prefix at runtime. + type: string + required: + - name + type: object + required: + - groupRef + - incomingAuth + type: object + status: + description: VirtualMCPServerStatus defines the observed state of VirtualMCPServer + properties: + backendCount: + description: |- + BackendCount is the number of routable backends (ready + unauthenticated). + Excludes unavailable, degraded, and unknown backends. + format: int32 + type: integer + conditions: + description: Conditions represent the latest available observations + of the VirtualMCPServer's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + discoveredBackends: + description: DiscoveredBackends lists discovered backend configurations + from the MCPGroup + items: + description: |- + DiscoveredBackend represents a backend server discovered by vMCP runtime. + This type is shared with the Kubernetes operator CRD (VirtualMCPServer.Status.DiscoveredBackends). + properties: + authConfigRef: + description: AuthConfigRef is the name of the discovered MCPExternalAuthConfig + (if any) + type: string + authType: + description: AuthType is the type of authentication configured + type: string + circuitBreakerState: + description: |- + CircuitBreakerState is the current circuit breaker state (closed, open, half-open). + Empty when circuit breaker is disabled or not configured. + enum: + - closed + - open + - half-open + type: string + circuitLastChanged: + description: |- + CircuitLastChanged is the timestamp when the circuit breaker state last changed. + Empty when circuit breaker is disabled or has never changed state. + format: date-time + type: string + consecutiveFailures: + description: |- + ConsecutiveFailures is the current count of consecutive health check failures. + Resets to 0 when the backend becomes healthy again. + type: integer + lastHealthCheck: + description: LastHealthCheck is the timestamp of the last health + check + format: date-time + type: string + message: + description: Message provides additional information about the + backend status + type: string + name: + description: Name is the name of the backend MCPServer + type: string + status: + description: |- + Status is the current status of the backend (ready, degraded, unavailable, unauthenticated, unknown). + Use BackendHealthStatus.ToCRDStatus() to populate this field. + type: string + url: + description: URL is the URL of the backend MCPServer + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + message: + description: Message provides additional information about the current + phase + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + for this VirtualMCPServer + format: int64 + type: integer + oidcConfigHash: + description: |- + OIDCConfigHash is the hash of the referenced MCPOIDCConfig spec for change detection. + Only populated when IncomingAuth.OIDCConfigRef is set. + type: string + phase: + default: Pending + description: Phase is the current phase of the VirtualMCPServer + enum: + - Pending + - Ready + - Degraded + - Failed + type: string + telemetryConfigHash: + description: |- + TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection. + Only populated when TelemetryConfigRef is set. + type: string + url: + description: URL is the URL where the Virtual MCP server can be accessed + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - description: The phase of the VirtualMCPServer + jsonPath: .status.phase + name: Phase + type: string + - description: Virtual MCP server URL + jsonPath: .status.url + name: URL + type: string + - description: Discovered backends count + jsonPath: .status.backendCount + name: Backends + type: integer + - description: Age + jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + name: v1beta1 schema: openAPIV3Schema: description: |- diff --git a/deploy/keycloak/mcpserver-with-auth.yaml b/deploy/keycloak/mcpserver-with-auth.yaml index f023b19a16..85ecf97a3a 100644 --- a/deploy/keycloak/mcpserver-with-auth.yaml +++ b/deploy/keycloak/mcpserver-with-auth.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPOIDCConfig metadata: name: keycloak-oidc @@ -13,7 +13,7 @@ spec: # Optional: Allow private IP addresses for development jwksAllowPrivateIP: true --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch-server-keycloak diff --git a/docs/arch/02-core-concepts.md b/docs/arch/02-core-concepts.md index e3fbce4885..84e565ee3f 100644 --- a/docs/arch/02-core-concepts.md +++ b/docs/arch/02-core-concepts.md @@ -264,7 +264,7 @@ A **Virtual MCP Server** aggregates multiple MCP servers from a group into a sin **Example use case:** ```yaml # Combine GitHub, Slack, and Jira into one "team-tools" virtual server -apiVersion: mcp.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: team-tools @@ -288,7 +288,7 @@ spec: - CLI: Standalone via the `vmcp` binary for development or non-Kubernetes environments **Implementation:** -- CRD: `cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go` +- CRD: `cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go` - Controller: `cmd/thv-operator/controllers/virtualmcpserver_controller.go` - Binary: `cmd/vmcp/` (virtual MCP server runtime) diff --git a/docs/arch/03-transport-architecture.md b/docs/arch/03-transport-architecture.md index 0ba404fcf6..6fcb14012e 100644 --- a/docs/arch/03-transport-architecture.md +++ b/docs/arch/03-transport-architecture.md @@ -618,7 +618,7 @@ thv run --transport sse --endpoint-prefix /playwright playwright **Kubernetes CRD:** ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer spec: endpointPrefix: /playwright diff --git a/docs/arch/04-secrets-management.md b/docs/arch/04-secrets-management.md index 7d1e412916..74b68e8d48 100644 --- a/docs/arch/04-secrets-management.md +++ b/docs/arch/04-secrets-management.md @@ -67,7 +67,7 @@ In Kubernetes/operator mode, ToolHive uses **native Kubernetes Secrets** instead MCPServer resources reference Kubernetes Secrets via `SecretRef`. Secrets are injected as environment variables using Kubernetes `SecretKeyRef`. **Implementation**: -- CRD types: `cmd/thv-operator/api/v1alpha1/mcpserver_types.go` +- CRD types: `cmd/thv-operator/api/v1beta1/mcpserver_types.go` - Pod builder: `cmd/thv-operator/controllers/mcpserver_podtemplatespec_builder.go` ### External Authentication Secrets @@ -75,11 +75,11 @@ MCPServer resources reference Kubernetes Secrets via `SecretRef`. Secrets are in OAuth/OIDC client secrets are stored in Kubernetes Secrets and referenced using `SecretKeyRef`: 1. **Token Exchange (MCPExternalAuthConfig)**: OAuth 2.0 client secrets for RFC-8693 token exchange flows - - **Implementation**: `cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go` + - **Implementation**: `cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go` - **Secret injection**: `cmd/thv-operator/pkg/controllerutil/tokenexchange.go` 2. **OIDC Authentication (MCPOIDCConfig)**: OIDC client secrets for token introspection - - **CRD field**: `InlineOIDCSharedConfig.ClientSecretRef` in `cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go` + - **CRD field**: `InlineOIDCSharedConfig.ClientSecretRef` in `cmd/thv-operator/api/v1beta1/mcpoidcconfig_types.go` - **Secret injection**: `cmd/thv-operator/pkg/controllerutil/oidc.go` - **Runtime loading**: `pkg/auth/token.go` (via `TOOLHIVE_OIDC_CLIENT_SECRET` environment variable) diff --git a/docs/arch/06-registry-system.md b/docs/arch/06-registry-system.md index 09152cf284..68129ffc34 100644 --- a/docs/arch/06-registry-system.md +++ b/docs/arch/06-registry-system.md @@ -512,7 +512,7 @@ For complete registry server documentation, see: For Kubernetes deployments, registries managed via `MCPRegistry` CRD. -**Implementation**: `cmd/thv-operator/api/v1alpha1/mcpregistry_types.go` +**Implementation**: `cmd/thv-operator/api/v1beta1/mcpregistry_types.go` ### How configYAML Works @@ -529,7 +529,7 @@ certs) must be mounted into the registry-api container via explicit ### Example CRD ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRegistry metadata: name: company-registry diff --git a/docs/arch/09-operator-architecture.md b/docs/arch/09-operator-architecture.md index 510e6690e2..82eff27f4a 100644 --- a/docs/arch/09-operator-architecture.md +++ b/docs/arch/09-operator-architecture.md @@ -154,7 +154,7 @@ graph TB Defines an MCP server deployment, including container images, transports, middleware, and authentication configuration. -**Implementation**: `cmd/thv-operator/api/v1alpha1/mcpserver_types.go` +**Implementation**: `cmd/thv-operator/api/v1beta1/mcpserver_types.go` MCPServer resources support various transport types (stdio, SSE, streamable-http), permission profiles, OIDC authentication, and Cedar-based authorization policies. The operator reconciles these resources into Kubernetes Deployments, Services, and StatefulSets. @@ -178,7 +178,7 @@ For examples, see: Manages MCP server registries in Kubernetes, supporting both Git-based and ConfigMap-based registry sources with automatic or manual synchronization. -**Implementation**: `cmd/thv-operator/api/v1alpha1/mcpregistry_types.go` +**Implementation**: `cmd/thv-operator/api/v1beta1/mcpregistry_types.go` MCPRegistry resources can sync registry data from external sources and optionally deploy a registry API service for serving the registry data to other components. @@ -190,7 +190,7 @@ For examples, see the [`examples/operator/`](../../examples/operator/) directory Defines tool filtering and override configuration. -**Implementation**: `cmd/thv-operator/api/v1alpha1/toolconfig_types.go` +**Implementation**: `cmd/thv-operator/api/v1beta1/toolconfig_types.go` MCPToolConfig allows you to filter which tools are exposed by an MCP server and customize tool metadata. See [`examples/operator/mcp-servers/mcpserver_fetch_tools_filter.yaml`](../../examples/operator/mcp-servers/mcpserver_fetch_tools_filter.yaml) for a complete example. @@ -202,7 +202,7 @@ MCPToolConfig allows you to filter which tools are exposed by an MCP server and Manages external authentication configurations that can be shared across multiple MCPServer resources. -**Implementation**: `cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go` +**Implementation**: `cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go` MCPExternalAuthConfig allows you to define reusable authentication configurations that can be referenced by multiple MCPServer and MCPRemoteProxy resources. When using the embedded auth server type, the `storage` field supports configuring Redis Sentinel as a shared storage backend for horizontal scaling. See [Auth Server Storage](11-auth-server-storage.md) for details. @@ -218,7 +218,7 @@ MCPExternalAuthConfig resources can be referenced via two paths: Defines shared OIDC provider configuration that can be referenced by multiple workload CRDs (MCPServer, MCPRemoteProxy, VirtualMCPServer) in the same namespace. -**Implementation**: `cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go` +**Implementation**: `cmd/thv-operator/api/v1beta1/mcpoidcconfig_types.go` MCPOIDCConfig eliminates OIDC configuration duplication — define an identity provider once and reference it from any number of workloads. A single issuer URL change updates all referencing workloads automatically. @@ -243,7 +243,7 @@ For examples, see [`examples/operator/mcp-servers/mcpserver_with_oidcconfig_ref. Defines shared OpenTelemetry and Prometheus configuration that can be referenced by multiple MCPServer resources in the same namespace. -**Implementation**: `cmd/thv-operator/api/v1alpha1/mcptelemetryconfig_types.go` +**Implementation**: `cmd/thv-operator/api/v1beta1/mcptelemetryconfig_types.go` MCPTelemetryConfig centralises telemetry infrastructure settings (collector endpoint, sampling rate, headers) so they can be managed once for a fleet of MCP servers. @@ -279,7 +279,7 @@ OIDC is optional — omit `oidcConfigRef` for unauthenticated proxies. ```yaml # MCPRemoteProxy with embedded auth server (incoming) + AWS STS (outgoing) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRemoteProxy metadata: name: bedrock-proxy @@ -292,7 +292,7 @@ spec: name: bedrock-sts-config # type: awsSts ``` -**Implementation**: `cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go` +**Implementation**: `cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go` **Controller**: `cmd/thv-operator/controllers/mcpremoteproxy_controller.go` @@ -310,7 +310,7 @@ The MCPServerEntry controller is validation-only: it validates that referenced r MCPServerEntry backends are discovered by vMCP in both static mode (listed at startup) and dynamic mode (watched by the BackendReconciler). In dynamic mode, ConfigMap changes trigger re-reconciliation of affected MCPServerEntry backends via a field-indexed watch on `spec.caBundleRef.configMapRef.name`. -**Implementation**: `cmd/thv-operator/api/v1alpha1/mcpserverentry_types.go` +**Implementation**: `cmd/thv-operator/api/v1beta1/mcpserverentry_types.go` **Controller**: `cmd/thv-operator/controllers/mcpserverentry_controller.go` @@ -318,7 +318,7 @@ MCPServerEntry backends are discovered by vMCP in both static mode (listed at st Logically groups MCPServer resources together for organizational purposes. -**Implementation**: `cmd/thv-operator/api/v1alpha1/mcpgroup_types.go` +**Implementation**: `cmd/thv-operator/api/v1beta1/mcpgroup_types.go` MCPGroup resources allow grouping related MCP servers. Servers reference their group using the `groupRef` typed struct (`MCPGroupRef`) in MCPServer spec. The group tracks member servers in its status. @@ -332,7 +332,7 @@ MCPGroup resources allow grouping related MCP servers. Servers reference their g Aggregates multiple MCPServer resources from an MCPGroup into a single unified MCP server interface with advanced composition capabilities. -**Implementation**: `cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go` +**Implementation**: `cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go` VirtualMCPServer creates a virtual MCP server that aggregates tools, resources, and prompts from multiple backend MCPServers. It provides: @@ -401,7 +401,7 @@ VirtualMCPServer creates a virtual MCP server that aggregates tools, resources, Defines reusable composite tool workflows that can be shared across multiple VirtualMCPServers. -**Implementation**: `cmd/thv-operator/api/v1alpha1/virtualmcpcompositetooldefinition_types.go` +**Implementation**: `cmd/thv-operator/api/v1beta1/virtualmcpcompositetooldefinition_types.go` Composite tools orchestrate calls to multiple backend tools in sequence or parallel, enabling complex workflows without client awareness of the underlying backends. Workflow steps form a DAG (Directed Acyclic Graph) with support for conditional execution and error handling. @@ -640,7 +640,7 @@ The preferred approach is to define OIDC and telemetry settings in dedicated con **MCPOIDCConfig reference:** ```yaml # Define shared OIDC config once -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPOIDCConfig metadata: name: corporate-idp @@ -665,7 +665,7 @@ spec: **MCPTelemetryConfig reference:** ```yaml # Define shared telemetry config once -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPTelemetryConfig metadata: name: shared-otel diff --git a/docs/arch/11-auth-server-storage.md b/docs/arch/11-auth-server-storage.md index 78168729de..efd1ab4132 100644 --- a/docs/arch/11-auth-server-storage.md +++ b/docs/arch/11-auth-server-storage.md @@ -172,7 +172,7 @@ MCPExternalAuthConfig └── timeouts (dial, read, write) ``` -**Implementation:** `cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go` +**Implementation:** `cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go` ### RunConfig Serialization diff --git a/docs/observability.md b/docs/observability.md index 9e27f5d36e..4f48062e4b 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -133,7 +133,7 @@ override. Sensitive headers (API keys, bearer tokens) are stored in Kubernetes Secrets via `sensitiveHeaders[].secretKeyRef`. ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPTelemetryConfig metadata: name: shared-otel @@ -148,7 +148,7 @@ spec: metrics: enabled: true --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: my-server @@ -164,7 +164,7 @@ for a complete example. **Inline (deprecated)**: The inline `spec.telemetry` (MCPServer, MCPRemoteProxy) and `spec.config.telemetry` (VirtualMCPServer) fields still work but are -deprecated and will be removed in v1beta1. They are mutually exclusive with +deprecated and will be removed in a future API version. They are mutually exclusive with `telemetryConfigRef` (CEL enforced). All three resource types now support `spec.telemetryConfigRef`. diff --git a/docs/operator/advanced-workflow-patterns.md b/docs/operator/advanced-workflow-patterns.md index 8ac7d67839..8131469f8c 100644 --- a/docs/operator/advanced-workflow-patterns.md +++ b/docs/operator/advanced-workflow-patterns.md @@ -31,7 +31,7 @@ Virtual MCP Composite Tools use a Directed Acyclic Graph (DAG) execution model t ### Example: Parallel Data Fetching ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: incident-investigation @@ -307,7 +307,7 @@ steps: ### Example: Combining Error Strategies ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: robust-deployment diff --git a/docs/operator/composite-tools-quick-reference.md b/docs/operator/composite-tools-quick-reference.md index a3e8b73911..719026a293 100644 --- a/docs/operator/composite-tools-quick-reference.md +++ b/docs/operator/composite-tools-quick-reference.md @@ -5,7 +5,7 @@ Quick reference for Virtual MCP Composite Tool workflows. ## Basic Workflow Structure ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: my-workflow diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 0453460af2..0fbc8f22fe 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -6,6 +6,7 @@ - [toolhive.stacklok.dev/config](#toolhivestacklokdevconfig) - [toolhive.stacklok.dev/telemetry](#toolhivestacklokdevtelemetry) - [toolhive.stacklok.dev/v1alpha1](#toolhivestacklokdevv1alpha1) +- [toolhive.stacklok.dev/v1beta1](#toolhivestacklokdevv1beta1) ## toolhive.stacklok.dev/audit @@ -216,7 +217,7 @@ This matches the YAML structure from the proposal (lines 173-255). _Appears in:_ - [vmcp.config.Config](#vmcpconfigconfig) -- [api.v1alpha1.VirtualMCPCompositeToolDefinitionSpec](#apiv1alpha1virtualmcpcompositetooldefinitionspec) +- [api.v1beta1.VirtualMCPCompositeToolDefinitionSpec](#apiv1beta1virtualmcpcompositetooldefinitionspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -259,7 +260,7 @@ _Validation:_ - Type: object _Appears in:_ -- [api.v1alpha1.VirtualMCPServerSpec](#apiv1alpha1virtualmcpserverspec) +- [api.v1beta1.VirtualMCPServerSpec](#apiv1beta1virtualmcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -469,7 +470,7 @@ MCP output schema (type, description) and runtime value construction (value, def _Appears in:_ - [vmcp.config.CompositeToolConfig](#vmcpconfigcompositetoolconfig) -- [api.v1alpha1.VirtualMCPCompositeToolDefinitionSpec](#apiv1alpha1virtualmcpcompositetooldefinitionspec) +- [api.v1beta1.VirtualMCPCompositeToolDefinitionSpec](#apiv1beta1virtualmcpcompositetooldefinitionspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -642,7 +643,7 @@ This matches the proposal's step configuration (lines 180-255). _Appears in:_ - [vmcp.config.CompositeToolConfig](#vmcpconfigcompositetoolconfig) -- [api.v1alpha1.VirtualMCPCompositeToolDefinitionSpec](#apiv1alpha1virtualmcpcompositetooldefinitionspec) +- [api.v1beta1.VirtualMCPCompositeToolDefinitionSpec](#apiv1beta1virtualmcpcompositetooldefinitionspec) - [vmcp.config.WorkflowStepConfig](#vmcpconfigworkflowstepconfig) | Field | Description | Default | Validation | @@ -761,7 +762,85 @@ _Appears in:_ -#### api.v1alpha1.AWSStsConfig + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +## toolhive.stacklok.dev/v1beta1 +### Resource Types +- [api.v1beta1.EmbeddingServer](#apiv1beta1embeddingserver) +- [api.v1beta1.EmbeddingServerList](#apiv1beta1embeddingserverlist) +- [api.v1beta1.MCPExternalAuthConfig](#apiv1beta1mcpexternalauthconfig) +- [api.v1beta1.MCPExternalAuthConfigList](#apiv1beta1mcpexternalauthconfiglist) +- [api.v1beta1.MCPGroup](#apiv1beta1mcpgroup) +- [api.v1beta1.MCPGroupList](#apiv1beta1mcpgrouplist) +- [api.v1beta1.MCPOIDCConfig](#apiv1beta1mcpoidcconfig) +- [api.v1beta1.MCPOIDCConfigList](#apiv1beta1mcpoidcconfiglist) +- [api.v1beta1.MCPRegistry](#apiv1beta1mcpregistry) +- [api.v1beta1.MCPRegistryList](#apiv1beta1mcpregistrylist) +- [api.v1beta1.MCPRemoteProxy](#apiv1beta1mcpremoteproxy) +- [api.v1beta1.MCPRemoteProxyList](#apiv1beta1mcpremoteproxylist) +- [api.v1beta1.MCPServer](#apiv1beta1mcpserver) +- [api.v1beta1.MCPServerEntry](#apiv1beta1mcpserverentry) +- [api.v1beta1.MCPServerEntryList](#apiv1beta1mcpserverentrylist) +- [api.v1beta1.MCPServerList](#apiv1beta1mcpserverlist) +- [api.v1beta1.MCPTelemetryConfig](#apiv1beta1mcptelemetryconfig) +- [api.v1beta1.MCPTelemetryConfigList](#apiv1beta1mcptelemetryconfiglist) +- [api.v1beta1.MCPToolConfig](#apiv1beta1mcptoolconfig) +- [api.v1beta1.MCPToolConfigList](#apiv1beta1mcptoolconfiglist) +- [api.v1beta1.VirtualMCPCompositeToolDefinition](#apiv1beta1virtualmcpcompositetooldefinition) +- [api.v1beta1.VirtualMCPCompositeToolDefinitionList](#apiv1beta1virtualmcpcompositetooldefinitionlist) +- [api.v1beta1.VirtualMCPServer](#apiv1beta1virtualmcpserver) +- [api.v1beta1.VirtualMCPServerList](#apiv1beta1virtualmcpserverlist) + + + +#### api.v1beta1.AWSStsConfig @@ -772,20 +851,20 @@ temporary credentials, then signs requests to AWS services using SigV4. _Appears in:_ -- [api.v1alpha1.MCPExternalAuthConfigSpec](#apiv1alpha1mcpexternalauthconfigspec) +- [api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `region` _string_ | Region is the AWS region for the STS endpoint and service (e.g., "us-east-1", "eu-west-1") | | MinLength: 1 <br />Pattern: `^[a-z]\{2\}(-[a-z]+)+-\d+$` <br />Required: \{\} <br /> | | `service` _string_ | Service is the AWS service name for SigV4 signing<br />Defaults to "aws-mcp" for AWS MCP Server endpoints | aws-mcp | Optional: \{\} <br /> | | `fallbackRoleArn` _string_ | FallbackRoleArn is the IAM role ARN to assume when no role mappings match<br />Used as the default role when RoleMappings is empty or no mapping matches<br />At least one of FallbackRoleArn or RoleMappings must be configured (enforced by webhook) | | Pattern: `^arn:(aws\|aws-cn\|aws-us-gov):iam::\d\{12\}:role/[\w+=,.@\-_/]+$` <br />Optional: \{\} <br /> | -| `roleMappings` _[api.v1alpha1.RoleMapping](#apiv1alpha1rolemapping) array_ | RoleMappings defines claim-based role selection rules<br />Allows mapping JWT claims (e.g., groups, roles) to specific IAM roles<br />Lower priority values are evaluated first (higher priority) | | Optional: \{\} <br /> | +| `roleMappings` _[api.v1beta1.RoleMapping](#apiv1beta1rolemapping) array_ | RoleMappings defines claim-based role selection rules<br />Allows mapping JWT claims (e.g., groups, roles) to specific IAM roles<br />Lower priority values are evaluated first (higher priority) | | Optional: \{\} <br /> | | `roleClaim` _string_ | RoleClaim is the JWT claim to use for role mapping evaluation<br />Defaults to "groups" to match common OIDC group claims | groups | Optional: \{\} <br /> | | `sessionDuration` _integer_ | SessionDuration is the duration in seconds for the STS session<br />Must be between 900 (15 minutes) and 43200 (12 hours)<br />Defaults to 3600 (1 hour) if not specified | 3600 | Maximum: 43200 <br />Minimum: 900 <br />Optional: \{\} <br /> | | `sessionNameClaim` _string_ | SessionNameClaim is the JWT claim to use for role session name<br />Defaults to "sub" to use the subject claim | sub | Optional: \{\} <br /> | -#### api.v1alpha1.AuditConfig +#### api.v1beta1.AuditConfig @@ -794,15 +873,15 @@ AuditConfig defines audit logging configuration for the MCP server _Appears in:_ -- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `enabled` _boolean_ | Enabled controls whether audit logging is enabled<br />When true, enables audit logging with default configuration | false | Optional: \{\} <br /> | -#### api.v1alpha1.AuthServerRef +#### api.v1beta1.AuthServerRef @@ -813,8 +892,8 @@ the enum will be extended when a dedicated auth server CRD is introduced. _Appears in:_ -- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -822,7 +901,7 @@ _Appears in:_ | `name` _string_ | Name is the name of the referenced resource in the same namespace. | | MinLength: 1 <br />Required: \{\} <br /> | -#### api.v1alpha1.AuthServerStorageConfig +#### api.v1beta1.AuthServerStorageConfig @@ -831,15 +910,15 @@ AuthServerStorageConfig configures the storage backend for the embedded auth ser _Appears in:_ -- [api.v1alpha1.EmbeddedAuthServerConfig](#apiv1alpha1embeddedauthserverconfig) +- [api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `type` _[api.v1alpha1.AuthServerStorageType](#apiv1alpha1authserverstoragetype)_ | Type specifies the storage backend type.<br />Valid values: "memory" (default), "redis". | memory | Enum: [memory redis] <br /> | -| `redis` _[api.v1alpha1.RedisStorageConfig](#apiv1alpha1redisstorageconfig)_ | Redis configures the Redis storage backend.<br />Required when type is "redis". | | Optional: \{\} <br /> | +| `type` _[api.v1beta1.AuthServerStorageType](#apiv1beta1authserverstoragetype)_ | Type specifies the storage backend type.<br />Valid values: "memory" (default), "redis". | memory | Enum: [memory redis] <br /> | +| `redis` _[api.v1beta1.RedisStorageConfig](#apiv1beta1redisstorageconfig)_ | Redis configures the Redis storage backend.<br />Required when type is "redis". | | Optional: \{\} <br /> | -#### api.v1alpha1.AuthServerStorageType +#### api.v1beta1.AuthServerStorageType _Underlying type:_ _string_ @@ -848,7 +927,7 @@ AuthServerStorageType represents the type of storage backend for the embedded au _Appears in:_ -- [api.v1alpha1.AuthServerStorageConfig](#apiv1alpha1authserverstorageconfig) +- [api.v1beta1.AuthServerStorageConfig](#apiv1beta1authserverstorageconfig) | Field | Description | | --- | --- | @@ -856,7 +935,7 @@ _Appears in:_ | `redis` | AuthServerStorageTypeRedis is the Redis storage backend<br /> | -#### api.v1alpha1.AuthzConfigRef +#### api.v1beta1.AuthzConfigRef @@ -865,18 +944,18 @@ AuthzConfigRef defines a reference to authorization configuration _Appears in:_ -- [api.v1alpha1.IncomingAuthConfig](#apiv1alpha1incomingauthconfig) -- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.IncomingAuthConfig](#apiv1beta1incomingauthconfig) +- [api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `type` _string_ | Type is the type of authorization configuration | configMap | Enum: [configMap inline] <br /> | -| `configMap` _[api.v1alpha1.ConfigMapAuthzRef](#apiv1alpha1configmapauthzref)_ | ConfigMap references a ConfigMap containing authorization configuration<br />Only used when Type is "configMap" | | Optional: \{\} <br /> | -| `inline` _[api.v1alpha1.InlineAuthzConfig](#apiv1alpha1inlineauthzconfig)_ | Inline contains direct authorization configuration<br />Only used when Type is "inline" | | Optional: \{\} <br /> | +| `configMap` _[api.v1beta1.ConfigMapAuthzRef](#apiv1beta1configmapauthzref)_ | ConfigMap references a ConfigMap containing authorization configuration<br />Only used when Type is "configMap" | | Optional: \{\} <br /> | +| `inline` _[api.v1beta1.InlineAuthzConfig](#apiv1beta1inlineauthzconfig)_ | Inline contains direct authorization configuration<br />Only used when Type is "inline" | | Optional: \{\} <br /> | -#### api.v1alpha1.BackendAuthConfig +#### api.v1beta1.BackendAuthConfig @@ -885,15 +964,15 @@ BackendAuthConfig defines authentication configuration for a backend MCPServer _Appears in:_ -- [api.v1alpha1.OutgoingAuthConfig](#apiv1alpha1outgoingauthconfig) +- [api.v1beta1.OutgoingAuthConfig](#apiv1beta1outgoingauthconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `type` _string_ | Type defines the authentication type | | Enum: [discovered externalAuthConfigRef] <br />Required: \{\} <br /> | -| `externalAuthConfigRef` _[api.v1alpha1.ExternalAuthConfigRef](#apiv1alpha1externalauthconfigref)_ | ExternalAuthConfigRef references an MCPExternalAuthConfig resource<br />Only used when Type is "externalAuthConfigRef" | | Optional: \{\} <br /> | +| `externalAuthConfigRef` _[api.v1beta1.ExternalAuthConfigRef](#apiv1beta1externalauthconfigref)_ | ExternalAuthConfigRef references an MCPExternalAuthConfig resource<br />Only used when Type is "externalAuthConfigRef" | | Optional: \{\} <br /> | -#### api.v1alpha1.BearerTokenConfig +#### api.v1beta1.BearerTokenConfig @@ -904,14 +983,14 @@ For security reasons, only secret references are supported (no plaintext values) _Appears in:_ -- [api.v1alpha1.MCPExternalAuthConfigSpec](#apiv1alpha1mcpexternalauthconfigspec) +- [api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `tokenSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | TokenSecretRef references a Kubernetes Secret containing the bearer token | | Required: \{\} <br /> | +| `tokenSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | TokenSecretRef references a Kubernetes Secret containing the bearer token | | Required: \{\} <br /> | -#### api.v1alpha1.CABundleSource +#### api.v1beta1.CABundleSource @@ -920,16 +999,16 @@ CABundleSource defines a source for CA certificate bundles. _Appears in:_ -- [api.v1alpha1.InlineOIDCSharedConfig](#apiv1alpha1inlineoidcsharedconfig) -- [api.v1alpha1.MCPServerEntrySpec](#apiv1alpha1mcpserverentryspec) -- [api.v1alpha1.MCPTelemetryOTelConfig](#apiv1alpha1mcptelemetryotelconfig) +- [api.v1beta1.InlineOIDCSharedConfig](#apiv1beta1inlineoidcsharedconfig) +- [api.v1beta1.MCPServerEntrySpec](#apiv1beta1mcpserverentryspec) +- [api.v1beta1.MCPTelemetryOTelConfig](#apiv1beta1mcptelemetryotelconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `configMapRef` _[ConfigMapKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#configmapkeyselector-v1-core)_ | ConfigMapRef references a ConfigMap containing the CA certificate bundle.<br />If Key is not specified, it defaults to "ca.crt". | | Optional: \{\} <br /> | -#### api.v1alpha1.ConfigMapAuthzRef +#### api.v1beta1.ConfigMapAuthzRef @@ -938,7 +1017,7 @@ ConfigMapAuthzRef references a ConfigMap containing authorization configuration _Appears in:_ -- [api.v1alpha1.AuthzConfigRef](#apiv1alpha1authzconfigref) +- [api.v1beta1.AuthzConfigRef](#apiv1beta1authzconfigref) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -948,7 +1027,7 @@ _Appears in:_ -#### api.v1alpha1.EmbeddedAuthServerConfig +#### api.v1beta1.EmbeddedAuthServerConfig @@ -958,21 +1037,21 @@ This enables running an authorization server that delegates authentication to up _Appears in:_ -- [api.v1alpha1.MCPExternalAuthConfigSpec](#apiv1alpha1mcpexternalauthconfigspec) -- [api.v1alpha1.VirtualMCPServerSpec](#apiv1alpha1virtualmcpserverspec) +- [api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec) +- [api.v1beta1.VirtualMCPServerSpec](#apiv1beta1virtualmcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `issuer` _string_ | Issuer is the issuer identifier for this authorization server.<br />This will be included in the "iss" claim of issued tokens.<br />Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414). | | Pattern: `^https?://[^\s?#]+[^/\s?#]$` <br />Required: \{\} <br /> | | `authorizationEndpointBaseUrl` _string_ | AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint<br />in the OAuth discovery document. When set, the discovery document will advertise<br />`\{authorizationEndpointBaseUrl\}/oauth/authorize` instead of `\{issuer\}/oauth/authorize`.<br />All other endpoints (token, registration, JWKS) remain derived from the issuer.<br />This is useful when the browser-facing authorization endpoint needs to be on a<br />different host than the issuer used for backend-to-backend calls.<br />Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash. | | Pattern: `^https?://[^\s?#]+[^/\s?#]$` <br />Optional: \{\} <br /> | -| `signingKeySecretRefs` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref) array_ | SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.<br />Supports key rotation by allowing multiple keys (oldest keys are used for verification only).<br />If not specified, an ephemeral signing key will be auto-generated (development only -<br />JWTs will be invalid after restart). | | MaxItems: 5 <br />Optional: \{\} <br /> | -| `hmacSecretRefs` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref) array_ | HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing<br />authorization codes and refresh tokens (opaque tokens).<br />Current secret must be at least 32 bytes and cryptographically random.<br />Supports secret rotation via multiple entries (first is current, rest are for verification).<br />If not specified, an ephemeral secret will be auto-generated (development only -<br />auth codes and refresh tokens will be invalid after restart). | | Optional: \{\} <br /> | -| `tokenLifespans` _[api.v1alpha1.TokenLifespanConfig](#apiv1alpha1tokenlifespanconfig)_ | TokenLifespans configures the duration that various tokens are valid.<br />If not specified, defaults are applied (access: 1h, refresh: 7d, authCode: 10m). | | Optional: \{\} <br /> | -| `upstreamProviders` _[api.v1alpha1.UpstreamProviderConfig](#apiv1alpha1upstreamproviderconfig) array_ | UpstreamProviders configures connections to upstream Identity Providers.<br />The embedded auth server delegates authentication to these providers.<br />MCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple. | | MinItems: 1 <br />Required: \{\} <br /> | -| `storage` _[api.v1alpha1.AuthServerStorageConfig](#apiv1alpha1authserverstorageconfig)_ | Storage configures the storage backend for the embedded auth server.<br />If not specified, defaults to in-memory storage. | | Optional: \{\} <br /> | +| `signingKeySecretRefs` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref) array_ | SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.<br />Supports key rotation by allowing multiple keys (oldest keys are used for verification only).<br />If not specified, an ephemeral signing key will be auto-generated (development only -<br />JWTs will be invalid after restart). | | MaxItems: 5 <br />Optional: \{\} <br /> | +| `hmacSecretRefs` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref) array_ | HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing<br />authorization codes and refresh tokens (opaque tokens).<br />Current secret must be at least 32 bytes and cryptographically random.<br />Supports secret rotation via multiple entries (first is current, rest are for verification).<br />If not specified, an ephemeral secret will be auto-generated (development only -<br />auth codes and refresh tokens will be invalid after restart). | | Optional: \{\} <br /> | +| `tokenLifespans` _[api.v1beta1.TokenLifespanConfig](#apiv1beta1tokenlifespanconfig)_ | TokenLifespans configures the duration that various tokens are valid.<br />If not specified, defaults are applied (access: 1h, refresh: 7d, authCode: 10m). | | Optional: \{\} <br /> | +| `upstreamProviders` _[api.v1beta1.UpstreamProviderConfig](#apiv1beta1upstreamproviderconfig) array_ | UpstreamProviders configures connections to upstream Identity Providers.<br />The embedded auth server delegates authentication to these providers.<br />MCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple. | | MinItems: 1 <br />Required: \{\} <br /> | +| `storage` _[api.v1beta1.AuthServerStorageConfig](#apiv1beta1authserverstorageconfig)_ | Storage configures the storage backend for the embedded auth server.<br />If not specified, defaults to in-memory storage. | | Optional: \{\} <br /> | -#### api.v1alpha1.EmbeddingResourceOverrides +#### api.v1beta1.EmbeddingResourceOverrides @@ -981,16 +1060,16 @@ EmbeddingResourceOverrides defines overrides for annotations and labels on creat _Appears in:_ -- [api.v1alpha1.EmbeddingServerSpec](#apiv1alpha1embeddingserverspec) +- [api.v1beta1.EmbeddingServerSpec](#apiv1beta1embeddingserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `statefulSet` _[api.v1alpha1.EmbeddingStatefulSetOverrides](#apiv1alpha1embeddingstatefulsetoverrides)_ | StatefulSet defines overrides for the StatefulSet resource | | Optional: \{\} <br /> | -| `service` _[api.v1alpha1.ResourceMetadataOverrides](#apiv1alpha1resourcemetadataoverrides)_ | Service defines overrides for the Service resource | | Optional: \{\} <br /> | -| `persistentVolumeClaim` _[api.v1alpha1.ResourceMetadataOverrides](#apiv1alpha1resourcemetadataoverrides)_ | PersistentVolumeClaim defines overrides for the PVC resource | | Optional: \{\} <br /> | +| `statefulSet` _[api.v1beta1.EmbeddingStatefulSetOverrides](#apiv1beta1embeddingstatefulsetoverrides)_ | StatefulSet defines overrides for the StatefulSet resource | | Optional: \{\} <br /> | +| `service` _[api.v1beta1.ResourceMetadataOverrides](#apiv1beta1resourcemetadataoverrides)_ | Service defines overrides for the Service resource | | Optional: \{\} <br /> | +| `persistentVolumeClaim` _[api.v1beta1.ResourceMetadataOverrides](#apiv1beta1resourcemetadataoverrides)_ | PersistentVolumeClaim defines overrides for the PVC resource | | Optional: \{\} <br /> | -#### api.v1alpha1.EmbeddingServer +#### api.v1beta1.EmbeddingServer @@ -999,20 +1078,20 @@ EmbeddingServer is the Schema for the embeddingservers API _Appears in:_ -- [api.v1alpha1.EmbeddingServerList](#apiv1alpha1embeddingserverlist) +- [api.v1beta1.EmbeddingServerList](#apiv1beta1embeddingserverlist) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `EmbeddingServer` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[api.v1alpha1.EmbeddingServerSpec](#apiv1alpha1embeddingserverspec)_ | | | | -| `status` _[api.v1alpha1.EmbeddingServerStatus](#apiv1alpha1embeddingserverstatus)_ | | | | +| `spec` _[api.v1beta1.EmbeddingServerSpec](#apiv1beta1embeddingserverspec)_ | | | | +| `status` _[api.v1beta1.EmbeddingServerStatus](#apiv1beta1embeddingserverstatus)_ | | | | -#### api.v1alpha1.EmbeddingServerList +#### api.v1beta1.EmbeddingServerList @@ -1024,15 +1103,15 @@ EmbeddingServerList contains a list of EmbeddingServer | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `EmbeddingServerList` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[api.v1alpha1.EmbeddingServer](#apiv1alpha1embeddingserver) array_ | | | | +| `items` _[api.v1beta1.EmbeddingServer](#apiv1beta1embeddingserver) array_ | | | | -#### api.v1alpha1.EmbeddingServerPhase +#### api.v1beta1.EmbeddingServerPhase _Underlying type:_ _string_ @@ -1042,7 +1121,7 @@ _Validation:_ - Enum: [Pending Downloading Ready Failed Terminating] _Appears in:_ -- [api.v1alpha1.EmbeddingServerStatus](#apiv1alpha1embeddingserverstatus) +- [api.v1beta1.EmbeddingServerStatus](#apiv1beta1embeddingserverstatus) | Field | Description | | --- | --- | @@ -1053,7 +1132,7 @@ _Appears in:_ | `Terminating` | EmbeddingServerPhaseTerminating means the EmbeddingServer is being deleted<br /> | -#### api.v1alpha1.EmbeddingServerRef +#### api.v1beta1.EmbeddingServerRef @@ -1063,14 +1142,14 @@ This follows the same pattern as ExternalAuthConfigRef and ToolConfigRef. _Appears in:_ -- [api.v1alpha1.VirtualMCPServerSpec](#apiv1alpha1virtualmcpserverspec) +- [api.v1beta1.VirtualMCPServerSpec](#apiv1beta1virtualmcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `name` _string_ | Name is the name of the EmbeddingServer resource | | Required: \{\} <br /> | -#### api.v1alpha1.EmbeddingServerSpec +#### api.v1beta1.EmbeddingServerSpec @@ -1079,25 +1158,25 @@ EmbeddingServerSpec defines the desired state of EmbeddingServer _Appears in:_ -- [api.v1alpha1.EmbeddingServer](#apiv1alpha1embeddingserver) +- [api.v1beta1.EmbeddingServer](#apiv1beta1embeddingserver) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `model` _string_ | Model is the HuggingFace embedding model to use (e.g., "sentence-transformers/all-MiniLM-L6-v2") | BAAI/bge-small-en-v1.5 | Optional: \{\} <br /> | -| `hfTokenSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | HFTokenSecretRef is a reference to a Kubernetes Secret containing the huggingface token.<br />If provided, the secret value will be provided to the embedding server for authentication with huggingface. | | Optional: \{\} <br /> | +| `hfTokenSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | HFTokenSecretRef is a reference to a Kubernetes Secret containing the huggingface token.<br />If provided, the secret value will be provided to the embedding server for authentication with huggingface. | | Optional: \{\} <br /> | | `image` _string_ | Image is the container image for the embedding inference server.<br />Images must be from HuggingFace Text Embeddings Inference (https://github.com/huggingface/text-embeddings-inference). | ghcr.io/huggingface/text-embeddings-inference:cpu-latest | Optional: \{\} <br /> | | `imagePullPolicy` _string_ | ImagePullPolicy defines the pull policy for the container image | IfNotPresent | Enum: [Always Never IfNotPresent] <br />Optional: \{\} <br /> | | `port` _integer_ | Port is the port to expose the embedding service on | 8080 | Maximum: 65535 <br />Minimum: 1 <br /> | | `args` _string array_ | Args are additional arguments to pass to the embedding inference server | | Optional: \{\} <br /> | -| `env` _[api.v1alpha1.EnvVar](#apiv1alpha1envvar) array_ | Env are environment variables to set in the container | | Optional: \{\} <br /> | -| `resources` _[api.v1alpha1.ResourceRequirements](#apiv1alpha1resourcerequirements)_ | Resources defines compute resources for the embedding server | | Optional: \{\} <br /> | -| `modelCache` _[api.v1alpha1.ModelCacheConfig](#apiv1alpha1modelcacheconfig)_ | ModelCache configures persistent storage for downloaded models<br />When enabled, models are cached in a PVC and reused across pod restarts | | Optional: \{\} <br /> | +| `env` _[api.v1beta1.EnvVar](#apiv1beta1envvar) array_ | Env are environment variables to set in the container | | Optional: \{\} <br /> | +| `resources` _[api.v1beta1.ResourceRequirements](#apiv1beta1resourcerequirements)_ | Resources defines compute resources for the embedding server | | Optional: \{\} <br /> | +| `modelCache` _[api.v1beta1.ModelCacheConfig](#apiv1beta1modelcacheconfig)_ | ModelCache configures persistent storage for downloaded models<br />When enabled, models are cached in a PVC and reused across pod restarts | | Optional: \{\} <br /> | | `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec allows customizing the pod (node selection, tolerations, etc.)<br />This field accepts a PodTemplateSpec object as JSON/YAML.<br />Note that to modify the specific container the embedding server runs in, you must specify<br />the 'embedding' container name in the PodTemplateSpec. | | Type: object <br />Optional: \{\} <br /> | -| `resourceOverrides` _[api.v1alpha1.EmbeddingResourceOverrides](#apiv1alpha1embeddingresourceoverrides)_ | ResourceOverrides allows overriding annotations and labels for resources created by the operator | | Optional: \{\} <br /> | +| `resourceOverrides` _[api.v1beta1.EmbeddingResourceOverrides](#apiv1beta1embeddingresourceoverrides)_ | ResourceOverrides allows overriding annotations and labels for resources created by the operator | | Optional: \{\} <br /> | | `replicas` _integer_ | Replicas is the number of embedding server replicas to run | 1 | Minimum: 1 <br />Optional: \{\} <br /> | -#### api.v1alpha1.EmbeddingServerStatus +#### api.v1beta1.EmbeddingServerStatus @@ -1106,19 +1185,19 @@ EmbeddingServerStatus defines the observed state of EmbeddingServer _Appears in:_ -- [api.v1alpha1.EmbeddingServer](#apiv1alpha1embeddingserver) +- [api.v1beta1.EmbeddingServer](#apiv1beta1embeddingserver) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the EmbeddingServer's state | | Optional: \{\} <br /> | -| `phase` _[api.v1alpha1.EmbeddingServerPhase](#apiv1alpha1embeddingserverphase)_ | Phase is the current phase of the EmbeddingServer | | Enum: [Pending Downloading Ready Failed Terminating] <br />Optional: \{\} <br /> | +| `phase` _[api.v1beta1.EmbeddingServerPhase](#apiv1beta1embeddingserverphase)_ | Phase is the current phase of the EmbeddingServer | | Enum: [Pending Downloading Ready Failed Terminating] <br />Optional: \{\} <br /> | | `message` _string_ | Message provides additional information about the current phase | | Optional: \{\} <br /> | | `url` _string_ | URL is the URL where the embedding service can be accessed | | Optional: \{\} <br /> | | `readyReplicas` _integer_ | ReadyReplicas is the number of ready replicas | | Optional: \{\} <br /> | | `observedGeneration` _integer_ | ObservedGeneration reflects the generation most recently observed by the controller | | Optional: \{\} <br /> | -#### api.v1alpha1.EmbeddingStatefulSetOverrides +#### api.v1beta1.EmbeddingStatefulSetOverrides @@ -1127,16 +1206,16 @@ EmbeddingStatefulSetOverrides defines overrides specific to the embedding statef _Appears in:_ -- [api.v1alpha1.EmbeddingResourceOverrides](#apiv1alpha1embeddingresourceoverrides) +- [api.v1beta1.EmbeddingResourceOverrides](#apiv1beta1embeddingresourceoverrides) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `annotations` _object (keys:string, values:string)_ | Annotations to add or override on the resource | | Optional: \{\} <br /> | | `labels` _object (keys:string, values:string)_ | Labels to add or override on the resource | | Optional: \{\} <br /> | -| `podTemplateMetadataOverrides` _[api.v1alpha1.ResourceMetadataOverrides](#apiv1alpha1resourcemetadataoverrides)_ | PodTemplateMetadataOverrides defines metadata overrides for the pod template | | Optional: \{\} <br /> | +| `podTemplateMetadataOverrides` _[api.v1beta1.ResourceMetadataOverrides](#apiv1beta1resourcemetadataoverrides)_ | PodTemplateMetadataOverrides defines metadata overrides for the pod template | | Optional: \{\} <br /> | -#### api.v1alpha1.EnvVar +#### api.v1beta1.EnvVar @@ -1145,9 +1224,9 @@ EnvVar represents an environment variable in a container _Appears in:_ -- [api.v1alpha1.EmbeddingServerSpec](#apiv1alpha1embeddingserverspec) -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) -- [api.v1alpha1.ProxyDeploymentOverrides](#apiv1alpha1proxydeploymentoverrides) +- [api.v1beta1.EmbeddingServerSpec](#apiv1beta1embeddingserverspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) +- [api.v1beta1.ProxyDeploymentOverrides](#apiv1beta1proxydeploymentoverrides) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -1155,7 +1234,7 @@ _Appears in:_ | `value` _string_ | Value of the environment variable | | Required: \{\} <br /> | -#### api.v1alpha1.ExternalAuthConfigRef +#### api.v1beta1.ExternalAuthConfigRef @@ -1165,17 +1244,17 @@ The referenced MCPExternalAuthConfig must be in the same namespace as the MCPSer _Appears in:_ -- [api.v1alpha1.BackendAuthConfig](#apiv1alpha1backendauthconfig) -- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) -- [api.v1alpha1.MCPServerEntrySpec](#apiv1alpha1mcpserverentryspec) -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.BackendAuthConfig](#apiv1beta1backendauthconfig) +- [api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec) +- [api.v1beta1.MCPServerEntrySpec](#apiv1beta1mcpserverentryspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `name` _string_ | Name is the name of the MCPExternalAuthConfig resource | | Required: \{\} <br /> | -#### api.v1alpha1.ExternalAuthType +#### api.v1beta1.ExternalAuthType _Underlying type:_ _string_ @@ -1184,7 +1263,7 @@ ExternalAuthType represents the type of external authentication _Appears in:_ -- [api.v1alpha1.MCPExternalAuthConfigSpec](#apiv1alpha1mcpexternalauthconfigspec) +- [api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec) | Field | Description | | --- | --- | @@ -1197,7 +1276,7 @@ _Appears in:_ | `upstreamInject` | ExternalAuthTypeUpstreamInject is the type for upstream token injection<br />This injects an upstream IDP access token as the Authorization: Bearer header<br /> | -#### api.v1alpha1.HeaderForwardConfig +#### api.v1beta1.HeaderForwardConfig @@ -1206,16 +1285,16 @@ HeaderForwardConfig defines header forward configuration for remote servers. _Appears in:_ -- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) -- [api.v1alpha1.MCPServerEntrySpec](#apiv1alpha1mcpserverentryspec) +- [api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec) +- [api.v1beta1.MCPServerEntrySpec](#apiv1beta1mcpserverentryspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `addPlaintextHeaders` _object (keys:string, values:string)_ | AddPlaintextHeaders is a map of header names to literal values to inject into requests.<br />WARNING: Values are stored in plaintext and visible via kubectl commands.<br />Use addHeadersFromSecret for sensitive data like API keys or tokens. | | Optional: \{\} <br /> | -| `addHeadersFromSecret` _[api.v1alpha1.HeaderFromSecret](#apiv1alpha1headerfromsecret) array_ | AddHeadersFromSecret references Kubernetes Secrets for sensitive header values. | | Optional: \{\} <br /> | +| `addHeadersFromSecret` _[api.v1beta1.HeaderFromSecret](#apiv1beta1headerfromsecret) array_ | AddHeadersFromSecret references Kubernetes Secrets for sensitive header values. | | Optional: \{\} <br /> | -#### api.v1alpha1.HeaderFromSecret +#### api.v1beta1.HeaderFromSecret @@ -1224,15 +1303,15 @@ HeaderFromSecret defines a header whose value comes from a Kubernetes Secret. _Appears in:_ -- [api.v1alpha1.HeaderForwardConfig](#apiv1alpha1headerforwardconfig) +- [api.v1beta1.HeaderForwardConfig](#apiv1beta1headerforwardconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `headerName` _string_ | HeaderName is the HTTP header name (e.g., "X-API-Key") | | MaxLength: 255 <br />MinLength: 1 <br />Required: \{\} <br /> | -| `valueSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | ValueSecretRef references the Secret and key containing the header value | | Required: \{\} <br /> | +| `valueSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ValueSecretRef references the Secret and key containing the header value | | Required: \{\} <br /> | -#### api.v1alpha1.HeaderInjectionConfig +#### api.v1beta1.HeaderInjectionConfig @@ -1243,15 +1322,15 @@ For security reasons, only secret references are supported (no plaintext values) _Appears in:_ -- [api.v1alpha1.MCPExternalAuthConfigSpec](#apiv1alpha1mcpexternalauthconfigspec) +- [api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `headerName` _string_ | HeaderName is the name of the HTTP header to inject | | MinLength: 1 <br />Required: \{\} <br /> | -| `valueSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | ValueSecretRef references a Kubernetes Secret containing the header value | | Required: \{\} <br /> | +| `valueSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ValueSecretRef references a Kubernetes Secret containing the header value | | Required: \{\} <br /> | -#### api.v1alpha1.IncomingAuthConfig +#### api.v1beta1.IncomingAuthConfig @@ -1260,16 +1339,16 @@ IncomingAuthConfig configures authentication for clients connecting to the Virtu _Appears in:_ -- [api.v1alpha1.VirtualMCPServerSpec](#apiv1alpha1virtualmcpserverspec) +- [api.v1beta1.VirtualMCPServerSpec](#apiv1beta1virtualmcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `type` _string_ | Type defines the authentication type: anonymous or oidc<br />When no authentication is required, explicitly set this to "anonymous" | | Enum: [anonymous oidc] <br />Required: \{\} <br /> | -| `oidcConfigRef` _[api.v1alpha1.MCPOIDCConfigReference](#apiv1alpha1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.<br />The referenced MCPOIDCConfig must exist in the same namespace as this VirtualMCPServer.<br />Per-server overrides (audience, scopes) are specified here; shared provider config<br />lives in the MCPOIDCConfig resource. | | Optional: \{\} <br /> | -| `authzConfig` _[api.v1alpha1.AuthzConfigRef](#apiv1alpha1authzconfigref)_ | AuthzConfig defines authorization policy configuration<br />Reuses MCPServer authz patterns | | Optional: \{\} <br /> | +| `oidcConfigRef` _[api.v1beta1.MCPOIDCConfigReference](#apiv1beta1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.<br />The referenced MCPOIDCConfig must exist in the same namespace as this VirtualMCPServer.<br />Per-server overrides (audience, scopes) are specified here; shared provider config<br />lives in the MCPOIDCConfig resource. | | Optional: \{\} <br /> | +| `authzConfig` _[api.v1beta1.AuthzConfigRef](#apiv1beta1authzconfigref)_ | AuthzConfig defines authorization policy configuration<br />Reuses MCPServer authz patterns | | Optional: \{\} <br /> | -#### api.v1alpha1.InlineAuthzConfig +#### api.v1beta1.InlineAuthzConfig @@ -1278,7 +1357,7 @@ InlineAuthzConfig contains direct authorization configuration _Appears in:_ -- [api.v1alpha1.AuthzConfigRef](#apiv1alpha1authzconfigref) +- [api.v1beta1.AuthzConfigRef](#apiv1beta1authzconfigref) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -1286,7 +1365,7 @@ _Appears in:_ | `entitiesJson` _string_ | EntitiesJSON is a JSON string representing Cedar entities | [] | Optional: \{\} <br /> | -#### api.v1alpha1.InlineOIDCSharedConfig +#### api.v1beta1.InlineOIDCSharedConfig @@ -1297,7 +1376,7 @@ via MCPOIDCConfigReference. _Appears in:_ -- [api.v1alpha1.MCPOIDCConfigSpec](#apiv1alpha1mcpoidcconfigspec) +- [api.v1beta1.MCPOIDCConfigSpec](#apiv1beta1mcpoidcconfigspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -1305,15 +1384,15 @@ _Appears in:_ | `jwksUrl` _string_ | JWKSURL is the URL to fetch the JWKS from | | Optional: \{\} <br /> | | `introspectionUrl` _string_ | IntrospectionURL is the URL for token introspection endpoint | | Optional: \{\} <br /> | | `clientId` _string_ | ClientID is the OIDC client ID | | Optional: \{\} <br /> | -| `clientSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | ClientSecretRef is a reference to a Kubernetes Secret containing the client secret | | Optional: \{\} <br /> | -| `caBundleRef` _[api.v1alpha1.CABundleSource](#apiv1alpha1cabundlesource)_ | CABundleRef references a ConfigMap containing the CA certificate bundle.<br />When specified, ToolHive auto-mounts the ConfigMap and auto-computes ThvCABundlePath. | | Optional: \{\} <br /> | +| `clientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ClientSecretRef is a reference to a Kubernetes Secret containing the client secret | | Optional: \{\} <br /> | +| `caBundleRef` _[api.v1beta1.CABundleSource](#apiv1beta1cabundlesource)_ | CABundleRef references a ConfigMap containing the CA certificate bundle.<br />When specified, ToolHive auto-mounts the ConfigMap and auto-computes ThvCABundlePath. | | Optional: \{\} <br /> | | `jwksAuthTokenPath` _string_ | JWKSAuthTokenPath is the path to file containing bearer token for JWKS/OIDC requests | | Optional: \{\} <br /> | | `jwksAllowPrivateIP` _boolean_ | JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses.<br />Note: at runtime, if either JWKSAllowPrivateIP or ProtectedResourceAllowPrivateIP<br />is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). | false | Optional: \{\} <br /> | | `protectedResourceAllowPrivateIP` _boolean_ | ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses.<br />Note: at runtime, if either ProtectedResourceAllowPrivateIP or JWKSAllowPrivateIP<br />is true, private IPs are allowed for all OIDC HTTP requests (JWKS, discovery, introspection). | false | Optional: \{\} <br /> | | `insecureAllowHTTP` _boolean_ | InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing.<br />WARNING: This is insecure and should NEVER be used in production. | false | Optional: \{\} <br /> | -#### api.v1alpha1.KubernetesServiceAccountOIDCConfig +#### api.v1beta1.KubernetesServiceAccountOIDCConfig @@ -1323,7 +1402,7 @@ This contains shared fields without audience, which is specified per-server via _Appears in:_ -- [api.v1alpha1.MCPOIDCConfigSpec](#apiv1alpha1mcpoidcconfigspec) +- [api.v1beta1.MCPOIDCConfigSpec](#apiv1beta1mcpoidcconfigspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -1335,7 +1414,7 @@ _Appears in:_ | `useClusterAuth` _boolean_ | UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token.<br />When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification<br />and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication.<br />Defaults to true if not specified. | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPExternalAuthConfig +#### api.v1beta1.MCPExternalAuthConfig @@ -1347,20 +1426,20 @@ are not supported for security and isolation reasons. _Appears in:_ -- [api.v1alpha1.MCPExternalAuthConfigList](#apiv1alpha1mcpexternalauthconfiglist) +- [api.v1beta1.MCPExternalAuthConfigList](#apiv1beta1mcpexternalauthconfiglist) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPExternalAuthConfig` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[api.v1alpha1.MCPExternalAuthConfigSpec](#apiv1alpha1mcpexternalauthconfigspec)_ | | | | -| `status` _[api.v1alpha1.MCPExternalAuthConfigStatus](#apiv1alpha1mcpexternalauthconfigstatus)_ | | | | +| `spec` _[api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPExternalAuthConfigStatus](#apiv1beta1mcpexternalauthconfigstatus)_ | | | | -#### api.v1alpha1.MCPExternalAuthConfigList +#### api.v1beta1.MCPExternalAuthConfigList @@ -1372,15 +1451,15 @@ MCPExternalAuthConfigList contains a list of MCPExternalAuthConfig | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPExternalAuthConfigList` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[api.v1alpha1.MCPExternalAuthConfig](#apiv1alpha1mcpexternalauthconfig) array_ | | | | +| `items` _[api.v1beta1.MCPExternalAuthConfig](#apiv1beta1mcpexternalauthconfig) array_ | | | | -#### api.v1alpha1.MCPExternalAuthConfigSpec +#### api.v1beta1.MCPExternalAuthConfigSpec @@ -1391,20 +1470,20 @@ MCPServer resources in the same namespace. _Appears in:_ -- [api.v1alpha1.MCPExternalAuthConfig](#apiv1alpha1mcpexternalauthconfig) +- [api.v1beta1.MCPExternalAuthConfig](#apiv1beta1mcpexternalauthconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `type` _[api.v1alpha1.ExternalAuthType](#apiv1alpha1externalauthtype)_ | Type is the type of external authentication to configure | | Enum: [tokenExchange headerInjection bearerToken unauthenticated embeddedAuthServer awsSts upstreamInject] <br />Required: \{\} <br /> | -| `tokenExchange` _[api.v1alpha1.TokenExchangeConfig](#apiv1alpha1tokenexchangeconfig)_ | TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange<br />Only used when Type is "tokenExchange" | | Optional: \{\} <br /> | -| `headerInjection` _[api.v1alpha1.HeaderInjectionConfig](#apiv1alpha1headerinjectionconfig)_ | HeaderInjection configures custom HTTP header injection<br />Only used when Type is "headerInjection" | | Optional: \{\} <br /> | -| `bearerToken` _[api.v1alpha1.BearerTokenConfig](#apiv1alpha1bearertokenconfig)_ | BearerToken configures bearer token authentication<br />Only used when Type is "bearerToken" | | Optional: \{\} <br /> | -| `embeddedAuthServer` _[api.v1alpha1.EmbeddedAuthServerConfig](#apiv1alpha1embeddedauthserverconfig)_ | EmbeddedAuthServer configures an embedded OAuth2/OIDC authorization server<br />Only used when Type is "embeddedAuthServer" | | Optional: \{\} <br /> | -| `awsSts` _[api.v1alpha1.AWSStsConfig](#apiv1alpha1awsstsconfig)_ | AWSSts configures AWS STS authentication with SigV4 request signing<br />Only used when Type is "awsSts" | | Optional: \{\} <br /> | -| `upstreamInject` _[api.v1alpha1.UpstreamInjectSpec](#apiv1alpha1upstreaminjectspec)_ | UpstreamInject configures upstream token injection for backend requests.<br />Only used when Type is "upstreamInject". | | Optional: \{\} <br /> | +| `type` _[api.v1beta1.ExternalAuthType](#apiv1beta1externalauthtype)_ | Type is the type of external authentication to configure | | Enum: [tokenExchange headerInjection bearerToken unauthenticated embeddedAuthServer awsSts upstreamInject] <br />Required: \{\} <br /> | +| `tokenExchange` _[api.v1beta1.TokenExchangeConfig](#apiv1beta1tokenexchangeconfig)_ | TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange<br />Only used when Type is "tokenExchange" | | Optional: \{\} <br /> | +| `headerInjection` _[api.v1beta1.HeaderInjectionConfig](#apiv1beta1headerinjectionconfig)_ | HeaderInjection configures custom HTTP header injection<br />Only used when Type is "headerInjection" | | Optional: \{\} <br /> | +| `bearerToken` _[api.v1beta1.BearerTokenConfig](#apiv1beta1bearertokenconfig)_ | BearerToken configures bearer token authentication<br />Only used when Type is "bearerToken" | | Optional: \{\} <br /> | +| `embeddedAuthServer` _[api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig)_ | EmbeddedAuthServer configures an embedded OAuth2/OIDC authorization server<br />Only used when Type is "embeddedAuthServer" | | Optional: \{\} <br /> | +| `awsSts` _[api.v1beta1.AWSStsConfig](#apiv1beta1awsstsconfig)_ | AWSSts configures AWS STS authentication with SigV4 request signing<br />Only used when Type is "awsSts" | | Optional: \{\} <br /> | +| `upstreamInject` _[api.v1beta1.UpstreamInjectSpec](#apiv1beta1upstreaminjectspec)_ | UpstreamInject configures upstream token injection for backend requests.<br />Only used when Type is "upstreamInject". | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPExternalAuthConfigStatus +#### api.v1beta1.MCPExternalAuthConfigStatus @@ -1413,17 +1492,17 @@ MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig _Appears in:_ -- [api.v1alpha1.MCPExternalAuthConfig](#apiv1alpha1mcpexternalauthconfig) +- [api.v1beta1.MCPExternalAuthConfig](#apiv1beta1mcpexternalauthconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the MCPExternalAuthConfig's state | | Optional: \{\} <br /> | | `observedGeneration` _integer_ | ObservedGeneration is the most recent generation observed for this MCPExternalAuthConfig.<br />It corresponds to the MCPExternalAuthConfig's generation, which is updated on mutation by the API Server. | | Optional: \{\} <br /> | | `configHash` _string_ | ConfigHash is a hash of the current configuration for change detection | | Optional: \{\} <br /> | -| `referencingWorkloads` _[api.v1alpha1.WorkloadReference](#apiv1alpha1workloadreference) array_ | ReferencingWorkloads is a list of workload resources that reference this MCPExternalAuthConfig.<br />Each entry identifies the workload by kind and name. | | Optional: \{\} <br /> | +| `referencingWorkloads` _[api.v1beta1.WorkloadReference](#apiv1beta1workloadreference) array_ | ReferencingWorkloads is a list of workload resources that reference this MCPExternalAuthConfig.<br />Each entry identifies the workload by kind and name. | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPGroup +#### api.v1beta1.MCPGroup @@ -1432,20 +1511,20 @@ MCPGroup is the Schema for the mcpgroups API _Appears in:_ -- [api.v1alpha1.MCPGroupList](#apiv1alpha1mcpgrouplist) +- [api.v1beta1.MCPGroupList](#apiv1beta1mcpgrouplist) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPGroup` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[api.v1alpha1.MCPGroupSpec](#apiv1alpha1mcpgroupspec)_ | | | | -| `status` _[api.v1alpha1.MCPGroupStatus](#apiv1alpha1mcpgroupstatus)_ | | | | +| `spec` _[api.v1beta1.MCPGroupSpec](#apiv1beta1mcpgroupspec)_ | | | | +| `status` _[api.v1beta1.MCPGroupStatus](#apiv1beta1mcpgroupstatus)_ | | | | -#### api.v1alpha1.MCPGroupList +#### api.v1beta1.MCPGroupList @@ -1457,15 +1536,15 @@ MCPGroupList contains a list of MCPGroup | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPGroupList` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[api.v1alpha1.MCPGroup](#apiv1alpha1mcpgroup) array_ | | | | +| `items` _[api.v1beta1.MCPGroup](#apiv1beta1mcpgroup) array_ | | | | -#### api.v1alpha1.MCPGroupPhase +#### api.v1beta1.MCPGroupPhase _Underlying type:_ _string_ @@ -1475,7 +1554,7 @@ _Validation:_ - Enum: [Ready Pending Failed] _Appears in:_ -- [api.v1alpha1.MCPGroupStatus](#apiv1alpha1mcpgroupstatus) +- [api.v1beta1.MCPGroupStatus](#apiv1beta1mcpgroupstatus) | Field | Description | | --- | --- | @@ -1484,7 +1563,7 @@ _Appears in:_ | `Failed` | MCPGroupPhaseFailed indicates the MCPGroup has failed<br /> | -#### api.v1alpha1.MCPGroupRef +#### api.v1beta1.MCPGroupRef @@ -1494,17 +1573,17 @@ The referenced MCPGroup must be in the same namespace. _Appears in:_ -- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) -- [api.v1alpha1.MCPServerEntrySpec](#apiv1alpha1mcpserverentryspec) -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) -- [api.v1alpha1.VirtualMCPServerSpec](#apiv1alpha1virtualmcpserverspec) +- [api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec) +- [api.v1beta1.MCPServerEntrySpec](#apiv1beta1mcpserverentryspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) +- [api.v1beta1.VirtualMCPServerSpec](#apiv1beta1virtualmcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `name` _string_ | Name is the name of the MCPGroup resource in the same namespace | | MinLength: 1 <br />Required: \{\} <br /> | -#### api.v1alpha1.MCPGroupSpec +#### api.v1beta1.MCPGroupSpec @@ -1513,14 +1592,14 @@ MCPGroupSpec defines the desired state of MCPGroup _Appears in:_ -- [api.v1alpha1.MCPGroup](#apiv1alpha1mcpgroup) +- [api.v1beta1.MCPGroup](#apiv1beta1mcpgroup) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `description` _string_ | Description provides human-readable context | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPGroupStatus +#### api.v1beta1.MCPGroupStatus @@ -1529,12 +1608,12 @@ MCPGroupStatus defines observed state _Appears in:_ -- [api.v1alpha1.MCPGroup](#apiv1alpha1mcpgroup) +- [api.v1beta1.MCPGroup](#apiv1beta1mcpgroup) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `observedGeneration` _integer_ | ObservedGeneration reflects the generation most recently observed by the controller | | Optional: \{\} <br /> | -| `phase` _[api.v1alpha1.MCPGroupPhase](#apiv1alpha1mcpgroupphase)_ | Phase indicates current state | Pending | Enum: [Ready Pending Failed] <br />Optional: \{\} <br /> | +| `phase` _[api.v1beta1.MCPGroupPhase](#apiv1beta1mcpgroupphase)_ | Phase indicates current state | Pending | Enum: [Ready Pending Failed] <br />Optional: \{\} <br /> | | `servers` _string array_ | Servers lists MCPServer names in this group | | Optional: \{\} <br /> | | `serverCount` _integer_ | ServerCount is the number of MCPServers | | Optional: \{\} <br /> | | `remoteProxies` _string array_ | RemoteProxies lists MCPRemoteProxy names in this group | | Optional: \{\} <br /> | @@ -1544,7 +1623,7 @@ _Appears in:_ | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent observations | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPOIDCConfig +#### api.v1beta1.MCPOIDCConfig @@ -1556,20 +1635,20 @@ are not supported for security and isolation reasons. _Appears in:_ -- [api.v1alpha1.MCPOIDCConfigList](#apiv1alpha1mcpoidcconfiglist) +- [api.v1beta1.MCPOIDCConfigList](#apiv1beta1mcpoidcconfiglist) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPOIDCConfig` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[api.v1alpha1.MCPOIDCConfigSpec](#apiv1alpha1mcpoidcconfigspec)_ | | | | -| `status` _[api.v1alpha1.MCPOIDCConfigStatus](#apiv1alpha1mcpoidcconfigstatus)_ | | | | +| `spec` _[api.v1beta1.MCPOIDCConfigSpec](#apiv1beta1mcpoidcconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPOIDCConfigStatus](#apiv1beta1mcpoidcconfigstatus)_ | | | | -#### api.v1alpha1.MCPOIDCConfigList +#### api.v1beta1.MCPOIDCConfigList @@ -1581,15 +1660,15 @@ MCPOIDCConfigList contains a list of MCPOIDCConfig | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPOIDCConfigList` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[api.v1alpha1.MCPOIDCConfig](#apiv1alpha1mcpoidcconfig) array_ | | | | +| `items` _[api.v1beta1.MCPOIDCConfig](#apiv1beta1mcpoidcconfig) array_ | | | | -#### api.v1alpha1.MCPOIDCConfigReference +#### api.v1beta1.MCPOIDCConfigReference @@ -1599,9 +1678,9 @@ The referenced MCPOIDCConfig must be in the same namespace as the MCPServer. _Appears in:_ -- [api.v1alpha1.IncomingAuthConfig](#apiv1alpha1incomingauthconfig) -- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.IncomingAuthConfig](#apiv1beta1incomingauthconfig) +- [api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -1611,7 +1690,7 @@ _Appears in:_ | `resourceUrl` _string_ | ResourceURL is the public URL for OAuth protected resource metadata (RFC 9728).<br />When the server is exposed via Ingress or gateway, set this to the external<br />URL that MCP clients connect to. If not specified, defaults to the internal<br />Kubernetes service URL. | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPOIDCConfigSourceType +#### api.v1beta1.MCPOIDCConfigSourceType _Underlying type:_ _string_ @@ -1620,7 +1699,7 @@ MCPOIDCConfigSourceType represents the type of OIDC configuration source for MCP _Appears in:_ -- [api.v1alpha1.MCPOIDCConfigSpec](#apiv1alpha1mcpoidcconfigspec) +- [api.v1beta1.MCPOIDCConfigSpec](#apiv1beta1mcpoidcconfigspec) | Field | Description | | --- | --- | @@ -1628,7 +1707,7 @@ _Appears in:_ | `inline` | MCPOIDCConfigTypeInline is the type for inline OIDC configuration<br /> | -#### api.v1alpha1.MCPOIDCConfigSpec +#### api.v1beta1.MCPOIDCConfigSpec @@ -1639,16 +1718,16 @@ MCPServer resources in the same namespace. _Appears in:_ -- [api.v1alpha1.MCPOIDCConfig](#apiv1alpha1mcpoidcconfig) +- [api.v1beta1.MCPOIDCConfig](#apiv1beta1mcpoidcconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `type` _[api.v1alpha1.MCPOIDCConfigSourceType](#apiv1alpha1mcpoidcconfigsourcetype)_ | Type is the type of OIDC configuration source | | Enum: [kubernetesServiceAccount inline] <br />Required: \{\} <br /> | -| `kubernetesServiceAccount` _[api.v1alpha1.KubernetesServiceAccountOIDCConfig](#apiv1alpha1kubernetesserviceaccountoidcconfig)_ | KubernetesServiceAccount configures OIDC for Kubernetes service account token validation.<br />Only used when Type is "kubernetesServiceAccount". | | Optional: \{\} <br /> | -| `inline` _[api.v1alpha1.InlineOIDCSharedConfig](#apiv1alpha1inlineoidcsharedconfig)_ | Inline contains direct OIDC configuration.<br />Only used when Type is "inline". | | Optional: \{\} <br /> | +| `type` _[api.v1beta1.MCPOIDCConfigSourceType](#apiv1beta1mcpoidcconfigsourcetype)_ | Type is the type of OIDC configuration source | | Enum: [kubernetesServiceAccount inline] <br />Required: \{\} <br /> | +| `kubernetesServiceAccount` _[api.v1beta1.KubernetesServiceAccountOIDCConfig](#apiv1beta1kubernetesserviceaccountoidcconfig)_ | KubernetesServiceAccount configures OIDC for Kubernetes service account token validation.<br />Only used when Type is "kubernetesServiceAccount". | | Optional: \{\} <br /> | +| `inline` _[api.v1beta1.InlineOIDCSharedConfig](#apiv1beta1inlineoidcsharedconfig)_ | Inline contains direct OIDC configuration.<br />Only used when Type is "inline". | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPOIDCConfigStatus +#### api.v1beta1.MCPOIDCConfigStatus @@ -1657,17 +1736,17 @@ MCPOIDCConfigStatus defines the observed state of MCPOIDCConfig _Appears in:_ -- [api.v1alpha1.MCPOIDCConfig](#apiv1alpha1mcpoidcconfig) +- [api.v1beta1.MCPOIDCConfig](#apiv1beta1mcpoidcconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the MCPOIDCConfig's state | | Optional: \{\} <br /> | | `observedGeneration` _integer_ | ObservedGeneration is the most recent generation observed for this MCPOIDCConfig. | | Optional: \{\} <br /> | | `configHash` _string_ | ConfigHash is a hash of the current configuration for change detection | | Optional: \{\} <br /> | -| `referencingWorkloads` _[api.v1alpha1.WorkloadReference](#apiv1alpha1workloadreference) array_ | ReferencingWorkloads is a list of workload resources that reference this MCPOIDCConfig.<br />Each entry identifies the workload by kind and name. | | Optional: \{\} <br /> | +| `referencingWorkloads` _[api.v1beta1.WorkloadReference](#apiv1beta1workloadreference) array_ | ReferencingWorkloads is a list of workload resources that reference this MCPOIDCConfig.<br />Each entry identifies the workload by kind and name. | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPRegistry +#### api.v1beta1.MCPRegistry @@ -1676,20 +1755,20 @@ MCPRegistry is the Schema for the mcpregistries API _Appears in:_ -- [api.v1alpha1.MCPRegistryList](#apiv1alpha1mcpregistrylist) +- [api.v1beta1.MCPRegistryList](#apiv1beta1mcpregistrylist) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPRegistry` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[api.v1alpha1.MCPRegistrySpec](#apiv1alpha1mcpregistryspec)_ | | | | -| `status` _[api.v1alpha1.MCPRegistryStatus](#apiv1alpha1mcpregistrystatus)_ | | | | +| `spec` _[api.v1beta1.MCPRegistrySpec](#apiv1beta1mcpregistryspec)_ | | | | +| `status` _[api.v1beta1.MCPRegistryStatus](#apiv1beta1mcpregistrystatus)_ | | | | -#### api.v1alpha1.MCPRegistryList +#### api.v1beta1.MCPRegistryList @@ -1701,15 +1780,15 @@ MCPRegistryList contains a list of MCPRegistry | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPRegistryList` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[api.v1alpha1.MCPRegistry](#apiv1alpha1mcpregistry) array_ | | | | +| `items` _[api.v1beta1.MCPRegistry](#apiv1beta1mcpregistry) array_ | | | | -#### api.v1alpha1.MCPRegistryPhase +#### api.v1beta1.MCPRegistryPhase _Underlying type:_ _string_ @@ -1719,7 +1798,7 @@ _Validation:_ - Enum: [Pending Ready Failed Terminating] _Appears in:_ -- [api.v1alpha1.MCPRegistryStatus](#apiv1alpha1mcpregistrystatus) +- [api.v1beta1.MCPRegistryStatus](#apiv1beta1mcpregistrystatus) | Field | Description | | --- | --- | @@ -1729,7 +1808,7 @@ _Appears in:_ | `Terminating` | MCPRegistryPhaseTerminating means the MCPRegistry is being deleted<br /> | -#### api.v1alpha1.MCPRegistrySpec +#### api.v1beta1.MCPRegistrySpec @@ -1738,7 +1817,7 @@ MCPRegistrySpec defines the desired state of MCPRegistry _Appears in:_ -- [api.v1alpha1.MCPRegistry](#apiv1alpha1mcpregistry) +- [api.v1beta1.MCPRegistry](#apiv1beta1mcpregistry) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -1750,7 +1829,7 @@ _Appears in:_ | `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the registry API server.<br />This allows for customizing the pod configuration beyond what is provided by the other fields.<br />Note that to modify the specific container the registry API server runs in, you must specify<br />the `registry-api` container name in the PodTemplateSpec.<br />This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object <br />Optional: \{\} <br /> | -#### api.v1alpha1.MCPRegistryStatus +#### api.v1beta1.MCPRegistryStatus @@ -1759,19 +1838,19 @@ MCPRegistryStatus defines the observed state of MCPRegistry _Appears in:_ -- [api.v1alpha1.MCPRegistry](#apiv1alpha1mcpregistry) +- [api.v1beta1.MCPRegistry](#apiv1beta1mcpregistry) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the MCPRegistry's state | | Optional: \{\} <br /> | | `observedGeneration` _integer_ | ObservedGeneration reflects the generation most recently observed by the controller | | Optional: \{\} <br /> | -| `phase` _[api.v1alpha1.MCPRegistryPhase](#apiv1alpha1mcpregistryphase)_ | Phase represents the current overall phase of the MCPRegistry | | Enum: [Pending Ready Failed Terminating] <br />Optional: \{\} <br /> | +| `phase` _[api.v1beta1.MCPRegistryPhase](#apiv1beta1mcpregistryphase)_ | Phase represents the current overall phase of the MCPRegistry | | Enum: [Pending Ready Failed Terminating] <br />Optional: \{\} <br /> | | `message` _string_ | Message provides additional information about the current phase | | Optional: \{\} <br /> | | `url` _string_ | URL is the URL where the registry API can be accessed | | Optional: \{\} <br /> | | `readyReplicas` _integer_ | ReadyReplicas is the number of ready registry API replicas | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPRemoteProxy +#### api.v1beta1.MCPRemoteProxy @@ -1781,20 +1860,20 @@ It enables proxying remote MCP servers with authentication, authorization, audit _Appears in:_ -- [api.v1alpha1.MCPRemoteProxyList](#apiv1alpha1mcpremoteproxylist) +- [api.v1beta1.MCPRemoteProxyList](#apiv1beta1mcpremoteproxylist) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPRemoteProxy` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec)_ | | | | -| `status` _[api.v1alpha1.MCPRemoteProxyStatus](#apiv1alpha1mcpremoteproxystatus)_ | | | | +| `spec` _[api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec)_ | | | | +| `status` _[api.v1beta1.MCPRemoteProxyStatus](#apiv1beta1mcpremoteproxystatus)_ | | | | -#### api.v1alpha1.MCPRemoteProxyList +#### api.v1beta1.MCPRemoteProxyList @@ -1806,15 +1885,15 @@ MCPRemoteProxyList contains a list of MCPRemoteProxy | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPRemoteProxyList` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[api.v1alpha1.MCPRemoteProxy](#apiv1alpha1mcpremoteproxy) array_ | | | | +| `items` _[api.v1beta1.MCPRemoteProxy](#apiv1beta1mcpremoteproxy) array_ | | | | -#### api.v1alpha1.MCPRemoteProxyPhase +#### api.v1beta1.MCPRemoteProxyPhase _Underlying type:_ _string_ @@ -1824,7 +1903,7 @@ _Validation:_ - Enum: [Pending Ready Failed Terminating] _Appears in:_ -- [api.v1alpha1.MCPRemoteProxyStatus](#apiv1alpha1mcpremoteproxystatus) +- [api.v1beta1.MCPRemoteProxyStatus](#apiv1beta1mcpremoteproxystatus) | Field | Description | | --- | --- | @@ -1834,7 +1913,7 @@ _Appears in:_ | `Terminating` | MCPRemoteProxyPhaseTerminating means the proxy is being deleted<br /> | -#### api.v1alpha1.MCPRemoteProxySpec +#### api.v1beta1.MCPRemoteProxySpec @@ -1843,31 +1922,31 @@ MCPRemoteProxySpec defines the desired state of MCPRemoteProxy _Appears in:_ -- [api.v1alpha1.MCPRemoteProxy](#apiv1alpha1mcpremoteproxy) +- [api.v1beta1.MCPRemoteProxy](#apiv1beta1mcpremoteproxy) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `remoteUrl` _string_ | RemoteURL is the URL of the remote MCP server to proxy | | Pattern: `^https?://` <br />Required: \{\} <br /> | | `proxyPort` _integer_ | ProxyPort is the port to expose the MCP proxy on | 8080 | Maximum: 65535 <br />Minimum: 1 <br /> | | `transport` _string_ | Transport is the transport method for the remote proxy (sse or streamable-http) | streamable-http | Enum: [sse streamable-http] <br /> | -| `oidcConfigRef` _[api.v1alpha1.MCPOIDCConfigReference](#apiv1alpha1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.<br />The referenced MCPOIDCConfig must exist in the same namespace as this MCPRemoteProxy.<br />Per-server overrides (audience, scopes) are specified here; shared provider config<br />lives in the MCPOIDCConfig resource. | | Optional: \{\} <br /> | -| `externalAuthConfigRef` _[api.v1alpha1.ExternalAuthConfigRef](#apiv1alpha1externalauthconfigref)_ | ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange.<br />When specified, the proxy will exchange validated incoming tokens for remote service tokens.<br />The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPRemoteProxy. | | Optional: \{\} <br /> | -| `authServerRef` _[api.v1alpha1.AuthServerRef](#apiv1alpha1authserverref)_ | AuthServerRef optionally references a resource that configures an embedded<br />OAuth 2.0/OIDC authorization server to authenticate MCP clients.<br />Currently the only supported kind is MCPExternalAuthConfig (type: embeddedAuthServer). | | Optional: \{\} <br /> | -| `headerForward` _[api.v1alpha1.HeaderForwardConfig](#apiv1alpha1headerforwardconfig)_ | HeaderForward configures headers to inject into requests to the remote MCP server.<br />Use this to add custom headers like X-Tenant-ID or correlation IDs. | | Optional: \{\} <br /> | -| `authzConfig` _[api.v1alpha1.AuthzConfigRef](#apiv1alpha1authzconfigref)_ | AuthzConfig defines authorization policy configuration for the proxy | | Optional: \{\} <br /> | -| `audit` _[api.v1alpha1.AuditConfig](#apiv1alpha1auditconfig)_ | Audit defines audit logging configuration for the proxy | | Optional: \{\} <br /> | -| `toolConfigRef` _[api.v1alpha1.ToolConfigRef](#apiv1alpha1toolconfigref)_ | ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming.<br />The referenced MCPToolConfig must exist in the same namespace as this MCPRemoteProxy.<br />Cross-namespace references are not supported for security and isolation reasons.<br />If specified, this allows filtering and overriding tools from the remote MCP server. | | Optional: \{\} <br /> | -| `telemetryConfigRef` _[api.v1alpha1.MCPTelemetryConfigReference](#apiv1alpha1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.<br />The referenced MCPTelemetryConfig must exist in the same namespace as this MCPRemoteProxy.<br />Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\} <br /> | -| `resources` _[api.v1alpha1.ResourceRequirements](#apiv1alpha1resourcerequirements)_ | Resources defines the resource requirements for the proxy container | | Optional: \{\} <br /> | +| `oidcConfigRef` _[api.v1beta1.MCPOIDCConfigReference](#apiv1beta1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.<br />The referenced MCPOIDCConfig must exist in the same namespace as this MCPRemoteProxy.<br />Per-server overrides (audience, scopes) are specified here; shared provider config<br />lives in the MCPOIDCConfig resource. | | Optional: \{\} <br /> | +| `externalAuthConfigRef` _[api.v1beta1.ExternalAuthConfigRef](#apiv1beta1externalauthconfigref)_ | ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange.<br />When specified, the proxy will exchange validated incoming tokens for remote service tokens.<br />The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPRemoteProxy. | | Optional: \{\} <br /> | +| `authServerRef` _[api.v1beta1.AuthServerRef](#apiv1beta1authserverref)_ | AuthServerRef optionally references a resource that configures an embedded<br />OAuth 2.0/OIDC authorization server to authenticate MCP clients.<br />Currently the only supported kind is MCPExternalAuthConfig (type: embeddedAuthServer). | | Optional: \{\} <br /> | +| `headerForward` _[api.v1beta1.HeaderForwardConfig](#apiv1beta1headerforwardconfig)_ | HeaderForward configures headers to inject into requests to the remote MCP server.<br />Use this to add custom headers like X-Tenant-ID or correlation IDs. | | Optional: \{\} <br /> | +| `authzConfig` _[api.v1beta1.AuthzConfigRef](#apiv1beta1authzconfigref)_ | AuthzConfig defines authorization policy configuration for the proxy | | Optional: \{\} <br /> | +| `audit` _[api.v1beta1.AuditConfig](#apiv1beta1auditconfig)_ | Audit defines audit logging configuration for the proxy | | Optional: \{\} <br /> | +| `toolConfigRef` _[api.v1beta1.ToolConfigRef](#apiv1beta1toolconfigref)_ | ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming.<br />The referenced MCPToolConfig must exist in the same namespace as this MCPRemoteProxy.<br />Cross-namespace references are not supported for security and isolation reasons.<br />If specified, this allows filtering and overriding tools from the remote MCP server. | | Optional: \{\} <br /> | +| `telemetryConfigRef` _[api.v1beta1.MCPTelemetryConfigReference](#apiv1beta1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.<br />The referenced MCPTelemetryConfig must exist in the same namespace as this MCPRemoteProxy.<br />Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\} <br /> | +| `resources` _[api.v1beta1.ResourceRequirements](#apiv1beta1resourcerequirements)_ | Resources defines the resource requirements for the proxy container | | Optional: \{\} <br /> | | `serviceAccount` _string_ | ServiceAccount is the name of an already existing service account to use by the proxy.<br />If not specified, a ServiceAccount will be created automatically and used by the proxy. | | Optional: \{\} <br /> | | `trustProxyHeaders` _boolean_ | TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies<br />When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port,<br />and X-Forwarded-Prefix headers to construct endpoint URLs | false | Optional: \{\} <br /> | | `endpointPrefix` _string_ | EndpointPrefix is the path prefix to prepend to SSE endpoint URLs.<br />This is used to handle path-based ingress routing scenarios where the ingress<br />strips a path prefix before forwarding to the backend. | | Optional: \{\} <br /> | -| `resourceOverrides` _[api.v1alpha1.ResourceOverrides](#apiv1alpha1resourceoverrides)_ | ResourceOverrides allows overriding annotations and labels for resources created by the operator | | Optional: \{\} <br /> | -| `groupRef` _[api.v1alpha1.MCPGroupRef](#apiv1alpha1mcpgroupref)_ | GroupRef references the MCPGroup this proxy belongs to.<br />The referenced MCPGroup must be in the same namespace. | | Optional: \{\} <br /> | +| `resourceOverrides` _[api.v1beta1.ResourceOverrides](#apiv1beta1resourceoverrides)_ | ResourceOverrides allows overriding annotations and labels for resources created by the operator | | Optional: \{\} <br /> | +| `groupRef` _[api.v1beta1.MCPGroupRef](#apiv1beta1mcpgroupref)_ | GroupRef references the MCPGroup this proxy belongs to.<br />The referenced MCPGroup must be in the same namespace. | | Optional: \{\} <br /> | | `sessionAffinity` _string_ | SessionAffinity controls whether the Service routes repeated client connections to the same pod.<br />MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default.<br />Set to "None" for stateless servers or when using an external load balancer with its own affinity. | ClientIP | Enum: [ClientIP None] <br />Optional: \{\} <br /> | -#### api.v1alpha1.MCPRemoteProxyStatus +#### api.v1beta1.MCPRemoteProxyStatus @@ -1876,11 +1955,11 @@ MCPRemoteProxyStatus defines the observed state of MCPRemoteProxy _Appears in:_ -- [api.v1alpha1.MCPRemoteProxy](#apiv1alpha1mcpremoteproxy) +- [api.v1beta1.MCPRemoteProxy](#apiv1beta1mcpremoteproxy) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `phase` _[api.v1alpha1.MCPRemoteProxyPhase](#apiv1alpha1mcpremoteproxyphase)_ | Phase is the current phase of the MCPRemoteProxy | | Enum: [Pending Ready Failed Terminating] <br />Optional: \{\} <br /> | +| `phase` _[api.v1beta1.MCPRemoteProxyPhase](#apiv1beta1mcpremoteproxyphase)_ | Phase is the current phase of the MCPRemoteProxy | | Enum: [Pending Ready Failed Terminating] <br />Optional: \{\} <br /> | | `url` _string_ | URL is the internal cluster URL where the proxy can be accessed | | Optional: \{\} <br /> | | `externalUrl` _string_ | ExternalURL is the external URL where the proxy can be accessed (if exposed externally) | | Optional: \{\} <br /> | | `observedGeneration` _integer_ | ObservedGeneration reflects the generation of the most recently observed MCPRemoteProxy | | Optional: \{\} <br /> | @@ -1893,7 +1972,7 @@ _Appears in:_ | `message` _string_ | Message provides additional information about the current phase | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPServer +#### api.v1beta1.MCPServer @@ -1902,20 +1981,20 @@ MCPServer is the Schema for the mcpservers API _Appears in:_ -- [api.v1alpha1.MCPServerList](#apiv1alpha1mcpserverlist) +- [api.v1beta1.MCPServerList](#apiv1beta1mcpserverlist) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPServer` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec)_ | | | | -| `status` _[api.v1alpha1.MCPServerStatus](#apiv1alpha1mcpserverstatus)_ | | | | +| `spec` _[api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec)_ | | | | +| `status` _[api.v1beta1.MCPServerStatus](#apiv1beta1mcpserverstatus)_ | | | | -#### api.v1alpha1.MCPServerEntry +#### api.v1beta1.MCPServerEntry @@ -1926,20 +2005,20 @@ without deploying any infrastructure. _Appears in:_ -- [api.v1alpha1.MCPServerEntryList](#apiv1alpha1mcpserverentrylist) +- [api.v1beta1.MCPServerEntryList](#apiv1beta1mcpserverentrylist) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPServerEntry` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[api.v1alpha1.MCPServerEntrySpec](#apiv1alpha1mcpserverentryspec)_ | | | | -| `status` _[api.v1alpha1.MCPServerEntryStatus](#apiv1alpha1mcpserverentrystatus)_ | | | | +| `spec` _[api.v1beta1.MCPServerEntrySpec](#apiv1beta1mcpserverentryspec)_ | | | | +| `status` _[api.v1beta1.MCPServerEntryStatus](#apiv1beta1mcpserverentrystatus)_ | | | | -#### api.v1alpha1.MCPServerEntryList +#### api.v1beta1.MCPServerEntryList @@ -1951,15 +2030,15 @@ MCPServerEntryList contains a list of MCPServerEntry. | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPServerEntryList` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[api.v1alpha1.MCPServerEntry](#apiv1alpha1mcpserverentry) array_ | | | | +| `items` _[api.v1beta1.MCPServerEntry](#apiv1beta1mcpserverentry) array_ | | | | -#### api.v1alpha1.MCPServerEntryPhase +#### api.v1beta1.MCPServerEntryPhase _Underlying type:_ _string_ @@ -1969,7 +2048,7 @@ _Validation:_ - Enum: [Valid Pending Failed] _Appears in:_ -- [api.v1alpha1.MCPServerEntryStatus](#apiv1alpha1mcpserverentrystatus) +- [api.v1beta1.MCPServerEntryStatus](#apiv1beta1mcpserverentrystatus) | Field | Description | | --- | --- | @@ -1978,7 +2057,7 @@ _Appears in:_ | `Failed` | MCPServerEntryPhaseFailed indicates one or more referenced resources are missing or invalid.<br /> | -#### api.v1alpha1.MCPServerEntrySpec +#### api.v1beta1.MCPServerEntrySpec @@ -1989,19 +2068,19 @@ server endpoint. Unlike MCPRemoteProxy, it creates no pods, services, or deploym _Appears in:_ -- [api.v1alpha1.MCPServerEntry](#apiv1alpha1mcpserverentry) +- [api.v1beta1.MCPServerEntry](#apiv1beta1mcpserverentry) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `remoteUrl` _string_ | RemoteURL is the URL of the remote MCP server.<br />Both HTTP and HTTPS schemes are accepted at admission time. | | Pattern: `^https?://` <br />Required: \{\} <br /> | | `transport` _string_ | Transport is the transport method for the remote server (sse or streamable-http).<br />No default is set (unlike MCPRemoteProxy) because MCPServerEntry points at external<br />servers the user doesn't control — requiring explicit transport avoids silent mismatches. | | Enum: [sse streamable-http] <br />Required: \{\} <br /> | -| `groupRef` _[api.v1alpha1.MCPGroupRef](#apiv1alpha1mcpgroupref)_ | GroupRef references the MCPGroup this entry belongs to.<br />Required — every MCPServerEntry must be part of a group for vMCP discovery. | | Required: \{\} <br /> | -| `externalAuthConfigRef` _[api.v1alpha1.ExternalAuthConfigRef](#apiv1alpha1externalauthconfigref)_ | ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange<br />when connecting to the remote MCP server. The referenced MCPExternalAuthConfig must<br />exist in the same namespace as this MCPServerEntry. | | Optional: \{\} <br /> | -| `headerForward` _[api.v1alpha1.HeaderForwardConfig](#apiv1alpha1headerforwardconfig)_ | HeaderForward configures headers to inject into requests to the remote MCP server.<br />Use this to add custom headers like API keys or correlation IDs. | | Optional: \{\} <br /> | -| `caBundleRef` _[api.v1alpha1.CABundleSource](#apiv1alpha1cabundlesource)_ | CABundleRef references a ConfigMap containing CA certificates for TLS verification<br />when connecting to the remote MCP server. | | Optional: \{\} <br /> | +| `groupRef` _[api.v1beta1.MCPGroupRef](#apiv1beta1mcpgroupref)_ | GroupRef references the MCPGroup this entry belongs to.<br />Required — every MCPServerEntry must be part of a group for vMCP discovery. | | Required: \{\} <br /> | +| `externalAuthConfigRef` _[api.v1beta1.ExternalAuthConfigRef](#apiv1beta1externalauthconfigref)_ | ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange<br />when connecting to the remote MCP server. The referenced MCPExternalAuthConfig must<br />exist in the same namespace as this MCPServerEntry. | | Optional: \{\} <br /> | +| `headerForward` _[api.v1beta1.HeaderForwardConfig](#apiv1beta1headerforwardconfig)_ | HeaderForward configures headers to inject into requests to the remote MCP server.<br />Use this to add custom headers like API keys or correlation IDs. | | Optional: \{\} <br /> | +| `caBundleRef` _[api.v1beta1.CABundleSource](#apiv1beta1cabundlesource)_ | CABundleRef references a ConfigMap containing CA certificates for TLS verification<br />when connecting to the remote MCP server. | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPServerEntryStatus +#### api.v1beta1.MCPServerEntryStatus @@ -2010,16 +2089,16 @@ MCPServerEntryStatus defines the observed state of MCPServerEntry. _Appears in:_ -- [api.v1alpha1.MCPServerEntry](#apiv1alpha1mcpserverentry) +- [api.v1beta1.MCPServerEntry](#apiv1beta1mcpserverentry) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `observedGeneration` _integer_ | ObservedGeneration reflects the generation most recently observed by the controller. | | Optional: \{\} <br /> | -| `phase` _[api.v1alpha1.MCPServerEntryPhase](#apiv1alpha1mcpserverentryphase)_ | Phase indicates the current lifecycle phase of the MCPServerEntry. | Pending | Enum: [Valid Pending Failed] <br />Optional: \{\} <br /> | +| `phase` _[api.v1beta1.MCPServerEntryPhase](#apiv1beta1mcpserverentryphase)_ | Phase indicates the current lifecycle phase of the MCPServerEntry. | Pending | Enum: [Valid Pending Failed] <br />Optional: \{\} <br /> | | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the MCPServerEntry's state. | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPServerList +#### api.v1beta1.MCPServerList @@ -2031,15 +2110,15 @@ MCPServerList contains a list of MCPServer | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPServerList` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[api.v1alpha1.MCPServer](#apiv1alpha1mcpserver) array_ | | | | +| `items` _[api.v1beta1.MCPServer](#apiv1beta1mcpserver) array_ | | | | -#### api.v1alpha1.MCPServerPhase +#### api.v1beta1.MCPServerPhase _Underlying type:_ _string_ @@ -2049,7 +2128,7 @@ _Validation:_ - Enum: [Pending Ready Failed Terminating Stopped] _Appears in:_ -- [api.v1alpha1.MCPServerStatus](#apiv1alpha1mcpserverstatus) +- [api.v1beta1.MCPServerStatus](#apiv1beta1mcpserverstatus) | Field | Description | | --- | --- | @@ -2060,7 +2139,7 @@ _Appears in:_ | `Stopped` | MCPServerPhaseStopped means the MCPServer is scaled to zero<br /> | -#### api.v1alpha1.MCPServerSpec +#### api.v1beta1.MCPServerSpec @@ -2069,7 +2148,7 @@ MCPServerSpec defines the desired state of MCPServer _Appears in:_ -- [api.v1alpha1.MCPServer](#apiv1alpha1mcpserver) +- [api.v1beta1.MCPServer](#apiv1beta1mcpserver) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2079,32 +2158,32 @@ _Appears in:_ | `proxyPort` _integer_ | ProxyPort is the port to expose the proxy runner on | 8080 | Maximum: 65535 <br />Minimum: 1 <br /> | | `mcpPort` _integer_ | MCPPort is the port that MCP server listens to | | Maximum: 65535 <br />Minimum: 1 <br />Optional: \{\} <br /> | | `args` _string array_ | Args are additional arguments to pass to the MCP server | | Optional: \{\} <br /> | -| `env` _[api.v1alpha1.EnvVar](#apiv1alpha1envvar) array_ | Env are environment variables to set in the MCP server container | | Optional: \{\} <br /> | -| `volumes` _[api.v1alpha1.Volume](#apiv1alpha1volume) array_ | Volumes are volumes to mount in the MCP server container | | Optional: \{\} <br /> | -| `resources` _[api.v1alpha1.ResourceRequirements](#apiv1alpha1resourcerequirements)_ | Resources defines the resource requirements for the MCP server container | | Optional: \{\} <br /> | -| `secrets` _[api.v1alpha1.SecretRef](#apiv1alpha1secretref) array_ | Secrets are references to secrets to mount in the MCP server container | | Optional: \{\} <br /> | +| `env` _[api.v1beta1.EnvVar](#apiv1beta1envvar) array_ | Env are environment variables to set in the MCP server container | | Optional: \{\} <br /> | +| `volumes` _[api.v1beta1.Volume](#apiv1beta1volume) array_ | Volumes are volumes to mount in the MCP server container | | Optional: \{\} <br /> | +| `resources` _[api.v1beta1.ResourceRequirements](#apiv1beta1resourcerequirements)_ | Resources defines the resource requirements for the MCP server container | | Optional: \{\} <br /> | +| `secrets` _[api.v1beta1.SecretRef](#apiv1beta1secretref) array_ | Secrets are references to secrets to mount in the MCP server container | | Optional: \{\} <br /> | | `serviceAccount` _string_ | ServiceAccount is the name of an already existing service account to use by the MCP server.<br />If not specified, a ServiceAccount will be created automatically and used by the MCP server. | | Optional: \{\} <br /> | -| `permissionProfile` _[api.v1alpha1.PermissionProfileRef](#apiv1alpha1permissionprofileref)_ | PermissionProfile defines the permission profile to use | | Optional: \{\} <br /> | +| `permissionProfile` _[api.v1beta1.PermissionProfileRef](#apiv1beta1permissionprofileref)_ | PermissionProfile defines the permission profile to use | | Optional: \{\} <br /> | | `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the MCP server<br />This allows for customizing the pod configuration beyond what is provided by the other fields.<br />Note that to modify the specific container the MCP server runs in, you must specify<br />the `mcp` container name in the PodTemplateSpec.<br />This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object <br />Optional: \{\} <br /> | -| `resourceOverrides` _[api.v1alpha1.ResourceOverrides](#apiv1alpha1resourceoverrides)_ | ResourceOverrides allows overriding annotations and labels for resources created by the operator | | Optional: \{\} <br /> | -| `oidcConfigRef` _[api.v1alpha1.MCPOIDCConfigReference](#apiv1alpha1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.<br />The referenced MCPOIDCConfig must exist in the same namespace as this MCPServer.<br />Per-server overrides (audience, scopes) are specified here; shared provider config<br />lives in the MCPOIDCConfig resource. | | Optional: \{\} <br /> | -| `authzConfig` _[api.v1alpha1.AuthzConfigRef](#apiv1alpha1authzconfigref)_ | AuthzConfig defines authorization policy configuration for the MCP server | | Optional: \{\} <br /> | -| `audit` _[api.v1alpha1.AuditConfig](#apiv1alpha1auditconfig)_ | Audit defines audit logging configuration for the MCP server | | Optional: \{\} <br /> | -| `toolConfigRef` _[api.v1alpha1.ToolConfigRef](#apiv1alpha1toolconfigref)_ | ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming.<br />The referenced MCPToolConfig must exist in the same namespace as this MCPServer.<br />Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\} <br /> | -| `externalAuthConfigRef` _[api.v1alpha1.ExternalAuthConfigRef](#apiv1alpha1externalauthconfigref)_ | ExternalAuthConfigRef references a MCPExternalAuthConfig resource for external authentication.<br />The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPServer. | | Optional: \{\} <br /> | -| `authServerRef` _[api.v1alpha1.AuthServerRef](#apiv1alpha1authserverref)_ | AuthServerRef optionally references a resource that configures an embedded<br />OAuth 2.0/OIDC authorization server to authenticate MCP clients.<br />Currently the only supported kind is MCPExternalAuthConfig (type: embeddedAuthServer). | | Optional: \{\} <br /> | -| `telemetryConfigRef` _[api.v1alpha1.MCPTelemetryConfigReference](#apiv1alpha1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.<br />The referenced MCPTelemetryConfig must exist in the same namespace as this MCPServer.<br />Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\} <br /> | +| `resourceOverrides` _[api.v1beta1.ResourceOverrides](#apiv1beta1resourceoverrides)_ | ResourceOverrides allows overriding annotations and labels for resources created by the operator | | Optional: \{\} <br /> | +| `oidcConfigRef` _[api.v1beta1.MCPOIDCConfigReference](#apiv1beta1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.<br />The referenced MCPOIDCConfig must exist in the same namespace as this MCPServer.<br />Per-server overrides (audience, scopes) are specified here; shared provider config<br />lives in the MCPOIDCConfig resource. | | Optional: \{\} <br /> | +| `authzConfig` _[api.v1beta1.AuthzConfigRef](#apiv1beta1authzconfigref)_ | AuthzConfig defines authorization policy configuration for the MCP server | | Optional: \{\} <br /> | +| `audit` _[api.v1beta1.AuditConfig](#apiv1beta1auditconfig)_ | Audit defines audit logging configuration for the MCP server | | Optional: \{\} <br /> | +| `toolConfigRef` _[api.v1beta1.ToolConfigRef](#apiv1beta1toolconfigref)_ | ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming.<br />The referenced MCPToolConfig must exist in the same namespace as this MCPServer.<br />Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\} <br /> | +| `externalAuthConfigRef` _[api.v1beta1.ExternalAuthConfigRef](#apiv1beta1externalauthconfigref)_ | ExternalAuthConfigRef references a MCPExternalAuthConfig resource for external authentication.<br />The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPServer. | | Optional: \{\} <br /> | +| `authServerRef` _[api.v1beta1.AuthServerRef](#apiv1beta1authserverref)_ | AuthServerRef optionally references a resource that configures an embedded<br />OAuth 2.0/OIDC authorization server to authenticate MCP clients.<br />Currently the only supported kind is MCPExternalAuthConfig (type: embeddedAuthServer). | | Optional: \{\} <br /> | +| `telemetryConfigRef` _[api.v1beta1.MCPTelemetryConfigReference](#apiv1beta1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.<br />The referenced MCPTelemetryConfig must exist in the same namespace as this MCPServer.<br />Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\} <br /> | | `trustProxyHeaders` _boolean_ | TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies<br />When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port,<br />and X-Forwarded-Prefix headers to construct endpoint URLs | false | Optional: \{\} <br /> | | `endpointPrefix` _string_ | EndpointPrefix is the path prefix to prepend to SSE endpoint URLs.<br />This is used to handle path-based ingress routing scenarios where the ingress<br />strips a path prefix before forwarding to the backend. | | Optional: \{\} <br /> | -| `groupRef` _[api.v1alpha1.MCPGroupRef](#apiv1alpha1mcpgroupref)_ | GroupRef references the MCPGroup this server belongs to.<br />The referenced MCPGroup must be in the same namespace. | | Optional: \{\} <br /> | +| `groupRef` _[api.v1beta1.MCPGroupRef](#apiv1beta1mcpgroupref)_ | GroupRef references the MCPGroup this server belongs to.<br />The referenced MCPGroup must be in the same namespace. | | Optional: \{\} <br /> | | `sessionAffinity` _string_ | SessionAffinity controls whether the Service routes repeated client connections to the same pod.<br />MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default.<br />Set to "None" for stateless servers or when using an external load balancer with its own affinity. | ClientIP | Enum: [ClientIP None] <br />Optional: \{\} <br /> | | `replicas` _integer_ | Replicas is the desired number of proxy runner (thv run) pod replicas.<br />MCPServer creates two separate Deployments: one for the proxy runner and one<br />for the MCP server backend. This field controls the proxy runner Deployment.<br />When nil, the operator does not set Deployment.Spec.Replicas, leaving replica<br />management to an HPA or other external controller. | | Minimum: 0 <br />Optional: \{\} <br /> | | `backendReplicas` _integer_ | BackendReplicas is the desired number of MCP server backend pod replicas.<br />This controls the backend Deployment (the MCP server container itself),<br />independent of the proxy runner controlled by Replicas.<br />When nil, the operator does not set Deployment.Spec.Replicas, leaving replica<br />management to an HPA or other external controller. | | Minimum: 0 <br />Optional: \{\} <br /> | -| `sessionStorage` _[api.v1alpha1.SessionStorageConfig](#apiv1alpha1sessionstorageconfig)_ | SessionStorage configures session storage for stateful horizontal scaling.<br />When nil, no session storage is configured. | | Optional: \{\} <br /> | -| `rateLimiting` _[api.v1alpha1.RateLimitConfig](#apiv1alpha1ratelimitconfig)_ | RateLimiting defines rate limiting configuration for the MCP server.<br />Requires Redis session storage to be configured for distributed rate limiting. | | Optional: \{\} <br /> | +| `sessionStorage` _[api.v1beta1.SessionStorageConfig](#apiv1beta1sessionstorageconfig)_ | SessionStorage configures session storage for stateful horizontal scaling.<br />When nil, no session storage is configured. | | Optional: \{\} <br /> | +| `rateLimiting` _[api.v1beta1.RateLimitConfig](#apiv1beta1ratelimitconfig)_ | RateLimiting defines rate limiting configuration for the MCP server.<br />Requires Redis session storage to be configured for distributed rate limiting. | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPServerStatus +#### api.v1beta1.MCPServerStatus @@ -2113,7 +2192,7 @@ MCPServerStatus defines the observed state of MCPServer _Appears in:_ -- [api.v1alpha1.MCPServer](#apiv1alpha1mcpserver) +- [api.v1beta1.MCPServer](#apiv1beta1mcpserver) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2125,12 +2204,12 @@ _Appears in:_ | `oidcConfigHash` _string_ | OIDCConfigHash is the hash of the referenced MCPOIDCConfig spec for change detection | | Optional: \{\} <br /> | | `telemetryConfigHash` _string_ | TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection | | Optional: \{\} <br /> | | `url` _string_ | URL is the URL where the MCP server can be accessed | | Optional: \{\} <br /> | -| `phase` _[api.v1alpha1.MCPServerPhase](#apiv1alpha1mcpserverphase)_ | Phase is the current phase of the MCPServer | | Enum: [Pending Ready Failed Terminating Stopped] <br />Optional: \{\} <br /> | +| `phase` _[api.v1beta1.MCPServerPhase](#apiv1beta1mcpserverphase)_ | Phase is the current phase of the MCPServer | | Enum: [Pending Ready Failed Terminating Stopped] <br />Optional: \{\} <br /> | | `message` _string_ | Message provides additional information about the current phase | | Optional: \{\} <br /> | | `readyReplicas` _integer_ | ReadyReplicas is the number of ready proxy replicas | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPTelemetryConfig +#### api.v1beta1.MCPTelemetryConfig @@ -2142,20 +2221,20 @@ are not supported for security and isolation reasons. _Appears in:_ -- [api.v1alpha1.MCPTelemetryConfigList](#apiv1alpha1mcptelemetryconfiglist) +- [api.v1beta1.MCPTelemetryConfigList](#apiv1beta1mcptelemetryconfiglist) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPTelemetryConfig` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[api.v1alpha1.MCPTelemetryConfigSpec](#apiv1alpha1mcptelemetryconfigspec)_ | | | | -| `status` _[api.v1alpha1.MCPTelemetryConfigStatus](#apiv1alpha1mcptelemetryconfigstatus)_ | | | | +| `spec` _[api.v1beta1.MCPTelemetryConfigSpec](#apiv1beta1mcptelemetryconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPTelemetryConfigStatus](#apiv1beta1mcptelemetryconfigstatus)_ | | | | -#### api.v1alpha1.MCPTelemetryConfigList +#### api.v1beta1.MCPTelemetryConfigList @@ -2167,15 +2246,15 @@ MCPTelemetryConfigList contains a list of MCPTelemetryConfig | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPTelemetryConfigList` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[api.v1alpha1.MCPTelemetryConfig](#apiv1alpha1mcptelemetryconfig) array_ | | | | +| `items` _[api.v1beta1.MCPTelemetryConfig](#apiv1beta1mcptelemetryconfig) array_ | | | | -#### api.v1alpha1.MCPTelemetryConfigReference +#### api.v1beta1.MCPTelemetryConfigReference @@ -2186,9 +2265,9 @@ same namespace as the MCPServer. _Appears in:_ -- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) -- [api.v1alpha1.VirtualMCPServerSpec](#apiv1alpha1virtualmcpserverspec) +- [api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) +- [api.v1beta1.VirtualMCPServerSpec](#apiv1beta1virtualmcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2196,7 +2275,7 @@ _Appears in:_ | `serviceName` _string_ | ServiceName overrides the telemetry service name for this specific server.<br />This MUST be unique per server for proper observability (e.g., distinguishing<br />traces and metrics from different servers sharing the same collector).<br />If empty, defaults to the server name with "thv-" prefix at runtime. | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPTelemetryConfigSpec +#### api.v1beta1.MCPTelemetryConfigSpec @@ -2207,15 +2286,15 @@ for clear separation of concerns. _Appears in:_ -- [api.v1alpha1.MCPTelemetryConfig](#apiv1alpha1mcptelemetryconfig) +- [api.v1beta1.MCPTelemetryConfig](#apiv1beta1mcptelemetryconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `openTelemetry` _[api.v1alpha1.MCPTelemetryOTelConfig](#apiv1alpha1mcptelemetryotelconfig)_ | OpenTelemetry defines OpenTelemetry configuration (OTLP endpoint, tracing, metrics) | | Optional: \{\} <br /> | -| `prometheus` _[api.v1alpha1.PrometheusConfig](#apiv1alpha1prometheusconfig)_ | Prometheus defines Prometheus-specific configuration | | Optional: \{\} <br /> | +| `openTelemetry` _[api.v1beta1.MCPTelemetryOTelConfig](#apiv1beta1mcptelemetryotelconfig)_ | OpenTelemetry defines OpenTelemetry configuration (OTLP endpoint, tracing, metrics) | | Optional: \{\} <br /> | +| `prometheus` _[api.v1beta1.PrometheusConfig](#apiv1beta1prometheusconfig)_ | Prometheus defines Prometheus-specific configuration | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPTelemetryConfigStatus +#### api.v1beta1.MCPTelemetryConfigStatus @@ -2224,17 +2303,17 @@ MCPTelemetryConfigStatus defines the observed state of MCPTelemetryConfig _Appears in:_ -- [api.v1alpha1.MCPTelemetryConfig](#apiv1alpha1mcptelemetryconfig) +- [api.v1beta1.MCPTelemetryConfig](#apiv1beta1mcptelemetryconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the MCPTelemetryConfig's state | | Optional: \{\} <br /> | | `observedGeneration` _integer_ | ObservedGeneration is the most recent generation observed for this MCPTelemetryConfig. | | Optional: \{\} <br /> | | `configHash` _string_ | ConfigHash is a hash of the current configuration for change detection | | Optional: \{\} <br /> | -| `referencingWorkloads` _[api.v1alpha1.WorkloadReference](#apiv1alpha1workloadreference) array_ | ReferencingWorkloads lists workloads that reference this MCPTelemetryConfig | | Optional: \{\} <br /> | +| `referencingWorkloads` _[api.v1beta1.WorkloadReference](#apiv1beta1workloadreference) array_ | ReferencingWorkloads lists workloads that reference this MCPTelemetryConfig | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPTelemetryOTelConfig +#### api.v1beta1.MCPTelemetryOTelConfig @@ -2248,7 +2327,7 @@ Unlike OpenTelemetryConfig (used by inline MCPServer telemetry), this type: _Appears in:_ -- [api.v1alpha1.MCPTelemetryConfigSpec](#apiv1alpha1mcptelemetryconfigspec) +- [api.v1beta1.MCPTelemetryConfigSpec](#apiv1beta1mcptelemetryconfigspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2256,15 +2335,15 @@ _Appears in:_ | `endpoint` _string_ | Endpoint is the OTLP endpoint URL for tracing and metrics | | Optional: \{\} <br /> | | `insecure` _boolean_ | Insecure indicates whether to use HTTP instead of HTTPS for the OTLP endpoint | false | Optional: \{\} <br /> | | `headers` _object (keys:string, values:string)_ | Headers contains authentication headers for the OTLP endpoint.<br />For secret-backed credentials, use sensitiveHeaders instead. | | Optional: \{\} <br /> | -| `sensitiveHeaders` _[api.v1alpha1.SensitiveHeader](#apiv1alpha1sensitiveheader) array_ | SensitiveHeaders contains headers whose values are stored in Kubernetes Secrets.<br />Use this for credential headers (e.g., API keys, bearer tokens) instead of<br />embedding secrets in the headers field. | | Optional: \{\} <br /> | +| `sensitiveHeaders` _[api.v1beta1.SensitiveHeader](#apiv1beta1sensitiveheader) array_ | SensitiveHeaders contains headers whose values are stored in Kubernetes Secrets.<br />Use this for credential headers (e.g., API keys, bearer tokens) instead of<br />embedding secrets in the headers field. | | Optional: \{\} <br /> | | `resourceAttributes` _object (keys:string, values:string)_ | ResourceAttributes contains custom resource attributes to be added to all telemetry signals.<br />These become OTel resource attributes (e.g., deployment.environment, service.namespace).<br />Note: service.name is intentionally excluded — it is set per-server via<br />MCPTelemetryConfigReference.ServiceName. | | Optional: \{\} <br /> | -| `metrics` _[api.v1alpha1.OpenTelemetryMetricsConfig](#apiv1alpha1opentelemetrymetricsconfig)_ | Metrics defines OpenTelemetry metrics-specific configuration | | Optional: \{\} <br /> | -| `tracing` _[api.v1alpha1.OpenTelemetryTracingConfig](#apiv1alpha1opentelemetrytracingconfig)_ | Tracing defines OpenTelemetry tracing configuration | | Optional: \{\} <br /> | +| `metrics` _[api.v1beta1.OpenTelemetryMetricsConfig](#apiv1beta1opentelemetrymetricsconfig)_ | Metrics defines OpenTelemetry metrics-specific configuration | | Optional: \{\} <br /> | +| `tracing` _[api.v1beta1.OpenTelemetryTracingConfig](#apiv1beta1opentelemetrytracingconfig)_ | Tracing defines OpenTelemetry tracing configuration | | Optional: \{\} <br /> | | `useLegacyAttributes` _boolean_ | UseLegacyAttributes controls whether legacy attribute names are emitted alongside<br />the new MCP OTEL semantic convention names. Defaults to true for backward compatibility.<br />This will change to false in a future release and eventually be removed. | true | Optional: \{\} <br /> | -| `caBundleRef` _[api.v1alpha1.CABundleSource](#apiv1alpha1cabundlesource)_ | CABundleRef references a ConfigMap containing a CA certificate bundle for the OTLP endpoint.<br />When specified, the operator mounts the ConfigMap into the proxyrunner pod and configures<br />the OTLP exporters to trust the custom CA. This is useful when the OTLP collector uses<br />TLS with certificates signed by an internal or private CA. | | Optional: \{\} <br /> | +| `caBundleRef` _[api.v1beta1.CABundleSource](#apiv1beta1cabundlesource)_ | CABundleRef references a ConfigMap containing a CA certificate bundle for the OTLP endpoint.<br />When specified, the operator mounts the ConfigMap into the proxyrunner pod and configures<br />the OTLP exporters to trust the custom CA. This is useful when the OTLP collector uses<br />TLS with certificates signed by an internal or private CA. | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPToolConfig +#### api.v1beta1.MCPToolConfig @@ -2276,20 +2355,20 @@ are not supported for security and isolation reasons. _Appears in:_ -- [api.v1alpha1.MCPToolConfigList](#apiv1alpha1mcptoolconfiglist) +- [api.v1beta1.MCPToolConfigList](#apiv1beta1mcptoolconfiglist) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPToolConfig` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[api.v1alpha1.MCPToolConfigSpec](#apiv1alpha1mcptoolconfigspec)_ | | | | -| `status` _[api.v1alpha1.MCPToolConfigStatus](#apiv1alpha1mcptoolconfigstatus)_ | | | | +| `spec` _[api.v1beta1.MCPToolConfigSpec](#apiv1beta1mcptoolconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPToolConfigStatus](#apiv1beta1mcptoolconfigstatus)_ | | | | -#### api.v1alpha1.MCPToolConfigList +#### api.v1beta1.MCPToolConfigList @@ -2301,15 +2380,15 @@ MCPToolConfigList contains a list of MCPToolConfig | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `MCPToolConfigList` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[api.v1alpha1.MCPToolConfig](#apiv1alpha1mcptoolconfig) array_ | | | | +| `items` _[api.v1beta1.MCPToolConfig](#apiv1beta1mcptoolconfig) array_ | | | | -#### api.v1alpha1.MCPToolConfigSpec +#### api.v1beta1.MCPToolConfigSpec @@ -2320,15 +2399,15 @@ MCPServer resources in the same namespace. _Appears in:_ -- [api.v1alpha1.MCPToolConfig](#apiv1alpha1mcptoolconfig) +- [api.v1beta1.MCPToolConfig](#apiv1beta1mcptoolconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `toolsFilter` _string array_ | ToolsFilter is a list of tool names to filter (allow list).<br />Only tools in this list will be exposed by the MCP server.<br />If empty, all tools are exposed. | | Optional: \{\} <br /> | -| `toolsOverride` _object (keys:string, values:[api.v1alpha1.ToolOverride](#apiv1alpha1tooloverride))_ | ToolsOverride is a map from actual tool names to their overridden configuration.<br />This allows renaming tools and/or changing their descriptions. | | Optional: \{\} <br /> | +| `toolsOverride` _object (keys:string, values:[api.v1beta1.ToolOverride](#apiv1beta1tooloverride))_ | ToolsOverride is a map from actual tool names to their overridden configuration.<br />This allows renaming tools and/or changing their descriptions. | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPToolConfigStatus +#### api.v1beta1.MCPToolConfigStatus @@ -2337,17 +2416,17 @@ MCPToolConfigStatus defines the observed state of MCPToolConfig _Appears in:_ -- [api.v1alpha1.MCPToolConfig](#apiv1alpha1mcptoolconfig) +- [api.v1beta1.MCPToolConfig](#apiv1beta1mcptoolconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the MCPToolConfig's state | | Optional: \{\} <br /> | | `observedGeneration` _integer_ | ObservedGeneration is the most recent generation observed for this MCPToolConfig.<br />It corresponds to the MCPToolConfig's generation, which is updated on mutation by the API Server. | | Optional: \{\} <br /> | | `configHash` _string_ | ConfigHash is a hash of the current configuration for change detection | | Optional: \{\} <br /> | -| `referencingWorkloads` _[api.v1alpha1.WorkloadReference](#apiv1alpha1workloadreference) array_ | ReferencingWorkloads is a list of workload resources that reference this MCPToolConfig.<br />Each entry identifies the workload by kind and name. | | Optional: \{\} <br /> | +| `referencingWorkloads` _[api.v1beta1.WorkloadReference](#apiv1beta1workloadreference) array_ | ReferencingWorkloads is a list of workload resources that reference this MCPToolConfig.<br />Each entry identifies the workload by kind and name. | | Optional: \{\} <br /> | -#### api.v1alpha1.ModelCacheConfig +#### api.v1beta1.ModelCacheConfig @@ -2356,7 +2435,7 @@ ModelCacheConfig configures persistent storage for model caching _Appears in:_ -- [api.v1alpha1.EmbeddingServerSpec](#apiv1alpha1embeddingserverspec) +- [api.v1beta1.EmbeddingServerSpec](#apiv1beta1embeddingserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2366,7 +2445,7 @@ _Appears in:_ | `accessMode` _string_ | AccessMode is the access mode for the PVC | ReadWriteOnce | Enum: [ReadWriteOnce ReadWriteMany ReadOnlyMany] <br />Optional: \{\} <br /> | -#### api.v1alpha1.NetworkPermissions +#### api.v1beta1.NetworkPermissions @@ -2375,15 +2454,15 @@ NetworkPermissions defines the network permissions for an MCP server _Appears in:_ -- [api.v1alpha1.PermissionProfileSpec](#apiv1alpha1permissionprofilespec) +- [api.v1beta1.PermissionProfileSpec](#apiv1beta1permissionprofilespec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `mode` _string_ | Mode specifies the network mode for the container (e.g., "host", "bridge", "none")<br />When empty, the default container runtime network mode is used | | Optional: \{\} <br /> | -| `outbound` _[api.v1alpha1.OutboundNetworkPermissions](#apiv1alpha1outboundnetworkpermissions)_ | Outbound defines the outbound network permissions | | Optional: \{\} <br /> | +| `outbound` _[api.v1beta1.OutboundNetworkPermissions](#apiv1beta1outboundnetworkpermissions)_ | Outbound defines the outbound network permissions | | Optional: \{\} <br /> | -#### api.v1alpha1.OAuth2UpstreamConfig +#### api.v1beta1.OAuth2UpstreamConfig @@ -2393,22 +2472,22 @@ OAuth 2.0 providers require explicit endpoint configuration. _Appears in:_ -- [api.v1alpha1.UpstreamProviderConfig](#apiv1alpha1upstreamproviderconfig) +- [api.v1beta1.UpstreamProviderConfig](#apiv1beta1upstreamproviderconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `authorizationEndpoint` _string_ | AuthorizationEndpoint is the URL for the OAuth authorization endpoint. | | Pattern: `^https?://.*$` <br />Required: \{\} <br /> | | `tokenEndpoint` _string_ | TokenEndpoint is the URL for the OAuth token endpoint. | | Pattern: `^https?://.*$` <br />Required: \{\} <br /> | -| `userInfo` _[api.v1alpha1.UserInfoConfig](#apiv1alpha1userinfoconfig)_ | UserInfo contains configuration for fetching user information from the upstream provider.<br />Required for OAuth2 providers to resolve user identity. | | Required: \{\} <br /> | +| `userInfo` _[api.v1beta1.UserInfoConfig](#apiv1beta1userinfoconfig)_ | UserInfo contains configuration for fetching user information from the upstream provider.<br />Required for OAuth2 providers to resolve user identity. | | Required: \{\} <br /> | | `clientId` _string_ | ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. | | Required: \{\} <br /> | -| `clientSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret.<br />Optional for public clients using PKCE instead of client secret. | | Optional: \{\} <br /> | +| `clientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret.<br />Optional for public clients using PKCE instead of client secret. | | Optional: \{\} <br /> | | `redirectUri` _string_ | RedirectURI is the callback URL where the upstream IDP will redirect after authentication.<br />When not specified, defaults to `\{resourceUrl\}/oauth/callback` where `resourceUrl` is the<br />URL associated with the resource (e.g., MCPServer or vMCP) using this config. | | Optional: \{\} <br /> | | `scopes` _string array_ | Scopes are the OAuth scopes to request from the upstream IDP. | | Optional: \{\} <br /> | -| `tokenResponseMapping` _[api.v1alpha1.TokenResponseMapping](#apiv1alpha1tokenresponsemapping)_ | TokenResponseMapping configures custom field extraction from non-standard token responses.<br />Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths<br />instead of returning them at the top level. When set, ToolHive performs the token<br />exchange HTTP call directly and extracts fields using the configured dot-notation paths.<br />If nil, standard OAuth 2.0 token response parsing is used. | | Optional: \{\} <br /> | +| `tokenResponseMapping` _[api.v1beta1.TokenResponseMapping](#apiv1beta1tokenresponsemapping)_ | TokenResponseMapping configures custom field extraction from non-standard token responses.<br />Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths<br />instead of returning them at the top level. When set, ToolHive performs the token<br />exchange HTTP call directly and extracts fields using the configured dot-notation paths.<br />If nil, standard OAuth 2.0 token response parsing is used. | | Optional: \{\} <br /> | | `additionalAuthorizationParams` _object (keys:string, values:string)_ | AdditionalAuthorizationParams are extra query parameters to include in<br />authorization requests sent to the upstream provider.<br />This is useful for providers that require custom parameters, such as<br />Google's access_type=offline for obtaining refresh tokens.<br />Framework-managed parameters (response_type, client_id, redirect_uri,<br />scope, state, code_challenge, code_challenge_method, nonce) are not allowed. | | MaxProperties: 16 <br />Optional: \{\} <br /> | -#### api.v1alpha1.OIDCUpstreamConfig +#### api.v1beta1.OIDCUpstreamConfig @@ -2418,20 +2497,20 @@ OIDC providers support automatic endpoint discovery via the issuer URL. _Appears in:_ -- [api.v1alpha1.UpstreamProviderConfig](#apiv1alpha1upstreamproviderconfig) +- [api.v1beta1.UpstreamProviderConfig](#apiv1beta1upstreamproviderconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `issuerUrl` _string_ | IssuerURL is the OIDC issuer URL for automatic endpoint discovery.<br />Must be a valid HTTPS URL. | | Pattern: `^https://.*$` <br />Required: \{\} <br /> | | `clientId` _string_ | ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. | | Required: \{\} <br /> | -| `clientSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret.<br />Optional for public clients using PKCE instead of client secret. | | Optional: \{\} <br /> | +| `clientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret.<br />Optional for public clients using PKCE instead of client secret. | | Optional: \{\} <br /> | | `redirectUri` _string_ | RedirectURI is the callback URL where the upstream IDP will redirect after authentication.<br />When not specified, defaults to `\{resourceUrl\}/oauth/callback` where `resourceUrl` is the<br />URL associated with the resource (e.g., MCPServer or vMCP) using this config. | | Optional: \{\} <br /> | | `scopes` _string array_ | Scopes are the OAuth scopes to request from the upstream IDP.<br />If not specified, defaults to ["openid", "offline_access"].<br />When using additionalAuthorizationParams with provider-specific refresh token<br />mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid<br />sending both offline_access and the provider-specific parameter. | | Optional: \{\} <br /> | -| `userInfoOverride` _[api.v1alpha1.UserInfoConfig](#apiv1alpha1userinfoconfig)_ | UserInfoOverride allows customizing UserInfo fetching behavior for OIDC providers.<br />By default, the UserInfo endpoint is discovered automatically via OIDC discovery.<br />Use this to override the endpoint URL, HTTP method, or field mappings for providers<br />that return non-standard claim names in their UserInfo response. | | Optional: \{\} <br /> | +| `userInfoOverride` _[api.v1beta1.UserInfoConfig](#apiv1beta1userinfoconfig)_ | UserInfoOverride allows customizing UserInfo fetching behavior for OIDC providers.<br />By default, the UserInfo endpoint is discovered automatically via OIDC discovery.<br />Use this to override the endpoint URL, HTTP method, or field mappings for providers<br />that return non-standard claim names in their UserInfo response. | | Optional: \{\} <br /> | | `additionalAuthorizationParams` _object (keys:string, values:string)_ | AdditionalAuthorizationParams are extra query parameters to include in<br />authorization requests sent to the upstream provider.<br />This is useful for providers that require custom parameters, such as<br />Google's access_type=offline for obtaining refresh tokens.<br />Note: when using access_type=offline, also set explicit scopes to avoid<br />the default offline_access scope being sent alongside it.<br />Framework-managed parameters (response_type, client_id, redirect_uri,<br />scope, state, code_challenge, code_challenge_method, nonce) are not allowed. | | MaxProperties: 16 <br />Optional: \{\} <br /> | -#### api.v1alpha1.OpenTelemetryMetricsConfig +#### api.v1beta1.OpenTelemetryMetricsConfig @@ -2440,14 +2519,14 @@ OpenTelemetryMetricsConfig defines OpenTelemetry metrics configuration _Appears in:_ -- [api.v1alpha1.MCPTelemetryOTelConfig](#apiv1alpha1mcptelemetryotelconfig) +- [api.v1beta1.MCPTelemetryOTelConfig](#apiv1beta1mcptelemetryotelconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `enabled` _boolean_ | Enabled controls whether OTLP metrics are sent | false | Optional: \{\} <br /> | -#### api.v1alpha1.OpenTelemetryTracingConfig +#### api.v1beta1.OpenTelemetryTracingConfig @@ -2456,7 +2535,7 @@ OpenTelemetryTracingConfig defines OpenTelemetry tracing configuration _Appears in:_ -- [api.v1alpha1.MCPTelemetryOTelConfig](#apiv1alpha1mcptelemetryotelconfig) +- [api.v1beta1.MCPTelemetryOTelConfig](#apiv1beta1mcptelemetryotelconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2464,7 +2543,7 @@ _Appears in:_ | `samplingRate` _string_ | SamplingRate is the trace sampling rate (0.0-1.0) | 0.05 | Pattern: `^(0(\.\d+)?\|1(\.0+)?)$` <br />Optional: \{\} <br /> | -#### api.v1alpha1.OutboundNetworkPermissions +#### api.v1beta1.OutboundNetworkPermissions @@ -2473,7 +2552,7 @@ OutboundNetworkPermissions defines the outbound network permissions _Appears in:_ -- [api.v1alpha1.NetworkPermissions](#apiv1alpha1networkpermissions) +- [api.v1beta1.NetworkPermissions](#apiv1beta1networkpermissions) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2482,7 +2561,7 @@ _Appears in:_ | `allowPort` _integer array_ | AllowPort is a list of ports to allow connections to | | Optional: \{\} <br /> | -#### api.v1alpha1.OutgoingAuthConfig +#### api.v1beta1.OutgoingAuthConfig @@ -2491,16 +2570,16 @@ OutgoingAuthConfig configures authentication from Virtual MCP to backend MCPServ _Appears in:_ -- [api.v1alpha1.VirtualMCPServerSpec](#apiv1alpha1virtualmcpserverspec) +- [api.v1beta1.VirtualMCPServerSpec](#apiv1beta1virtualmcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `source` _string_ | Source defines how backend authentication configurations are determined<br />- discovered: Automatically discover from backend's MCPServer.spec.externalAuthConfigRef<br />- inline: Explicit per-backend configuration in VirtualMCPServer | discovered | Enum: [discovered inline] <br />Optional: \{\} <br /> | -| `default` _[api.v1alpha1.BackendAuthConfig](#apiv1alpha1backendauthconfig)_ | Default defines default behavior for backends without explicit auth config | | Optional: \{\} <br /> | -| `backends` _object (keys:string, values:[api.v1alpha1.BackendAuthConfig](#apiv1alpha1backendauthconfig))_ | Backends defines per-backend authentication overrides<br />Works in all modes (discovered, inline) | | Optional: \{\} <br /> | +| `default` _[api.v1beta1.BackendAuthConfig](#apiv1beta1backendauthconfig)_ | Default defines default behavior for backends without explicit auth config | | Optional: \{\} <br /> | +| `backends` _object (keys:string, values:[api.v1beta1.BackendAuthConfig](#apiv1beta1backendauthconfig))_ | Backends defines per-backend authentication overrides<br />Works in all modes (discovered, inline) | | Optional: \{\} <br /> | -#### api.v1alpha1.PermissionProfileRef +#### api.v1beta1.PermissionProfileRef @@ -2509,7 +2588,7 @@ PermissionProfileRef defines a reference to a permission profile _Appears in:_ -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2520,7 +2599,7 @@ _Appears in:_ -#### api.v1alpha1.PrometheusConfig +#### api.v1beta1.PrometheusConfig @@ -2529,14 +2608,14 @@ PrometheusConfig defines Prometheus-specific configuration _Appears in:_ -- [api.v1alpha1.MCPTelemetryConfigSpec](#apiv1alpha1mcptelemetryconfigspec) +- [api.v1beta1.MCPTelemetryConfigSpec](#apiv1beta1mcptelemetryconfigspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `enabled` _boolean_ | Enabled controls whether Prometheus metrics endpoint is exposed | false | Optional: \{\} <br /> | -#### api.v1alpha1.ProxyDeploymentOverrides +#### api.v1beta1.ProxyDeploymentOverrides @@ -2545,18 +2624,18 @@ ProxyDeploymentOverrides defines overrides specific to the proxy deployment _Appears in:_ -- [api.v1alpha1.ResourceOverrides](#apiv1alpha1resourceoverrides) +- [api.v1beta1.ResourceOverrides](#apiv1beta1resourceoverrides) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `annotations` _object (keys:string, values:string)_ | Annotations to add or override on the resource | | Optional: \{\} <br /> | | `labels` _object (keys:string, values:string)_ | Labels to add or override on the resource | | Optional: \{\} <br /> | -| `podTemplateMetadataOverrides` _[api.v1alpha1.ResourceMetadataOverrides](#apiv1alpha1resourcemetadataoverrides)_ | | | | -| `env` _[api.v1alpha1.EnvVar](#apiv1alpha1envvar) array_ | Env are environment variables to set in the proxy container (thv run process)<br />These affect the toolhive proxy itself, not the MCP server it manages<br />Use TOOLHIVE_DEBUG=true to enable debug logging in the proxy | | Optional: \{\} <br /> | +| `podTemplateMetadataOverrides` _[api.v1beta1.ResourceMetadataOverrides](#apiv1beta1resourcemetadataoverrides)_ | | | | +| `env` _[api.v1beta1.EnvVar](#apiv1beta1envvar) array_ | Env are environment variables to set in the proxy container (thv run process)<br />These affect the toolhive proxy itself, not the MCP server it manages<br />Use TOOLHIVE_DEBUG=true to enable debug logging in the proxy | | Optional: \{\} <br /> | | `imagePullSecrets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#localobjectreference-v1-core) array_ | ImagePullSecrets allows specifying image pull secrets for the proxy runner<br />These are applied to both the Deployment and the ServiceAccount | | Optional: \{\} <br /> | -#### api.v1alpha1.RateLimitBucket +#### api.v1beta1.RateLimitBucket @@ -2566,8 +2645,8 @@ and a refill period. Used by both shared (global) and per-user rate limits. _Appears in:_ -- [api.v1alpha1.RateLimitConfig](#apiv1alpha1ratelimitconfig) -- [api.v1alpha1.ToolRateLimitConfig](#apiv1alpha1toolratelimitconfig) +- [api.v1beta1.RateLimitConfig](#apiv1beta1ratelimitconfig) +- [api.v1beta1.ToolRateLimitConfig](#apiv1beta1toolratelimitconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2575,7 +2654,7 @@ _Appears in:_ | `refillPeriod` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#duration-v1-meta)_ | RefillPeriod is the duration to fully refill the bucket from zero to maxTokens.<br />The effective refill rate is maxTokens / refillPeriod tokens per second.<br />Format: Go duration string (e.g., "1m0s", "30s", "1h0m0s"). | | Required: \{\} <br /> | -#### api.v1alpha1.RateLimitConfig +#### api.v1beta1.RateLimitConfig @@ -2585,16 +2664,16 @@ At least one of shared, perUser, or tools must be configured. _Appears in:_ -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `shared` _[api.v1alpha1.RateLimitBucket](#apiv1alpha1ratelimitbucket)_ | Shared is a token bucket shared across all users for the entire server. | | Optional: \{\} <br /> | -| `perUser` _[api.v1alpha1.RateLimitBucket](#apiv1alpha1ratelimitbucket)_ | PerUser is a token bucket applied independently to each authenticated user<br />at the server level. Requires authentication to be enabled.<br />Each unique userID creates Redis keys that expire after 2x refillPeriod.<br />Memory formula: unique_users_per_TTL_window * (1 + num_tools_with_per_user_limits) keys. | | Optional: \{\} <br /> | -| `tools` _[api.v1alpha1.ToolRateLimitConfig](#apiv1alpha1toolratelimitconfig) array_ | Tools defines per-tool rate limit overrides.<br />Each entry applies additional rate limits to calls targeting a specific tool name.<br />A request must pass both the server-level limit and the per-tool limit. | | Optional: \{\} <br /> | +| `shared` _[api.v1beta1.RateLimitBucket](#apiv1beta1ratelimitbucket)_ | Shared is a token bucket shared across all users for the entire server. | | Optional: \{\} <br /> | +| `perUser` _[api.v1beta1.RateLimitBucket](#apiv1beta1ratelimitbucket)_ | PerUser is a token bucket applied independently to each authenticated user<br />at the server level. Requires authentication to be enabled.<br />Each unique userID creates Redis keys that expire after 2x refillPeriod.<br />Memory formula: unique_users_per_TTL_window * (1 + num_tools_with_per_user_limits) keys. | | Optional: \{\} <br /> | +| `tools` _[api.v1beta1.ToolRateLimitConfig](#apiv1beta1toolratelimitconfig) array_ | Tools defines per-tool rate limit overrides.<br />Each entry applies additional rate limits to calls targeting a specific tool name.<br />A request must pass both the server-level limit and the per-tool limit. | | Optional: \{\} <br /> | -#### api.v1alpha1.RedisACLUserConfig +#### api.v1beta1.RedisACLUserConfig @@ -2603,15 +2682,15 @@ RedisACLUserConfig configures Redis ACL user authentication. _Appears in:_ -- [api.v1alpha1.RedisStorageConfig](#apiv1alpha1redisstorageconfig) +- [api.v1beta1.RedisStorageConfig](#apiv1beta1redisstorageconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `usernameSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | UsernameSecretRef references a Secret containing the Redis ACL username. | | Required: \{\} <br /> | -| `passwordSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | PasswordSecretRef references a Secret containing the Redis ACL password. | | Required: \{\} <br /> | +| `usernameSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | UsernameSecretRef references a Secret containing the Redis ACL username. | | Required: \{\} <br /> | +| `passwordSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | PasswordSecretRef references a Secret containing the Redis ACL password. | | Required: \{\} <br /> | -#### api.v1alpha1.RedisSentinelConfig +#### api.v1beta1.RedisSentinelConfig @@ -2620,17 +2699,17 @@ RedisSentinelConfig configures Redis Sentinel connection. _Appears in:_ -- [api.v1alpha1.RedisStorageConfig](#apiv1alpha1redisstorageconfig) +- [api.v1beta1.RedisStorageConfig](#apiv1beta1redisstorageconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `masterName` _string_ | MasterName is the name of the Redis master monitored by Sentinel. | | Required: \{\} <br /> | | `sentinelAddrs` _string array_ | SentinelAddrs is a list of Sentinel host:port addresses.<br />Mutually exclusive with SentinelService. | | Optional: \{\} <br /> | -| `sentinelService` _[api.v1alpha1.SentinelServiceRef](#apiv1alpha1sentinelserviceref)_ | SentinelService enables automatic discovery from a Kubernetes Service.<br />Mutually exclusive with SentinelAddrs. | | Optional: \{\} <br /> | +| `sentinelService` _[api.v1beta1.SentinelServiceRef](#apiv1beta1sentinelserviceref)_ | SentinelService enables automatic discovery from a Kubernetes Service.<br />Mutually exclusive with SentinelAddrs. | | Optional: \{\} <br /> | | `db` _integer_ | DB is the Redis database number. | 0 | Optional: \{\} <br /> | -#### api.v1alpha1.RedisStorageConfig +#### api.v1beta1.RedisStorageConfig @@ -2640,20 +2719,20 @@ Redis is deployed in Sentinel mode with ACL user authentication (the only suppor _Appears in:_ -- [api.v1alpha1.AuthServerStorageConfig](#apiv1alpha1authserverstorageconfig) +- [api.v1beta1.AuthServerStorageConfig](#apiv1beta1authserverstorageconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `sentinelConfig` _[api.v1alpha1.RedisSentinelConfig](#apiv1alpha1redissentinelconfig)_ | SentinelConfig holds Redis Sentinel configuration. | | Required: \{\} <br /> | -| `aclUserConfig` _[api.v1alpha1.RedisACLUserConfig](#apiv1alpha1redisacluserconfig)_ | ACLUserConfig configures Redis ACL user authentication. | | Required: \{\} <br /> | +| `sentinelConfig` _[api.v1beta1.RedisSentinelConfig](#apiv1beta1redissentinelconfig)_ | SentinelConfig holds Redis Sentinel configuration. | | Required: \{\} <br /> | +| `aclUserConfig` _[api.v1beta1.RedisACLUserConfig](#apiv1beta1redisacluserconfig)_ | ACLUserConfig configures Redis ACL user authentication. | | Required: \{\} <br /> | | `dialTimeout` _string_ | DialTimeout is the timeout for establishing connections.<br />Format: Go duration string (e.g., "5s", "1m"). | 5s | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$` <br />Optional: \{\} <br /> | | `readTimeout` _string_ | ReadTimeout is the timeout for socket reads.<br />Format: Go duration string (e.g., "3s", "1m"). | 3s | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$` <br />Optional: \{\} <br /> | | `writeTimeout` _string_ | WriteTimeout is the timeout for socket writes.<br />Format: Go duration string (e.g., "3s", "1m"). | 3s | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$` <br />Optional: \{\} <br /> | -| `tls` _[api.v1alpha1.RedisTLSConfig](#apiv1alpha1redistlsconfig)_ | TLS configures TLS for connections to the Redis/Valkey master.<br />Presence of this field enables TLS. Omit to use plaintext. | | Optional: \{\} <br /> | -| `sentinelTls` _[api.v1alpha1.RedisTLSConfig](#apiv1alpha1redistlsconfig)_ | SentinelTLS configures TLS for connections to Sentinel instances.<br />Presence of this field enables TLS. Omit to use plaintext.<br />When omitted, sentinel connections use plaintext (no fallback to TLS config). | | Optional: \{\} <br /> | +| `tls` _[api.v1beta1.RedisTLSConfig](#apiv1beta1redistlsconfig)_ | TLS configures TLS for connections to the Redis/Valkey master.<br />Presence of this field enables TLS. Omit to use plaintext. | | Optional: \{\} <br /> | +| `sentinelTls` _[api.v1beta1.RedisTLSConfig](#apiv1beta1redistlsconfig)_ | SentinelTLS configures TLS for connections to Sentinel instances.<br />Presence of this field enables TLS. Omit to use plaintext.<br />When omitted, sentinel connections use plaintext (no fallback to TLS config). | | Optional: \{\} <br /> | -#### api.v1alpha1.RedisTLSConfig +#### api.v1beta1.RedisTLSConfig @@ -2663,15 +2742,15 @@ Presence of this struct on a connection type enables TLS for that connection. _Appears in:_ -- [api.v1alpha1.RedisStorageConfig](#apiv1alpha1redisstorageconfig) +- [api.v1beta1.RedisStorageConfig](#apiv1beta1redisstorageconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `insecureSkipVerify` _boolean_ | InsecureSkipVerify skips TLS certificate verification.<br />Use when connecting to services with self-signed certificates. | | Optional: \{\} <br /> | -| `caCertSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | CACertSecretRef references a Secret containing a PEM-encoded CA certificate<br />for verifying the server. When not specified, system root CAs are used. | | Optional: \{\} <br /> | +| `caCertSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | CACertSecretRef references a Secret containing a PEM-encoded CA certificate<br />for verifying the server. When not specified, system root CAs are used. | | Optional: \{\} <br /> | -#### api.v1alpha1.ResourceList +#### api.v1beta1.ResourceList @@ -2680,7 +2759,7 @@ ResourceList is a set of (resource name, quantity) pairs _Appears in:_ -- [api.v1alpha1.ResourceRequirements](#apiv1alpha1resourcerequirements) +- [api.v1beta1.ResourceRequirements](#apiv1beta1resourcerequirements) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2688,7 +2767,7 @@ _Appears in:_ | `memory` _string_ | Memory is the memory limit in bytes (e.g., "64Mi" for 64 megabytes) | | Optional: \{\} <br /> | -#### api.v1alpha1.ResourceMetadataOverrides +#### api.v1beta1.ResourceMetadataOverrides @@ -2697,10 +2776,10 @@ ResourceMetadataOverrides defines metadata overrides for a resource _Appears in:_ -- [api.v1alpha1.EmbeddingResourceOverrides](#apiv1alpha1embeddingresourceoverrides) -- [api.v1alpha1.EmbeddingStatefulSetOverrides](#apiv1alpha1embeddingstatefulsetoverrides) -- [api.v1alpha1.ProxyDeploymentOverrides](#apiv1alpha1proxydeploymentoverrides) -- [api.v1alpha1.ResourceOverrides](#apiv1alpha1resourceoverrides) +- [api.v1beta1.EmbeddingResourceOverrides](#apiv1beta1embeddingresourceoverrides) +- [api.v1beta1.EmbeddingStatefulSetOverrides](#apiv1beta1embeddingstatefulsetoverrides) +- [api.v1beta1.ProxyDeploymentOverrides](#apiv1beta1proxydeploymentoverrides) +- [api.v1beta1.ResourceOverrides](#apiv1beta1resourceoverrides) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2708,7 +2787,7 @@ _Appears in:_ | `labels` _object (keys:string, values:string)_ | Labels to add or override on the resource | | Optional: \{\} <br /> | -#### api.v1alpha1.ResourceOverrides +#### api.v1beta1.ResourceOverrides @@ -2717,16 +2796,16 @@ ResourceOverrides defines overrides for annotations and labels on created resour _Appears in:_ -- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `proxyDeployment` _[api.v1alpha1.ProxyDeploymentOverrides](#apiv1alpha1proxydeploymentoverrides)_ | ProxyDeployment defines overrides for the Proxy Deployment resource (toolhive proxy) | | Optional: \{\} <br /> | -| `proxyService` _[api.v1alpha1.ResourceMetadataOverrides](#apiv1alpha1resourcemetadataoverrides)_ | ProxyService defines overrides for the Proxy Service resource (points to the proxy deployment) | | Optional: \{\} <br /> | +| `proxyDeployment` _[api.v1beta1.ProxyDeploymentOverrides](#apiv1beta1proxydeploymentoverrides)_ | ProxyDeployment defines overrides for the Proxy Deployment resource (toolhive proxy) | | Optional: \{\} <br /> | +| `proxyService` _[api.v1beta1.ResourceMetadataOverrides](#apiv1beta1resourcemetadataoverrides)_ | ProxyService defines overrides for the Proxy Service resource (points to the proxy deployment) | | Optional: \{\} <br /> | -#### api.v1alpha1.ResourceRequirements +#### api.v1beta1.ResourceRequirements @@ -2735,17 +2814,17 @@ ResourceRequirements describes the compute resource requirements _Appears in:_ -- [api.v1alpha1.EmbeddingServerSpec](#apiv1alpha1embeddingserverspec) -- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.EmbeddingServerSpec](#apiv1beta1embeddingserverspec) +- [api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `limits` _[api.v1alpha1.ResourceList](#apiv1alpha1resourcelist)_ | Limits describes the maximum amount of compute resources allowed | | Optional: \{\} <br /> | -| `requests` _[api.v1alpha1.ResourceList](#apiv1alpha1resourcelist)_ | Requests describes the minimum amount of compute resources required | | Optional: \{\} <br /> | +| `limits` _[api.v1beta1.ResourceList](#apiv1beta1resourcelist)_ | Limits describes the maximum amount of compute resources allowed | | Optional: \{\} <br /> | +| `requests` _[api.v1beta1.ResourceList](#apiv1beta1resourcelist)_ | Requests describes the minimum amount of compute resources required | | Optional: \{\} <br /> | -#### api.v1alpha1.RoleMapping +#### api.v1beta1.RoleMapping @@ -2757,7 +2836,7 @@ Exactly one of Claim or Matcher must be specified. _Appears in:_ -- [api.v1alpha1.AWSStsConfig](#apiv1alpha1awsstsconfig) +- [api.v1beta1.AWSStsConfig](#apiv1beta1awsstsconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2767,7 +2846,7 @@ _Appears in:_ | `priority` _integer_ | Priority determines evaluation order (lower values = higher priority)<br />Allows fine-grained control over role selection precedence<br />When omitted, this mapping has the lowest possible priority and<br />configuration order acts as tie-breaker via stable sort | | Minimum: 0 <br />Optional: \{\} <br /> | -#### api.v1alpha1.SecretKeyRef +#### api.v1beta1.SecretKeyRef @@ -2776,19 +2855,19 @@ SecretKeyRef is a reference to a key within a Secret _Appears in:_ -- [api.v1alpha1.BearerTokenConfig](#apiv1alpha1bearertokenconfig) -- [api.v1alpha1.EmbeddedAuthServerConfig](#apiv1alpha1embeddedauthserverconfig) -- [api.v1alpha1.EmbeddingServerSpec](#apiv1alpha1embeddingserverspec) -- [api.v1alpha1.HeaderFromSecret](#apiv1alpha1headerfromsecret) -- [api.v1alpha1.HeaderInjectionConfig](#apiv1alpha1headerinjectionconfig) -- [api.v1alpha1.InlineOIDCSharedConfig](#apiv1alpha1inlineoidcsharedconfig) -- [api.v1alpha1.OAuth2UpstreamConfig](#apiv1alpha1oauth2upstreamconfig) -- [api.v1alpha1.OIDCUpstreamConfig](#apiv1alpha1oidcupstreamconfig) -- [api.v1alpha1.RedisACLUserConfig](#apiv1alpha1redisacluserconfig) -- [api.v1alpha1.RedisTLSConfig](#apiv1alpha1redistlsconfig) -- [api.v1alpha1.SensitiveHeader](#apiv1alpha1sensitiveheader) -- [api.v1alpha1.SessionStorageConfig](#apiv1alpha1sessionstorageconfig) -- [api.v1alpha1.TokenExchangeConfig](#apiv1alpha1tokenexchangeconfig) +- [api.v1beta1.BearerTokenConfig](#apiv1beta1bearertokenconfig) +- [api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig) +- [api.v1beta1.EmbeddingServerSpec](#apiv1beta1embeddingserverspec) +- [api.v1beta1.HeaderFromSecret](#apiv1beta1headerfromsecret) +- [api.v1beta1.HeaderInjectionConfig](#apiv1beta1headerinjectionconfig) +- [api.v1beta1.InlineOIDCSharedConfig](#apiv1beta1inlineoidcsharedconfig) +- [api.v1beta1.OAuth2UpstreamConfig](#apiv1beta1oauth2upstreamconfig) +- [api.v1beta1.OIDCUpstreamConfig](#apiv1beta1oidcupstreamconfig) +- [api.v1beta1.RedisACLUserConfig](#apiv1beta1redisacluserconfig) +- [api.v1beta1.RedisTLSConfig](#apiv1beta1redistlsconfig) +- [api.v1beta1.SensitiveHeader](#apiv1beta1sensitiveheader) +- [api.v1beta1.SessionStorageConfig](#apiv1beta1sessionstorageconfig) +- [api.v1beta1.TokenExchangeConfig](#apiv1beta1tokenexchangeconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2796,7 +2875,7 @@ _Appears in:_ | `key` _string_ | Key is the key within the secret | | Required: \{\} <br /> | -#### api.v1alpha1.SecretRef +#### api.v1beta1.SecretRef @@ -2805,7 +2884,7 @@ SecretRef is a reference to a secret _Appears in:_ -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2814,7 +2893,7 @@ _Appears in:_ | `targetEnvName` _string_ | TargetEnvName is the environment variable to be used when setting up the secret in the MCP server<br />If left unspecified, it defaults to the key | | Optional: \{\} <br /> | -#### api.v1alpha1.SensitiveHeader +#### api.v1beta1.SensitiveHeader @@ -2825,28 +2904,28 @@ referenced without embedding secrets inline in the MCPTelemetryConfig resource. _Appears in:_ -- [api.v1alpha1.MCPTelemetryOTelConfig](#apiv1alpha1mcptelemetryotelconfig) +- [api.v1beta1.MCPTelemetryOTelConfig](#apiv1beta1mcptelemetryotelconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `name` _string_ | Name is the header name (e.g., "Authorization", "X-API-Key") | | MinLength: 1 <br />Required: \{\} <br /> | -| `secretKeyRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | SecretKeyRef is a reference to a Kubernetes Secret key containing the header value | | Required: \{\} <br /> | +| `secretKeyRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | SecretKeyRef is a reference to a Kubernetes Secret key containing the header value | | Required: \{\} <br /> | -#### api.v1alpha1.SentinelServiceRef +#### api.v1beta1.SentinelServiceRef -_Underlying type:_ _[api.v1alpha1.struct{Name string "json:\"name\""; Namespace string "json:\"namespace,omitempty\""; Port int32 "json:\"port,omitempty\""}](#apiv1alpha1struct{name string "json:\"name\""; namespace string "json:\"namespace,omitempty\""; port int32 "json:\"port,omitempty\""})_ +_Underlying type:_ _[api.v1beta1.struct{Name string "json:\"name\""; Namespace string "json:\"namespace,omitempty\""; Port int32 "json:\"port,omitempty\""}](#apiv1beta1struct{name string "json:\"name\""; namespace string "json:\"namespace,omitempty\""; port int32 "json:\"port,omitempty\""})_ SentinelServiceRef references a Kubernetes Service for Sentinel discovery. _Appears in:_ -- [api.v1alpha1.RedisSentinelConfig](#apiv1alpha1redissentinelconfig) +- [api.v1beta1.RedisSentinelConfig](#apiv1beta1redissentinelconfig) -#### api.v1alpha1.SessionStorageConfig +#### api.v1beta1.SessionStorageConfig @@ -2861,8 +2940,8 @@ into the vMCP ConfigMap so the vMCP process receives connection parameters at st _Appears in:_ -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) -- [api.v1alpha1.VirtualMCPServerSpec](#apiv1alpha1virtualmcpserverspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) +- [api.v1beta1.VirtualMCPServerSpec](#apiv1beta1virtualmcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2870,10 +2949,10 @@ _Appears in:_ | `address` _string_ | Address is the Redis server address (required when provider is redis) | | MinLength: 1 <br />Optional: \{\} <br /> | | `db` _integer_ | DB is the Redis database number | 0 | Minimum: 0 <br />Optional: \{\} <br /> | | `keyPrefix` _string_ | KeyPrefix is an optional prefix for all Redis keys used by ToolHive | | Optional: \{\} <br /> | -| `passwordRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | PasswordRef is a reference to a Secret key containing the Redis password | | Optional: \{\} <br /> | +| `passwordRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | PasswordRef is a reference to a Secret key containing the Redis password | | Optional: \{\} <br /> | -#### api.v1alpha1.TokenExchangeConfig +#### api.v1beta1.TokenExchangeConfig @@ -2885,13 +2964,13 @@ The structure matches the tokenexchange.Config from pkg/auth/tokenexchange/middl _Appears in:_ -- [api.v1alpha1.MCPExternalAuthConfigSpec](#apiv1alpha1mcpexternalauthconfigspec) +- [api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `tokenUrl` _string_ | TokenURL is the OAuth 2.0 token endpoint URL for token exchange | | Required: \{\} <br /> | | `clientId` _string_ | ClientID is the OAuth 2.0 client identifier<br />Optional for some token exchange flows (e.g., Google Cloud Workforce Identity) | | Optional: \{\} <br /> | -| `clientSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | ClientSecretRef is a reference to a secret containing the OAuth 2.0 client secret<br />Optional for some token exchange flows (e.g., Google Cloud Workforce Identity) | | Optional: \{\} <br /> | +| `clientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ClientSecretRef is a reference to a secret containing the OAuth 2.0 client secret<br />Optional for some token exchange flows (e.g., Google Cloud Workforce Identity) | | Optional: \{\} <br /> | | `audience` _string_ | Audience is the target audience for the exchanged token | | Required: \{\} <br /> | | `scopes` _string array_ | Scopes is a list of OAuth 2.0 scopes to request for the exchanged token | | Optional: \{\} <br /> | | `subjectTokenType` _string_ | SubjectTokenType is the type of the incoming subject token.<br />Accepts short forms: "access_token" (default), "id_token", "jwt"<br />Or full URNs: "urn:ietf:params:oauth:token-type:access_token",<br /> "urn:ietf:params:oauth:token-type:id_token",<br /> "urn:ietf:params:oauth:token-type:jwt"<br />For Google Workload Identity Federation with OIDC providers (like Okta), use "id_token" | | Pattern: `^(access_token\|id_token\|jwt\|urn:ietf:params:oauth:token-type:(access_token\|id_token\|jwt))?$` <br />Optional: \{\} <br /> | @@ -2899,7 +2978,7 @@ _Appears in:_ | `subjectProviderName` _string_ | SubjectProviderName is the name of the upstream provider whose token is used as the<br />RFC 8693 subject token instead of identity.Token when performing token exchange.<br />When left empty and an embedded authorization server is configured on the VirtualMCPServer,<br />the controller automatically populates this field with the first configured upstream<br />provider name. Set it explicitly to override that default or to select a specific<br />provider when multiple upstreams are configured. | | Optional: \{\} <br /> | -#### api.v1alpha1.TokenLifespanConfig +#### api.v1beta1.TokenLifespanConfig @@ -2908,7 +2987,7 @@ TokenLifespanConfig holds configuration for token lifetimes. _Appears in:_ -- [api.v1alpha1.EmbeddedAuthServerConfig](#apiv1alpha1embeddedauthserverconfig) +- [api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2917,7 +2996,7 @@ _Appears in:_ | `authCodeLifespan` _string_ | AuthCodeLifespan is the duration that authorization codes are valid.<br />Format: Go duration string (e.g., "10m", "5m").<br />If empty, defaults to 10 minutes. | | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$` <br />Optional: \{\} <br /> | -#### api.v1alpha1.TokenResponseMapping +#### api.v1beta1.TokenResponseMapping @@ -2928,7 +3007,7 @@ the access token under paths like "authed_user.access_token". _Appears in:_ -- [api.v1alpha1.OAuth2UpstreamConfig](#apiv1alpha1oauth2upstreamconfig) +- [api.v1beta1.OAuth2UpstreamConfig](#apiv1beta1oauth2upstreamconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2938,7 +3017,7 @@ _Appears in:_ | `expiresInPath` _string_ | ExpiresInPath is the dot-notation path to the expires_in value (in seconds).<br />If not specified, defaults to "expires_in". | | Optional: \{\} <br /> | -#### api.v1alpha1.ToolAnnotationsOverride +#### api.v1beta1.ToolAnnotationsOverride @@ -2949,7 +3028,7 @@ All fields use pointers so nil means "don't override" while zero values _Appears in:_ -- [api.v1alpha1.ToolOverride](#apiv1alpha1tooloverride) +- [api.v1beta1.ToolOverride](#apiv1beta1tooloverride) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2960,7 +3039,7 @@ _Appears in:_ | `openWorldHint` _boolean_ | OpenWorldHint overrides the open-world hint annotation. | | Optional: \{\} <br /> | -#### api.v1alpha1.ToolConfigRef +#### api.v1beta1.ToolConfigRef @@ -2970,15 +3049,15 @@ The referenced MCPToolConfig must be in the same namespace as the MCPServer. _Appears in:_ -- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `name` _string_ | Name is the name of the MCPToolConfig resource in the same namespace | | Required: \{\} <br /> | -#### api.v1alpha1.ToolOverride +#### api.v1beta1.ToolOverride @@ -2989,16 +3068,16 @@ they can't be both empty. _Appears in:_ -- [api.v1alpha1.MCPToolConfigSpec](#apiv1alpha1mcptoolconfigspec) +- [api.v1beta1.MCPToolConfigSpec](#apiv1beta1mcptoolconfigspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `name` _string_ | Name is the redefined name of the tool | | Optional: \{\} <br /> | | `description` _string_ | Description is the redefined description of the tool | | Optional: \{\} <br /> | -| `annotations` _[api.v1alpha1.ToolAnnotationsOverride](#apiv1alpha1toolannotationsoverride)_ | Annotations overrides specific tool annotation fields.<br />Only specified fields are overridden; others pass through from the backend. | | Optional: \{\} <br /> | +| `annotations` _[api.v1beta1.ToolAnnotationsOverride](#apiv1beta1toolannotationsoverride)_ | Annotations overrides specific tool annotation fields.<br />Only specified fields are overridden; others pass through from the backend. | | Optional: \{\} <br /> | -#### api.v1alpha1.ToolRateLimitConfig +#### api.v1beta1.ToolRateLimitConfig @@ -3008,16 +3087,16 @@ At least one of shared or perUser must be configured. _Appears in:_ -- [api.v1alpha1.RateLimitConfig](#apiv1alpha1ratelimitconfig) +- [api.v1beta1.RateLimitConfig](#apiv1beta1ratelimitconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `name` _string_ | Name is the MCP tool name this limit applies to. | | MinLength: 1 <br />Required: \{\} <br /> | -| `shared` _[api.v1alpha1.RateLimitBucket](#apiv1alpha1ratelimitbucket)_ | Shared token bucket for this specific tool. | | Optional: \{\} <br /> | -| `perUser` _[api.v1alpha1.RateLimitBucket](#apiv1alpha1ratelimitbucket)_ | PerUser token bucket configuration for this tool. | | Optional: \{\} <br /> | +| `shared` _[api.v1beta1.RateLimitBucket](#apiv1beta1ratelimitbucket)_ | Shared token bucket for this specific tool. | | Optional: \{\} <br /> | +| `perUser` _[api.v1beta1.RateLimitBucket](#apiv1beta1ratelimitbucket)_ | PerUser token bucket configuration for this tool. | | Optional: \{\} <br /> | -#### api.v1alpha1.UpstreamInjectSpec +#### api.v1beta1.UpstreamInjectSpec @@ -3028,14 +3107,14 @@ authorization server into backend requests as the Authorization: Bearer header. _Appears in:_ -- [api.v1alpha1.MCPExternalAuthConfigSpec](#apiv1alpha1mcpexternalauthconfigspec) +- [api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `providerName` _string_ | ProviderName is the name of the upstream IDP provider whose access token<br />should be injected as the Authorization: Bearer header. | | MinLength: 1 <br />Required: \{\} <br /> | -#### api.v1alpha1.UpstreamProviderConfig +#### api.v1beta1.UpstreamProviderConfig @@ -3044,17 +3123,17 @@ UpstreamProviderConfig defines configuration for an upstream Identity Provider. _Appears in:_ -- [api.v1alpha1.EmbeddedAuthServerConfig](#apiv1alpha1embeddedauthserverconfig) +- [api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `name` _string_ | Name uniquely identifies this upstream provider.<br />Used for routing decisions and session binding in multi-upstream scenarios.<br />Must be lowercase alphanumeric with hyphens (DNS-label-like). | | MaxLength: 63 <br />MinLength: 1 <br />Pattern: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` <br />Required: \{\} <br /> | -| `type` _[api.v1alpha1.UpstreamProviderType](#apiv1alpha1upstreamprovidertype)_ | Type specifies the provider type: "oidc" or "oauth2" | | Enum: [oidc oauth2] <br />Required: \{\} <br /> | -| `oidcConfig` _[api.v1alpha1.OIDCUpstreamConfig](#apiv1alpha1oidcupstreamconfig)_ | OIDCConfig contains OIDC-specific configuration.<br />Required when Type is "oidc", must be nil when Type is "oauth2". | | Optional: \{\} <br /> | -| `oauth2Config` _[api.v1alpha1.OAuth2UpstreamConfig](#apiv1alpha1oauth2upstreamconfig)_ | OAuth2Config contains OAuth 2.0-specific configuration.<br />Required when Type is "oauth2", must be nil when Type is "oidc". | | Optional: \{\} <br /> | +| `type` _[api.v1beta1.UpstreamProviderType](#apiv1beta1upstreamprovidertype)_ | Type specifies the provider type: "oidc" or "oauth2" | | Enum: [oidc oauth2] <br />Required: \{\} <br /> | +| `oidcConfig` _[api.v1beta1.OIDCUpstreamConfig](#apiv1beta1oidcupstreamconfig)_ | OIDCConfig contains OIDC-specific configuration.<br />Required when Type is "oidc", must be nil when Type is "oauth2". | | Optional: \{\} <br /> | +| `oauth2Config` _[api.v1beta1.OAuth2UpstreamConfig](#apiv1beta1oauth2upstreamconfig)_ | OAuth2Config contains OAuth 2.0-specific configuration.<br />Required when Type is "oauth2", must be nil when Type is "oidc". | | Optional: \{\} <br /> | -#### api.v1alpha1.UpstreamProviderType +#### api.v1beta1.UpstreamProviderType _Underlying type:_ _string_ @@ -3063,7 +3142,7 @@ UpstreamProviderType identifies the type of upstream Identity Provider. _Appears in:_ -- [api.v1alpha1.UpstreamProviderConfig](#apiv1alpha1upstreamproviderconfig) +- [api.v1beta1.UpstreamProviderConfig](#apiv1beta1upstreamproviderconfig) | Field | Description | | --- | --- | @@ -3071,7 +3150,7 @@ _Appears in:_ | `oauth2` | UpstreamProviderTypeOAuth2 is for pure OAuth 2.0 providers with explicit endpoints<br /> | -#### api.v1alpha1.UserInfoConfig +#### api.v1beta1.UserInfoConfig @@ -3082,20 +3161,20 @@ like GitHub's /user API. _Appears in:_ -- [api.v1alpha1.OAuth2UpstreamConfig](#apiv1alpha1oauth2upstreamconfig) -- [api.v1alpha1.OIDCUpstreamConfig](#apiv1alpha1oidcupstreamconfig) +- [api.v1beta1.OAuth2UpstreamConfig](#apiv1beta1oauth2upstreamconfig) +- [api.v1beta1.OIDCUpstreamConfig](#apiv1beta1oidcupstreamconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `endpointUrl` _string_ | EndpointURL is the URL of the userinfo endpoint. | | Pattern: `^https?://.*$` <br />Required: \{\} <br /> | | `httpMethod` _string_ | HTTPMethod is the HTTP method to use for the userinfo request.<br />If not specified, defaults to GET. | | Enum: [GET POST] <br />Optional: \{\} <br /> | | `additionalHeaders` _object (keys:string, values:string)_ | AdditionalHeaders contains extra headers to include in the userinfo request.<br />Useful for providers that require specific headers (e.g., GitHub's Accept header). | | Optional: \{\} <br /> | -| `fieldMapping` _[api.v1alpha1.UserInfoFieldMapping](#apiv1alpha1userinfofieldmapping)_ | FieldMapping contains custom field mapping configuration for non-standard providers.<br />If nil, standard OIDC field names are used ("sub", "name", "email"). | | Optional: \{\} <br /> | +| `fieldMapping` _[api.v1beta1.UserInfoFieldMapping](#apiv1beta1userinfofieldmapping)_ | FieldMapping contains custom field mapping configuration for non-standard providers.<br />If nil, standard OIDC field names are used ("sub", "name", "email"). | | Optional: \{\} <br /> | -#### api.v1alpha1.UserInfoFieldMapping +#### api.v1beta1.UserInfoFieldMapping -_Underlying type:_ _[api.v1alpha1.struct{SubjectFields []string "json:\"subjectFields,omitempty\""; NameFields []string "json:\"nameFields,omitempty\""; EmailFields []string "json:\"emailFields,omitempty\""}](#apiv1alpha1struct{subjectfields []string "json:\"subjectfields,omitempty\""; namefields []string "json:\"namefields,omitempty\""; emailfields []string "json:\"emailfields,omitempty\""})_ +_Underlying type:_ _[api.v1beta1.struct{SubjectFields []string "json:\"subjectFields,omitempty\""; NameFields []string "json:\"nameFields,omitempty\""; EmailFields []string "json:\"emailFields,omitempty\""}](#apiv1beta1struct{subjectfields []string "json:\"subjectfields,omitempty\""; namefields []string "json:\"namefields,omitempty\""; emailfields []string "json:\"emailfields,omitempty\""})_ UserInfoFieldMapping maps provider-specific field names to standard UserInfo fields. This allows adapting non-standard provider responses to the canonical UserInfo structure. @@ -3112,11 +3191,11 @@ Example for GitHub: _Appears in:_ -- [api.v1alpha1.UserInfoConfig](#apiv1alpha1userinfoconfig) +- [api.v1beta1.UserInfoConfig](#apiv1beta1userinfoconfig) -#### api.v1alpha1.ValidationStatus +#### api.v1beta1.ValidationStatus _Underlying type:_ _string_ @@ -3126,7 +3205,7 @@ _Validation:_ - Enum: [Valid Invalid Unknown] _Appears in:_ -- [api.v1alpha1.VirtualMCPCompositeToolDefinitionStatus](#apiv1alpha1virtualmcpcompositetooldefinitionstatus) +- [api.v1beta1.VirtualMCPCompositeToolDefinitionStatus](#apiv1beta1virtualmcpcompositetooldefinitionstatus) | Field | Description | | --- | --- | @@ -3135,7 +3214,7 @@ _Appears in:_ | `Unknown` | ValidationStatusUnknown indicates validation hasn't been performed yet<br /> | -#### api.v1alpha1.VirtualMCPCompositeToolDefinition +#### api.v1beta1.VirtualMCPCompositeToolDefinition @@ -3146,20 +3225,20 @@ by multiple VirtualMCPServer instances _Appears in:_ -- [api.v1alpha1.VirtualMCPCompositeToolDefinitionList](#apiv1alpha1virtualmcpcompositetooldefinitionlist) +- [api.v1beta1.VirtualMCPCompositeToolDefinitionList](#apiv1beta1virtualmcpcompositetooldefinitionlist) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `VirtualMCPCompositeToolDefinition` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[api.v1alpha1.VirtualMCPCompositeToolDefinitionSpec](#apiv1alpha1virtualmcpcompositetooldefinitionspec)_ | | | | -| `status` _[api.v1alpha1.VirtualMCPCompositeToolDefinitionStatus](#apiv1alpha1virtualmcpcompositetooldefinitionstatus)_ | | | | +| `spec` _[api.v1beta1.VirtualMCPCompositeToolDefinitionSpec](#apiv1beta1virtualmcpcompositetooldefinitionspec)_ | | | | +| `status` _[api.v1beta1.VirtualMCPCompositeToolDefinitionStatus](#apiv1beta1virtualmcpcompositetooldefinitionstatus)_ | | | | -#### api.v1alpha1.VirtualMCPCompositeToolDefinitionList +#### api.v1beta1.VirtualMCPCompositeToolDefinitionList @@ -3171,15 +3250,15 @@ VirtualMCPCompositeToolDefinitionList contains a list of VirtualMCPCompositeTool | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `VirtualMCPCompositeToolDefinitionList` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[api.v1alpha1.VirtualMCPCompositeToolDefinition](#apiv1alpha1virtualmcpcompositetooldefinition) array_ | | | | +| `items` _[api.v1beta1.VirtualMCPCompositeToolDefinition](#apiv1beta1virtualmcpcompositetooldefinition) array_ | | | | -#### api.v1alpha1.VirtualMCPCompositeToolDefinitionSpec +#### api.v1beta1.VirtualMCPCompositeToolDefinitionSpec @@ -3190,7 +3269,7 @@ between CLI and operator usage. _Appears in:_ -- [api.v1alpha1.VirtualMCPCompositeToolDefinition](#apiv1alpha1virtualmcpcompositetooldefinition) +- [api.v1beta1.VirtualMCPCompositeToolDefinition](#apiv1beta1virtualmcpcompositetooldefinition) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -3202,7 +3281,7 @@ _Appears in:_ | `output` _[vmcp.config.OutputConfig](#vmcpconfigoutputconfig)_ | Output defines the structured output schema for this workflow.<br />If not specified, the workflow returns the last step's output (backward compatible). | | Optional: \{\} <br /> | -#### api.v1alpha1.VirtualMCPCompositeToolDefinitionStatus +#### api.v1beta1.VirtualMCPCompositeToolDefinitionStatus @@ -3211,18 +3290,18 @@ VirtualMCPCompositeToolDefinitionStatus defines the observed state of VirtualMCP _Appears in:_ -- [api.v1alpha1.VirtualMCPCompositeToolDefinition](#apiv1alpha1virtualmcpcompositetooldefinition) +- [api.v1beta1.VirtualMCPCompositeToolDefinition](#apiv1beta1virtualmcpcompositetooldefinition) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `validationStatus` _[api.v1alpha1.ValidationStatus](#apiv1alpha1validationstatus)_ | ValidationStatus indicates the validation state of the workflow<br />- Valid: Workflow structure is valid<br />- Invalid: Workflow has validation errors | | Enum: [Valid Invalid Unknown] <br />Optional: \{\} <br /> | +| `validationStatus` _[api.v1beta1.ValidationStatus](#apiv1beta1validationstatus)_ | ValidationStatus indicates the validation state of the workflow<br />- Valid: Workflow structure is valid<br />- Invalid: Workflow has validation errors | | Enum: [Valid Invalid Unknown] <br />Optional: \{\} <br /> | | `validationErrors` _string array_ | ValidationErrors contains validation error messages if ValidationStatus is Invalid | | Optional: \{\} <br /> | | `referencingVirtualServers` _string array_ | ReferencingVirtualServers lists VirtualMCPServer resources that reference this workflow<br />This helps track which servers need to be reconciled when this workflow changes | | Optional: \{\} <br /> | | `observedGeneration` _integer_ | ObservedGeneration is the most recent generation observed for this VirtualMCPCompositeToolDefinition<br />It corresponds to the resource's generation, which is updated on mutation by the API Server | | Optional: \{\} <br /> | | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the workflow's state | | Optional: \{\} <br /> | -#### api.v1alpha1.VirtualMCPServer +#### api.v1beta1.VirtualMCPServer @@ -3232,20 +3311,20 @@ VirtualMCPServer aggregates multiple backend MCPServers into a unified endpoint _Appears in:_ -- [api.v1alpha1.VirtualMCPServerList](#apiv1alpha1virtualmcpserverlist) +- [api.v1beta1.VirtualMCPServerList](#apiv1beta1virtualmcpserverlist) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `VirtualMCPServer` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[api.v1alpha1.VirtualMCPServerSpec](#apiv1alpha1virtualmcpserverspec)_ | | | | -| `status` _[api.v1alpha1.VirtualMCPServerStatus](#apiv1alpha1virtualmcpserverstatus)_ | | | | +| `spec` _[api.v1beta1.VirtualMCPServerSpec](#apiv1beta1virtualmcpserverspec)_ | | | | +| `status` _[api.v1beta1.VirtualMCPServerStatus](#apiv1beta1virtualmcpserverstatus)_ | | | | -#### api.v1alpha1.VirtualMCPServerList +#### api.v1beta1.VirtualMCPServerList @@ -3257,15 +3336,15 @@ VirtualMCPServerList contains a list of VirtualMCPServer | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | | `kind` _string_ | `VirtualMCPServerList` | | | | `kind` _string_ | Kind is a string value representing the REST resource this object represents.<br />Servers may infer this from the endpoint the client submits requests to.<br />Cannot be updated.<br />In CamelCase.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\} <br /> | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.<br />Servers should convert recognized schemas to the latest internal value, and<br />may reject unrecognized values.<br />More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\} <br /> | | `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `items` _[api.v1alpha1.VirtualMCPServer](#apiv1alpha1virtualmcpserver) array_ | | | | +| `items` _[api.v1beta1.VirtualMCPServer](#apiv1beta1virtualmcpserver) array_ | | | | -#### api.v1alpha1.VirtualMCPServerPhase +#### api.v1beta1.VirtualMCPServerPhase _Underlying type:_ _string_ @@ -3275,7 +3354,7 @@ _Validation:_ - Enum: [Pending Ready Degraded Failed] _Appears in:_ -- [api.v1alpha1.VirtualMCPServerStatus](#apiv1alpha1virtualmcpserverstatus) +- [api.v1beta1.VirtualMCPServerStatus](#apiv1beta1virtualmcpserverstatus) | Field | Description | | --- | --- | @@ -3285,7 +3364,7 @@ _Appears in:_ | `Failed` | VirtualMCPServerPhaseFailed indicates the VirtualMCPServer has failed<br /> | -#### api.v1alpha1.VirtualMCPServerSpec +#### api.v1beta1.VirtualMCPServerSpec @@ -3294,26 +3373,26 @@ VirtualMCPServerSpec defines the desired state of VirtualMCPServer _Appears in:_ -- [api.v1alpha1.VirtualMCPServer](#apiv1alpha1virtualmcpserver) +- [api.v1beta1.VirtualMCPServer](#apiv1beta1virtualmcpserver) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `incomingAuth` _[api.v1alpha1.IncomingAuthConfig](#apiv1alpha1incomingauthconfig)_ | IncomingAuth configures authentication for clients connecting to the Virtual MCP server.<br />Must be explicitly set - use "anonymous" type when no authentication is required.<br />This field takes precedence over config.IncomingAuth and should be preferred because it<br />supports Kubernetes-native secret references (SecretKeyRef, ConfigMapRef) for secure<br />dynamic discovery of credentials, rather than requiring secrets to be embedded in config. | | Required: \{\} <br /> | -| `outgoingAuth` _[api.v1alpha1.OutgoingAuthConfig](#apiv1alpha1outgoingauthconfig)_ | OutgoingAuth configures authentication from Virtual MCP to backend MCPServers.<br />This field takes precedence over config.OutgoingAuth and should be preferred because it<br />supports Kubernetes-native secret references (SecretKeyRef, ConfigMapRef) for secure<br />dynamic discovery of credentials, rather than requiring secrets to be embedded in config. | | Optional: \{\} <br /> | +| `incomingAuth` _[api.v1beta1.IncomingAuthConfig](#apiv1beta1incomingauthconfig)_ | IncomingAuth configures authentication for clients connecting to the Virtual MCP server.<br />Must be explicitly set - use "anonymous" type when no authentication is required.<br />This field takes precedence over config.IncomingAuth and should be preferred because it<br />supports Kubernetes-native secret references (SecretKeyRef, ConfigMapRef) for secure<br />dynamic discovery of credentials, rather than requiring secrets to be embedded in config. | | Required: \{\} <br /> | +| `outgoingAuth` _[api.v1beta1.OutgoingAuthConfig](#apiv1beta1outgoingauthconfig)_ | OutgoingAuth configures authentication from Virtual MCP to backend MCPServers.<br />This field takes precedence over config.OutgoingAuth and should be preferred because it<br />supports Kubernetes-native secret references (SecretKeyRef, ConfigMapRef) for secure<br />dynamic discovery of credentials, rather than requiring secrets to be embedded in config. | | Optional: \{\} <br /> | | `serviceType` _string_ | ServiceType specifies the Kubernetes service type for the Virtual MCP server | ClusterIP | Enum: [ClusterIP NodePort LoadBalancer] <br />Optional: \{\} <br /> | | `sessionAffinity` _string_ | SessionAffinity controls whether the Service routes repeated client connections to the same pod.<br />MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default.<br />Set to "None" for stateless servers or when using an external load balancer with its own affinity. | ClientIP | Enum: [ClientIP None] <br />Optional: \{\} <br /> | | `serviceAccount` _string_ | ServiceAccount is the name of an already existing service account to use by the Virtual MCP server.<br />If not specified, a ServiceAccount will be created automatically and used by the Virtual MCP server. | | Optional: \{\} <br /> | | `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the Virtual MCP server<br />This allows for customizing the pod configuration beyond what is provided by the other fields.<br />Note that to modify the specific container the Virtual MCP server runs in, you must specify<br />the 'vmcp' container name in the PodTemplateSpec.<br />This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object <br />Optional: \{\} <br /> | -| `groupRef` _[api.v1alpha1.MCPGroupRef](#apiv1alpha1mcpgroupref)_ | GroupRef references the MCPGroup that defines backend workloads.<br />The referenced MCPGroup must exist in the same namespace. | | Required: \{\} <br /> | +| `groupRef` _[api.v1beta1.MCPGroupRef](#apiv1beta1mcpgroupref)_ | GroupRef references the MCPGroup that defines backend workloads.<br />The referenced MCPGroup must exist in the same namespace. | | Required: \{\} <br /> | | `config` _[vmcp.config.Config](#vmcpconfigconfig)_ | Config is the Virtual MCP server configuration.<br />The audit config from here is also supported, but not required. | | Type: object <br />Optional: \{\} <br /> | -| `telemetryConfigRef` _[api.v1alpha1.MCPTelemetryConfigReference](#apiv1alpha1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.<br />The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer.<br />Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\} <br /> | -| `embeddingServerRef` _[api.v1alpha1.EmbeddingServerRef](#apiv1alpha1embeddingserverref)_ | EmbeddingServerRef references an existing EmbeddingServer resource by name.<br />When the optimizer is enabled, this field is required to point to a ready EmbeddingServer<br />that provides embedding capabilities.<br />The referenced EmbeddingServer must exist in the same namespace and be ready. | | Optional: \{\} <br /> | -| `authServerConfig` _[api.v1alpha1.EmbeddedAuthServerConfig](#apiv1alpha1embeddedauthserverconfig)_ | AuthServerConfig configures an embedded OAuth authorization server.<br />When set, the vMCP server acts as an OIDC issuer, drives users through<br />upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the<br />IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfigRef<br />so that tokens it issues are accepted by the vMCP's incoming auth middleware.<br />When nil, IncomingAuth uses an external IDP and behavior is unchanged. | | Optional: \{\} <br /> | +| `telemetryConfigRef` _[api.v1beta1.MCPTelemetryConfigReference](#apiv1beta1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.<br />The referenced MCPTelemetryConfig must exist in the same namespace as this VirtualMCPServer.<br />Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\} <br /> | +| `embeddingServerRef` _[api.v1beta1.EmbeddingServerRef](#apiv1beta1embeddingserverref)_ | EmbeddingServerRef references an existing EmbeddingServer resource by name.<br />When the optimizer is enabled, this field is required to point to a ready EmbeddingServer<br />that provides embedding capabilities.<br />The referenced EmbeddingServer must exist in the same namespace and be ready. | | Optional: \{\} <br /> | +| `authServerConfig` _[api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig)_ | AuthServerConfig configures an embedded OAuth authorization server.<br />When set, the vMCP server acts as an OIDC issuer, drives users through<br />upstream IDPs, and issues ToolHive JWTs. The embedded AS becomes the<br />IncomingAuth OIDC provider — its issuer must match IncomingAuth.OIDCConfigRef<br />so that tokens it issues are accepted by the vMCP's incoming auth middleware.<br />When nil, IncomingAuth uses an external IDP and behavior is unchanged. | | Optional: \{\} <br /> | | `replicas` _integer_ | Replicas is the desired number of vMCP pod replicas.<br />VirtualMCPServer creates a single Deployment for the vMCP aggregator process,<br />so there is only one replicas field (unlike MCPServer which has separate<br />Replicas and BackendReplicas for its two Deployments).<br />When nil, the operator does not set Deployment.Spec.Replicas, leaving replica<br />management to an HPA or other external controller. | | Minimum: 0 <br />Optional: \{\} <br /> | -| `sessionStorage` _[api.v1alpha1.SessionStorageConfig](#apiv1alpha1sessionstorageconfig)_ | SessionStorage configures session storage for stateful horizontal scaling.<br />When nil, no session storage is configured. | | Optional: \{\} <br /> | +| `sessionStorage` _[api.v1beta1.SessionStorageConfig](#apiv1beta1sessionstorageconfig)_ | SessionStorage configures session storage for stateful horizontal scaling.<br />When nil, no session storage is configured. | | Optional: \{\} <br /> | -#### api.v1alpha1.VirtualMCPServerStatus +#### api.v1beta1.VirtualMCPServerStatus @@ -3322,22 +3401,22 @@ VirtualMCPServerStatus defines the observed state of VirtualMCPServer _Appears in:_ -- [api.v1alpha1.VirtualMCPServer](#apiv1alpha1virtualmcpserver) +- [api.v1beta1.VirtualMCPServer](#apiv1beta1virtualmcpserver) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the VirtualMCPServer's state | | Optional: \{\} <br /> | | `observedGeneration` _integer_ | ObservedGeneration is the most recent generation observed for this VirtualMCPServer | | Optional: \{\} <br /> | -| `phase` _[api.v1alpha1.VirtualMCPServerPhase](#apiv1alpha1virtualmcpserverphase)_ | Phase is the current phase of the VirtualMCPServer | Pending | Enum: [Pending Ready Degraded Failed] <br />Optional: \{\} <br /> | +| `phase` _[api.v1beta1.VirtualMCPServerPhase](#apiv1beta1virtualmcpserverphase)_ | Phase is the current phase of the VirtualMCPServer | Pending | Enum: [Pending Ready Degraded Failed] <br />Optional: \{\} <br /> | | `message` _string_ | Message provides additional information about the current phase | | Optional: \{\} <br /> | | `url` _string_ | URL is the URL where the Virtual MCP server can be accessed | | Optional: \{\} <br /> | -| `discoveredBackends` _[api.v1alpha1.DiscoveredBackend](#apiv1alpha1discoveredbackend) array_ | DiscoveredBackends lists discovered backend configurations from the MCPGroup | | Optional: \{\} <br /> | +| `discoveredBackends` _[api.v1beta1.DiscoveredBackend](#apiv1beta1discoveredbackend) array_ | DiscoveredBackends lists discovered backend configurations from the MCPGroup | | Optional: \{\} <br /> | | `backendCount` _integer_ | BackendCount is the number of routable backends (ready + unauthenticated).<br />Excludes unavailable, degraded, and unknown backends. | | Optional: \{\} <br /> | | `oidcConfigHash` _string_ | OIDCConfigHash is the hash of the referenced MCPOIDCConfig spec for change detection.<br />Only populated when IncomingAuth.OIDCConfigRef is set. | | Optional: \{\} <br /> | | `telemetryConfigHash` _string_ | TelemetryConfigHash is the hash of the referenced MCPTelemetryConfig spec for change detection.<br />Only populated when TelemetryConfigRef is set. | | Optional: \{\} <br /> | -#### api.v1alpha1.Volume +#### api.v1beta1.Volume @@ -3346,7 +3425,7 @@ Volume represents a volume to mount in a container _Appears in:_ -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -3356,7 +3435,7 @@ _Appears in:_ | `readOnly` _boolean_ | ReadOnly specifies whether the volume should be mounted read-only | false | Optional: \{\} <br /> | -#### api.v1alpha1.WorkloadReference +#### api.v1beta1.WorkloadReference @@ -3366,10 +3445,10 @@ Namespace is implicit — cross-namespace references are not supported. _Appears in:_ -- [api.v1alpha1.MCPExternalAuthConfigStatus](#apiv1alpha1mcpexternalauthconfigstatus) -- [api.v1alpha1.MCPOIDCConfigStatus](#apiv1alpha1mcpoidcconfigstatus) -- [api.v1alpha1.MCPTelemetryConfigStatus](#apiv1alpha1mcptelemetryconfigstatus) -- [api.v1alpha1.MCPToolConfigStatus](#apiv1alpha1mcptoolconfigstatus) +- [api.v1beta1.MCPExternalAuthConfigStatus](#apiv1beta1mcpexternalauthconfigstatus) +- [api.v1beta1.MCPOIDCConfigStatus](#apiv1beta1mcpoidcconfigstatus) +- [api.v1beta1.MCPTelemetryConfigStatus](#apiv1beta1mcptelemetryconfigstatus) +- [api.v1beta1.MCPToolConfigStatus](#apiv1beta1mcptoolconfigstatus) | Field | Description | Default | Validation | | --- | --- | --- | --- | diff --git a/docs/operator/crd-ref-config.yaml b/docs/operator/crd-ref-config.yaml index 243c3b1823..8f2a9c0892 100644 --- a/docs/operator/crd-ref-config.yaml +++ b/docs/operator/crd-ref-config.yaml @@ -2,7 +2,7 @@ processor: ignoreTypes: [] ignoreFields: [] customMarkers: - # Opt-in marker for types outside api/v1alpha1 to be documented + # Opt-in marker for types outside api/v1beta1 to be documented - name: "gendoc" target: type render: diff --git a/docs/operator/restart-annotation.md b/docs/operator/restart-annotation.md index 561f700571..622dba4066 100644 --- a/docs/operator/restart-annotation.md +++ b/docs/operator/restart-annotation.md @@ -36,7 +36,7 @@ The MCPServer operator supports triggering pod restarts through specific annotat ### Basic Rolling Restart ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: my-mcpserver @@ -49,7 +49,7 @@ spec: ### Immediate Restart ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: my-mcpserver diff --git a/docs/operator/templates/markdown/type.tpl b/docs/operator/templates/markdown/type.tpl index 67aa4c529e..3428b854a1 100644 --- a/docs/operator/templates/markdown/type.tpl +++ b/docs/operator/templates/markdown/type.tpl @@ -42,9 +42,9 @@ {{- define "type" -}} {{- $type := . -}} {{- if markdownShouldRenderType $type -}} - {{- /* Filter: only render types with +gendoc marker OR in api/v1alpha1 package */ -}} + {{- /* Filter: only render types with +gendoc marker OR in api/v1beta1 package */ -}} {{- $hasGendoc := index $type.Markers "gendoc" -}} - {{- $isAPIType := hasSuffix "/api/v1alpha1" $type.Package -}} + {{- $isAPIType := hasSuffix "/api/v1beta1" $type.Package -}} {{- if or $hasGendoc $isAPIType -}} {{- /* Extract last two path segments from package for disambiguation */ -}} {{- $pkgParts := splitList "/" $type.Package -}} @@ -73,7 +73,7 @@ _Validation:_ {{- $filteredRefs := list -}} {{- range $type.SortedReferences -}} {{- $refHasGendoc := index .Markers "gendoc" -}} - {{- $refIsAPIType := hasSuffix "/api/v1alpha1" .Package -}} + {{- $refIsAPIType := hasSuffix "/api/v1beta1" .Package -}} {{- if or $refHasGendoc $refIsAPIType -}} {{- $filteredRefs = append $filteredRefs . -}} {{- end -}} diff --git a/docs/operator/toolconfig-reconciliation.md b/docs/operator/toolconfig-reconciliation.md index eee011c2f5..667de32fc2 100644 --- a/docs/operator/toolconfig-reconciliation.md +++ b/docs/operator/toolconfig-reconciliation.md @@ -80,7 +80,7 @@ graph TD MCPServers reference a MCPToolConfig through the `toolConfigRef` field: ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: my-server diff --git a/docs/operator/virtualmcpcompositetooldefinition-guide.md b/docs/operator/virtualmcpcompositetooldefinition-guide.md index 2fcbdb32ca..4c5e27cde8 100644 --- a/docs/operator/virtualmcpcompositetooldefinition-guide.md +++ b/docs/operator/virtualmcpcompositetooldefinition-guide.md @@ -27,7 +27,7 @@ A `VirtualMCPCompositeToolDefinition` consists of: ### Name and Description ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: deploy-app @@ -430,7 +430,7 @@ This applies to all numeric comparisons (`eq`, `ne`, `lt`, `le`, `gt`, `ge`) whe ### Example 1: Simple Deployment ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: simple-deploy @@ -463,7 +463,7 @@ spec: ### Example 2: Deploy with Verification ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: deploy-and-verify @@ -537,7 +537,7 @@ spec: ### Example 3: Incident Investigation ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: investigate-incident @@ -621,7 +621,7 @@ spec: ### Example 4: Multi-Stage Deployment ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: canary-deployment @@ -727,7 +727,7 @@ spec: To use a composite workflow in a Virtual MCP Server: ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: production-vmcp diff --git a/docs/operator/virtualmcpserver-api.md b/docs/operator/virtualmcpserver-api.md index 8249045c4c..2417da9754 100644 --- a/docs/operator/virtualmcpserver-api.md +++ b/docs/operator/virtualmcpserver-api.md @@ -13,7 +13,7 @@ The `VirtualMCPServer` CRD enables aggregation of multiple backend MCPServers in ## API Group and Version - **Group**: `toolhive.stacklok.dev` -- **Version**: `v1alpha1` +- **Version**: \`v1beta1\` - **Kind**: `VirtualMCPServer` ## Resource Names @@ -56,7 +56,7 @@ A `VirtualMCPServer` aggregates three types of backends from the referenced `MCP **Example: MCPServerEntry backend** ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServerEntry metadata: name: context7 @@ -448,7 +448,7 @@ The most recent generation observed for this VirtualMCPServer. ## Complete Example ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: engineering-vmcp diff --git a/docs/operator/virtualmcpserver-kubernetes-guide.md b/docs/operator/virtualmcpserver-kubernetes-guide.md index 859159353d..379ca9aaa6 100644 --- a/docs/operator/virtualmcpserver-kubernetes-guide.md +++ b/docs/operator/virtualmcpserver-kubernetes-guide.md @@ -50,7 +50,7 @@ The `--format k8s` option automatically converts to MCPServer CRD format. Review the exported YAML and make any necessary adjustments: ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: my-server @@ -163,7 +163,7 @@ Create an MCPGroup to organize the backends: ```yaml # mcp-group.yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: my-services @@ -178,7 +178,7 @@ Add `groupRef` to each exported MCPServer: ```yaml # github.yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: github @@ -200,7 +200,7 @@ Create a VirtualMCPServer to aggregate the backends: ```yaml # virtual-mcp-server.yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: my-vmcp @@ -362,7 +362,7 @@ thv export backend2 ./backend2.yaml --format k8s # Create manifests (add groupRef to each backend YAML) cat > resources.yaml <<EOF -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: services @@ -370,7 +370,7 @@ metadata: # Include backend1.yaml content with groupRef: {name: services} # Include backend2.yaml content with groupRef: {name: services} --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: services-vmcp @@ -438,7 +438,7 @@ For remote MCP servers that don't need a dedicated proxy, use `MCPServerEntry` i **Before (MCPRemoteProxy — deploys a proxy pod):** ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRemoteProxy metadata: name: context7 @@ -452,7 +452,7 @@ spec: **After (MCPServerEntry — zero infrastructure):** ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServerEntry metadata: name: context7 diff --git a/docs/operator/virtualmcpserver-observability.md b/docs/operator/virtualmcpserver-observability.md index 421b76cb7f..d322f34c70 100644 --- a/docs/operator/virtualmcpserver-observability.md +++ b/docs/operator/virtualmcpserver-observability.md @@ -162,7 +162,7 @@ and VirtualMCPServer resources. ```yaml # Shared telemetry configuration -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPTelemetryConfig metadata: name: shared-otel @@ -180,7 +180,7 @@ spec: enabled: true --- # VirtualMCPServer referencing shared telemetry config -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: my-vmcp @@ -198,13 +198,13 @@ See [`examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml`](../../exampl for a complete example with an MCPGroup and backend MCPServer. **Inline (deprecated)**: The inline `spec.config.telemetry` field still works -but is deprecated and will be removed in v1beta1. It is mutually exclusive with +but is deprecated and will be removed in a future API version. It is mutually exclusive with `telemetryConfigRef` (CEL enforced). Migrate to `telemetryConfigRef` to use the shared MCPTelemetryConfig pattern. ```yaml # Deprecated — use telemetryConfigRef instead -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: my-vmcp diff --git a/docs/redis-storage.md b/docs/redis-storage.md index 1c195d2800..4a30abe9f6 100644 --- a/docs/redis-storage.md +++ b/docs/redis-storage.md @@ -28,7 +28,7 @@ By default, ToolHive's embedded auth server uses in-memory storage. This works w When using the ToolHive operator, Redis storage is configured through the `storage` field in the embedded auth server section of `MCPExternalAuthConfig`. ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: my-auth-config @@ -236,7 +236,7 @@ stringData: password: "<your-secure-password>" --- # MCPExternalAuthConfig with Redis storage -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: my-auth-config diff --git a/docs/server/docs.go b/docs/server/docs.go index e24ca1e5df..27f4c5ae10 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -44,7 +44,7 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket": { + "github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket": { "description": "PerUser token bucket configuration for this tool.\n+optional", "properties": { "maxTokens": { @@ -57,19 +57,19 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitConfig": { + "github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig": { "description": "RateLimitConfig contains the CRD rate limiting configuration.\nWhen set, rate limiting middleware is added to the proxy middleware chain.", "properties": { "perUser": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" }, "shared": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" }, "tools": { "description": "Tools defines per-tool rate limit overrides.\nEach entry applies additional rate limits to calls targeting a specific tool name.\nA request must pass both the server-level limit and the per-tool limit.\n+listType=map\n+listMapKey=name\n+optional", "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.ToolRateLimitConfig" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.ToolRateLimitConfig" }, "type": "array", "uniqueItems": false @@ -77,17 +77,17 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.ToolRateLimitConfig": { + "github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.ToolRateLimitConfig": { "properties": { "name": { "description": "Name is the MCP tool name this limit applies to.\n+kubebuilder:validation:Required\n+kubebuilder:validation:MinLength=1", "type": "string" }, "perUser": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" }, "shared": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" } }, "type": "object" @@ -1270,7 +1270,7 @@ const docTemplate = `{ "uniqueItems": false }, "rate_limit_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitConfig" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig" }, "rate_limit_namespace": { "description": "RateLimitNamespace is the Kubernetes namespace for Redis key derivation.", diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 32bf65334b..e15d4dd48b 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -37,7 +37,7 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket": { + "github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket": { "description": "PerUser token bucket configuration for this tool.\n+optional", "properties": { "maxTokens": { @@ -50,19 +50,19 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitConfig": { + "github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig": { "description": "RateLimitConfig contains the CRD rate limiting configuration.\nWhen set, rate limiting middleware is added to the proxy middleware chain.", "properties": { "perUser": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" }, "shared": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" }, "tools": { "description": "Tools defines per-tool rate limit overrides.\nEach entry applies additional rate limits to calls targeting a specific tool name.\nA request must pass both the server-level limit and the per-tool limit.\n+listType=map\n+listMapKey=name\n+optional", "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.ToolRateLimitConfig" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.ToolRateLimitConfig" }, "type": "array", "uniqueItems": false @@ -70,17 +70,17 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.ToolRateLimitConfig": { + "github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.ToolRateLimitConfig": { "properties": { "name": { "description": "Name is the MCP tool name this limit applies to.\n+kubebuilder:validation:Required\n+kubebuilder:validation:MinLength=1", "type": "string" }, "perUser": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" }, "shared": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket" } }, "type": "object" @@ -1263,7 +1263,7 @@ "uniqueItems": false }, "rate_limit_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitConfig" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig" }, "rate_limit_namespace": { "description": "RateLimitNamespace is the Kubernetes namespace for Redis key derivation.", diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 5fc9d24520..e6c07a3eea 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -31,7 +31,7 @@ components: description: Version is the schema version of the registry type: string type: object - github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket: + github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket: description: |- PerUser token bucket configuration for this tool. +optional @@ -47,15 +47,15 @@ components: refillPeriod: $ref: '#/components/schemas/v1.Duration' type: object - github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitConfig: + github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig: description: |- RateLimitConfig contains the CRD rate limiting configuration. When set, rate limiting middleware is added to the proxy middleware chain. properties: perUser: - $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket' + $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket' shared: - $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket' + $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket' tools: description: |- Tools defines per-tool rate limit overrides. @@ -65,11 +65,11 @@ components: +listMapKey=name +optional items: - $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.ToolRateLimitConfig' + $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.ToolRateLimitConfig' type: array uniqueItems: false type: object - github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.ToolRateLimitConfig: + github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.ToolRateLimitConfig: properties: name: description: |- @@ -78,9 +78,9 @@ components: +kubebuilder:validation:MinLength=1 type: string perUser: - $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket' + $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket' shared: - $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitBucket' + $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitBucket' type: object github_com_stacklok_toolhive_pkg_audit.Config: description: |- @@ -1213,7 +1213,7 @@ components: type: array uniqueItems: false rate_limit_config: - $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1alpha1.RateLimitConfig' + $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig' rate_limit_namespace: description: RateLimitNamespace is the Kubernetes namespace for Redis key derivation. diff --git a/examples/mcpserver-with-audit.yaml b/examples/mcpserver-with-audit.yaml index 4a621fc02e..8c296de273 100644 --- a/examples/mcpserver-with-audit.yaml +++ b/examples/mcpserver-with-audit.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: example-server-with-audit diff --git a/examples/operator/embedding-servers/basic-embedding.yaml b/examples/operator/embedding-servers/basic-embedding.yaml index c4c2f01093..8416abfe06 100644 --- a/examples/operator/embedding-servers/basic-embedding.yaml +++ b/examples/operator/embedding-servers/basic-embedding.yaml @@ -1,6 +1,6 @@ # Basic EmbeddingServer example with minimal configuration # This creates an embedding server using the default text-embeddings-inference image -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: basic-embedding diff --git a/examples/operator/embedding-servers/embedding-advanced.yaml b/examples/operator/embedding-servers/embedding-advanced.yaml index 8c01b5858d..16e5280f48 100644 --- a/examples/operator/embedding-servers/embedding-advanced.yaml +++ b/examples/operator/embedding-servers/embedding-advanced.yaml @@ -1,5 +1,5 @@ # Advanced EmbeddingServer configuration with all features -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: advanced-embedding diff --git a/examples/operator/embedding-servers/embedding-with-cache.yaml b/examples/operator/embedding-servers/embedding-with-cache.yaml index 6595f69f01..96469e10a4 100644 --- a/examples/operator/embedding-servers/embedding-with-cache.yaml +++ b/examples/operator/embedding-servers/embedding-with-cache.yaml @@ -1,6 +1,6 @@ # EmbeddingServer with persistent model caching # This configuration caches downloaded models in a PVC for faster restarts -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: embedding-with-cache diff --git a/examples/operator/external-auth/complete_example.yaml b/examples/operator/external-auth/complete_example.yaml index ff1fa74d0f..fbcf95b5c3 100644 --- a/examples/operator/external-auth/complete_example.yaml +++ b/examples/operator/external-auth/complete_example.yaml @@ -19,7 +19,7 @@ stringData: --- # External authentication configuration -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: keycloak-token-exchange @@ -47,7 +47,7 @@ spec: --- # MCP Server with external authentication -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: authenticated-fetch diff --git a/examples/operator/external-auth/mcpexternalauthconfig_basic.yaml b/examples/operator/external-auth/mcpexternalauthconfig_basic.yaml index 2df3b35ff2..ced534856e 100644 --- a/examples/operator/external-auth/mcpexternalauthconfig_basic.yaml +++ b/examples/operator/external-auth/mcpexternalauthconfig_basic.yaml @@ -1,6 +1,6 @@ # Basic MCPExternalAuthConfig example with token exchange # This configures external authentication using OAuth2 token exchange -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: oauth-token-exchange diff --git a/examples/operator/external-auth/mcpexternalauthconfig_minimal.yaml b/examples/operator/external-auth/mcpexternalauthconfig_minimal.yaml index 5609abe483..82672f1102 100644 --- a/examples/operator/external-auth/mcpexternalauthconfig_minimal.yaml +++ b/examples/operator/external-auth/mcpexternalauthconfig_minimal.yaml @@ -1,6 +1,6 @@ # Minimal MCPExternalAuthConfig example # This shows the minimum required fields for token exchange configuration -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: minimal-oauth diff --git a/examples/operator/external-auth/mcpremoteproxy_with_bearer_token.yaml b/examples/operator/external-auth/mcpremoteproxy_with_bearer_token.yaml index 9e8ed6f65e..4f31c492e5 100644 --- a/examples/operator/external-auth/mcpremoteproxy_with_bearer_token.yaml +++ b/examples/operator/external-auth/mcpremoteproxy_with_bearer_token.yaml @@ -16,7 +16,7 @@ stringData: --- # External authentication configuration that references the bearer token secret -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: api-bearer-auth @@ -30,7 +30,7 @@ spec: --- # Shared OIDC configuration for incoming client authentication -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPOIDCConfig metadata: name: api-proxy-oidc @@ -42,7 +42,7 @@ spec: --- # MCPRemoteProxy that uses bearer token authentication for outgoing requests -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRemoteProxy metadata: name: api-proxy diff --git a/examples/operator/external-auth/mcpserver_with_external_auth.yaml b/examples/operator/external-auth/mcpserver_with_external_auth.yaml index 9c4869dbf8..b691d56439 100644 --- a/examples/operator/external-auth/mcpserver_with_external_auth.yaml +++ b/examples/operator/external-auth/mcpserver_with_external_auth.yaml @@ -1,6 +1,6 @@ # MCPServer with external authentication configuration # This example shows how to configure an MCP server to use external authentication -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch-with-auth diff --git a/examples/operator/mcp-registries/mcpregistry-configyaml-api.yaml b/examples/operator/mcp-registries/mcpregistry-configyaml-api.yaml index b1f02f8f29..ab732816c0 100644 --- a/examples/operator/mcp-registries/mcpregistry-configyaml-api.yaml +++ b/examples/operator/mcp-registries/mcpregistry-configyaml-api.yaml @@ -12,7 +12,7 @@ # This is functionally equivalent to mcpregistry-api-basic.yaml but uses # the decoupled configYAML path instead of the legacy typed fields. -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRegistry metadata: name: api-configyaml diff --git a/examples/operator/mcp-registries/mcpregistry-configyaml-configmap.yaml b/examples/operator/mcp-registries/mcpregistry-configyaml-configmap.yaml index df3cf42e80..bdfec882e9 100644 --- a/examples/operator/mcp-registries/mcpregistry-configyaml-configmap.yaml +++ b/examples/operator/mcp-registries/mcpregistry-configyaml-configmap.yaml @@ -75,7 +75,7 @@ data: } } --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRegistry metadata: name: configmap-configyaml diff --git a/examples/operator/mcp-registries/mcpregistry-configyaml-git-auth.yaml b/examples/operator/mcp-registries/mcpregistry-configyaml-git-auth.yaml index c78778fed5..aedc734bd5 100644 --- a/examples/operator/mcp-registries/mcpregistry-configyaml-git-auth.yaml +++ b/examples/operator/mcp-registries/mcpregistry-configyaml-git-auth.yaml @@ -33,7 +33,7 @@ stringData: token: "ghp_your_personal_access_token_here" --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRegistry metadata: name: git-auth-configyaml diff --git a/examples/operator/mcp-registries/mcpregistry-configyaml-minimal.yaml b/examples/operator/mcp-registries/mcpregistry-configyaml-minimal.yaml index 7a9f4851bd..9c37d6beb2 100644 --- a/examples/operator/mcp-registries/mcpregistry-configyaml-minimal.yaml +++ b/examples/operator/mcp-registries/mcpregistry-configyaml-minimal.yaml @@ -13,7 +13,7 @@ # This example uses auth mode "anonymous" for development/testing. # For production, use "oauth" mode (see mcpregistry-configyaml-oauth.yaml). -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRegistry metadata: name: minimal-configyaml diff --git a/examples/operator/mcp-registries/mcpregistry-configyaml-oauth.yaml b/examples/operator/mcp-registries/mcpregistry-configyaml-oauth.yaml index 1d41e9891f..d9f60bc1e4 100644 --- a/examples/operator/mcp-registries/mcpregistry-configyaml-oauth.yaml +++ b/examples/operator/mcp-registries/mcpregistry-configyaml-oauth.yaml @@ -15,7 +15,7 @@ # production deployments. --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRegistry metadata: name: oauth-configyaml diff --git a/examples/operator/mcp-registries/mcpregistry-configyaml-pgpass.yaml b/examples/operator/mcp-registries/mcpregistry-configyaml-pgpass.yaml index 7a219d1b13..69fd5e4d34 100644 --- a/examples/operator/mcp-registries/mcpregistry-configyaml-pgpass.yaml +++ b/examples/operator/mcp-registries/mcpregistry-configyaml-pgpass.yaml @@ -37,7 +37,7 @@ stringData: postgres:5432:registry:db_migrator:mymigrationpassword --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRegistry metadata: name: pgpass-configyaml diff --git a/examples/operator/mcp-server-entries/mcpserverentry_basic.yaml b/examples/operator/mcp-server-entries/mcpserverentry_basic.yaml index 332979b0b2..d0834b1a9a 100644 --- a/examples/operator/mcp-server-entries/mcpserverentry_basic.yaml +++ b/examples/operator/mcp-server-entries/mcpserverentry_basic.yaml @@ -5,7 +5,7 @@ # connects directly to the remote URL. --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: remote-tools @@ -14,7 +14,7 @@ spec: description: "Group containing remote MCP server entries" --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServerEntry metadata: name: context7 diff --git a/examples/operator/mcp-server-entries/mcpserverentry_mixed_group.yaml b/examples/operator/mcp-server-entries/mcpserverentry_mixed_group.yaml index 8cecb068f2..70307311ad 100644 --- a/examples/operator/mcp-server-entries/mcpserverentry_mixed_group.yaml +++ b/examples/operator/mcp-server-entries/mcpserverentry_mixed_group.yaml @@ -5,7 +5,7 @@ # both types transparently. --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: engineering-team @@ -15,7 +15,7 @@ spec: --- # Local container-based MCP server -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: github-mcp @@ -28,7 +28,7 @@ spec: --- # Remote MCP server (no pods deployed) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServerEntry metadata: name: context7 @@ -41,7 +41,7 @@ spec: --- # VirtualMCPServer aggregates both backends -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: eng-tools diff --git a/examples/operator/mcp-server-entries/mcpserverentry_with_ca_bundle.yaml b/examples/operator/mcp-server-entries/mcpserverentry_with_ca_bundle.yaml index 3591a43df0..0cdd42bd36 100644 --- a/examples/operator/mcp-server-entries/mcpserverentry_with_ca_bundle.yaml +++ b/examples/operator/mcp-server-entries/mcpserverentry_with_ca_bundle.yaml @@ -17,7 +17,7 @@ data: -----END CERTIFICATE----- --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServerEntry metadata: name: internal-mcp diff --git a/examples/operator/mcp-server-entries/mcpserverentry_with_header_forward.yaml b/examples/operator/mcp-server-entries/mcpserverentry_with_header_forward.yaml index e29ea80634..7e650c531c 100644 --- a/examples/operator/mcp-server-entries/mcpserverentry_with_header_forward.yaml +++ b/examples/operator/mcp-server-entries/mcpserverentry_with_header_forward.yaml @@ -4,7 +4,7 @@ # secret-backed headers (values stored in Kubernetes Secrets). --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServerEntry metadata: name: internal-api diff --git a/examples/operator/mcp-server-entries/mcpserverentry_with_token_exchange.yaml b/examples/operator/mcp-server-entries/mcpserverentry_with_token_exchange.yaml index 61fed2ec19..54e3275844 100644 --- a/examples/operator/mcp-server-entries/mcpserverentry_with_token_exchange.yaml +++ b/examples/operator/mcp-server-entries/mcpserverentry_with_token_exchange.yaml @@ -5,7 +5,7 @@ # VirtualMCPServer applies the auth strategy directly. --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: salesforce-auth @@ -24,7 +24,7 @@ spec: - mcp:write --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServerEntry metadata: name: salesforce-mcp diff --git a/examples/operator/mcp-servers/mcpremoteproxy_with_oidcconfig_ref.yaml b/examples/operator/mcp-servers/mcpremoteproxy_with_oidcconfig_ref.yaml index aa8fa1c9c6..113980c25c 100644 --- a/examples/operator/mcp-servers/mcpremoteproxy_with_oidcconfig_ref.yaml +++ b/examples/operator/mcp-servers/mcpremoteproxy_with_oidcconfig_ref.yaml @@ -1,11 +1,11 @@ # MCPRemoteProxy referencing a shared MCPOIDCConfig via oidcConfigRef. # # This is the preferred pattern — the inline oidcConfig field is deprecated -# and will be removed in v1beta1. +# and will be removed in a future API version. --- # Shared MCPOIDCConfig for the proxy's incoming authentication -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPOIDCConfig metadata: name: proxy-idp @@ -16,7 +16,7 @@ spec: --- # MCPRemoteProxy using oidcConfigRef instead of inline oidcConfig -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRemoteProxy metadata: name: github-proxy diff --git a/examples/operator/mcp-servers/mcpserver_fetch.yaml b/examples/operator/mcp-servers/mcpserver_fetch.yaml index 0d8931a01f..3f6f7e4f41 100644 --- a/examples/operator/mcp-servers/mcpserver_fetch.yaml +++ b/examples/operator/mcp-servers/mcpserver_fetch.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch diff --git a/examples/operator/mcp-servers/mcpserver_fetch_otel.yaml b/examples/operator/mcp-servers/mcpserver_fetch_otel.yaml index 9d71e76e55..da7e62bc3d 100644 --- a/examples/operator/mcp-servers/mcpserver_fetch_otel.yaml +++ b/examples/operator/mcp-servers/mcpserver_fetch_otel.yaml @@ -2,7 +2,7 @@ # # Define telemetry configuration once and reference it from multiple MCPServers. # Each MCPServer provides a unique serviceName for its traces and metrics. -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPTelemetryConfig metadata: name: basic-telemetry @@ -24,7 +24,7 @@ spec: # # The telemetryConfigRef replaces the deprecated inline spec.telemetry field. # serviceName provides a unique OTel service name for this server's telemetry. -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch diff --git a/examples/operator/mcp-servers/mcpserver_fetch_tools_filter.yaml b/examples/operator/mcp-servers/mcpserver_fetch_tools_filter.yaml index b7a83f144a..e7b3d42e55 100644 --- a/examples/operator/mcp-servers/mcpserver_fetch_tools_filter.yaml +++ b/examples/operator/mcp-servers/mcpserver_fetch_tools_filter.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPToolConfig metadata: name: fetch-tools @@ -7,7 +7,7 @@ spec: toolsFilter: - fetch --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch diff --git a/examples/operator/mcp-servers/mcpserver_github.yaml b/examples/operator/mcp-servers/mcpserver_github.yaml index 3e43ee7dd5..7f6486f081 100644 --- a/examples/operator/mcp-servers/mcpserver_github.yaml +++ b/examples/operator/mcp-servers/mcpserver_github.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: github diff --git a/examples/operator/mcp-servers/mcpserver_mkp.yaml b/examples/operator/mcp-servers/mcpserver_mkp.yaml index e79e791f79..8062e5f553 100644 --- a/examples/operator/mcp-servers/mcpserver_mkp.yaml +++ b/examples/operator/mcp-servers/mcpserver_mkp.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: mkp diff --git a/examples/operator/mcp-servers/mcpserver_with_oidcconfig_ref.yaml b/examples/operator/mcp-servers/mcpserver_with_oidcconfig_ref.yaml index bd6b86376b..d71c16231a 100644 --- a/examples/operator/mcp-servers/mcpserver_with_oidcconfig_ref.yaml +++ b/examples/operator/mcp-servers/mcpserver_with_oidcconfig_ref.yaml @@ -5,7 +5,7 @@ # Each workload provides a unique audience to prevent token replay. # # This is the preferred pattern — the inline oidcConfig field is deprecated -# and will be removed in v1beta1. +# and will be removed in a future API version. --- # Kubernetes Secret for the OIDC client secret @@ -20,7 +20,7 @@ stringData: --- # Shared MCPOIDCConfig — Kubernetes service account variant -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPOIDCConfig metadata: name: k8s-sa-idp @@ -32,7 +32,7 @@ spec: --- # Shared MCPOIDCConfig — inline provider variant -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPOIDCConfig metadata: name: corporate-idp @@ -51,7 +51,7 @@ spec: # MCPServer referencing the shared MCPOIDCConfig. # The oidcConfigRef replaces the deprecated inline spec.oidcConfig field. # audience must be unique per server to prevent token replay attacks. -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch-with-shared-oidc diff --git a/examples/operator/mcp-servers/mcpserver_with_pod_template.yaml b/examples/operator/mcp-servers/mcpserver_with_pod_template.yaml index 21836d6c8e..1dc3407136 100644 --- a/examples/operator/mcp-servers/mcpserver_with_pod_template.yaml +++ b/examples/operator/mcp-servers/mcpserver_with_pod_template.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: sample-with-pod-template diff --git a/examples/operator/mcp-servers/mcpserver_with_resource_overrides.yaml b/examples/operator/mcp-servers/mcpserver_with_resource_overrides.yaml index 5b3b22b3bd..9e36b2a842 100644 --- a/examples/operator/mcp-servers/mcpserver_with_resource_overrides.yaml +++ b/examples/operator/mcp-servers/mcpserver_with_resource_overrides.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: github-with-overrides diff --git a/examples/operator/mcp-servers/mcpserver_with_restart_strategy.yaml b/examples/operator/mcp-servers/mcpserver_with_restart_strategy.yaml index 752a5dfd04..e15ef0ee48 100644 --- a/examples/operator/mcp-servers/mcpserver_with_restart_strategy.yaml +++ b/examples/operator/mcp-servers/mcpserver_with_restart_strategy.yaml @@ -1,5 +1,5 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: my-server diff --git a/examples/operator/mcp-servers/mcpserver_yardstick_sse.yaml b/examples/operator/mcp-servers/mcpserver_yardstick_sse.yaml index 0c1e10b484..c9f5f4a2de 100644 --- a/examples/operator/mcp-servers/mcpserver_yardstick_sse.yaml +++ b/examples/operator/mcp-servers/mcpserver_yardstick_sse.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick diff --git a/examples/operator/mcp-servers/mcpserver_yardstick_stdio.yaml b/examples/operator/mcp-servers/mcpserver_yardstick_stdio.yaml index 018f59d50f..5c5ba66b7c 100644 --- a/examples/operator/mcp-servers/mcpserver_yardstick_stdio.yaml +++ b/examples/operator/mcp-servers/mcpserver_yardstick_stdio.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick diff --git a/examples/operator/mcp-servers/mcpserver_yardstick_streamablehttp.yaml b/examples/operator/mcp-servers/mcpserver_yardstick_streamablehttp.yaml index 168abf3a77..969145405c 100644 --- a/examples/operator/mcp-servers/mcpserver_yardstick_streamablehttp.yaml +++ b/examples/operator/mcp-servers/mcpserver_yardstick_streamablehttp.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick diff --git a/examples/operator/redis-storage/mcpexternalauthconfig-redis-storage.yaml b/examples/operator/redis-storage/mcpexternalauthconfig-redis-storage.yaml index 0905328af7..65a3ce3812 100644 --- a/examples/operator/redis-storage/mcpexternalauthconfig-redis-storage.yaml +++ b/examples/operator/redis-storage/mcpexternalauthconfig-redis-storage.yaml @@ -12,7 +12,7 @@ # Usage: # kubectl apply -f redis-credentials.yaml # kubectl apply -f mcpexternalauthconfig-redis-storage.yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: auth-with-redis diff --git a/examples/operator/tool-configs/toolconfig_basic.yaml b/examples/operator/tool-configs/toolconfig_basic.yaml index 731b3a2573..0207b8724d 100644 --- a/examples/operator/tool-configs/toolconfig_basic.yaml +++ b/examples/operator/tool-configs/toolconfig_basic.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPToolConfig metadata: name: basic-tool-filter diff --git a/examples/operator/tool-configs/toolconfig_with_overrides.yaml b/examples/operator/tool-configs/toolconfig_with_overrides.yaml index 96f26eaa53..da93c4da08 100644 --- a/examples/operator/tool-configs/toolconfig_with_overrides.yaml +++ b/examples/operator/tool-configs/toolconfig_with_overrides.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPToolConfig metadata: name: github-tools-config diff --git a/examples/operator/vault/mcpserver-github-with-vault.yaml b/examples/operator/vault/mcpserver-github-with-vault.yaml index 3b83e213a0..5eaa8a834b 100644 --- a/examples/operator/vault/mcpserver-github-with-vault.yaml +++ b/examples/operator/vault/mcpserver-github-with-vault.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: github-vault-generic diff --git a/examples/operator/virtual-mcps/composite_tool_complex.yaml b/examples/operator/virtual-mcps/composite_tool_complex.yaml index 0e3c0fa90e..febcc92779 100644 --- a/examples/operator/virtual-mcps/composite_tool_complex.yaml +++ b/examples/operator/virtual-mcps/composite_tool_complex.yaml @@ -22,7 +22,7 @@ --- # Create MCPGroup -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: data-processing-services @@ -32,7 +32,7 @@ spec: --- # Backend MCP Server: Fetch (for HTTP requests) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch @@ -54,7 +54,7 @@ spec: --- # Backend MCP Server: Yardstick SSE (for echo and math operations) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick-sse @@ -79,7 +79,7 @@ spec: --- # Backend MCP Server: Yardstick Streamable (for longecho and LLM) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick-streamable @@ -104,7 +104,7 @@ spec: --- # Complex Composite Tool Definition -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: multi-source-data-processor @@ -285,7 +285,7 @@ spec: --- # VirtualMCPServer using the complex composite tool -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: vmcp-data-processor diff --git a/examples/operator/virtual-mcps/composite_tool_simple.yaml b/examples/operator/virtual-mcps/composite_tool_simple.yaml index 5472efc20d..7614787dc2 100644 --- a/examples/operator/virtual-mcps/composite_tool_simple.yaml +++ b/examples/operator/virtual-mcps/composite_tool_simple.yaml @@ -15,7 +15,7 @@ --- # Create MCPGroup -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: my-services @@ -25,7 +25,7 @@ spec: --- # Backend MCP Server: Fetch (for HTTP requests) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch @@ -47,7 +47,7 @@ spec: --- # Backend MCP Server: Yardstick SSE (for echo and validation) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick-sse @@ -72,7 +72,7 @@ spec: --- # Simple Composite Tool Definition -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: fetch-and-validate @@ -142,7 +142,7 @@ spec: --- # VirtualMCPServer using the simple composite tool -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: vmcp-simple-composite diff --git a/examples/operator/virtual-mcps/composite_tool_with_elicitations.yaml b/examples/operator/virtual-mcps/composite_tool_with_elicitations.yaml index 196e439604..51d3814ac1 100644 --- a/examples/operator/virtual-mcps/composite_tool_with_elicitations.yaml +++ b/examples/operator/virtual-mcps/composite_tool_with_elicitations.yaml @@ -23,7 +23,7 @@ --- # Create MCPGroup -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: deployment-services @@ -33,7 +33,7 @@ spec: --- # Backend MCP Server: Yardstick Streamable (provides echo tool) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick-streamable @@ -58,7 +58,7 @@ spec: --- # Composite Tool Definition with Elicitations -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: interactive-deploy @@ -198,7 +198,7 @@ spec: --- # VirtualMCPServer using the interactive composite tool -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: vmcp-interactive-deploy diff --git a/examples/operator/virtual-mcps/vmcp_conflict_resolution.yaml b/examples/operator/virtual-mcps/vmcp_conflict_resolution.yaml index d21b28fbfe..d9763d603a 100644 --- a/examples/operator/virtual-mcps/vmcp_conflict_resolution.yaml +++ b/examples/operator/virtual-mcps/vmcp_conflict_resolution.yaml @@ -15,7 +15,7 @@ --- # Create MCPGroup for all examples -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: my-services @@ -25,7 +25,7 @@ spec: --- # Create backend MCPServers used by all examples -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick-sse @@ -49,7 +49,7 @@ spec: memory: "64Mi" --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch @@ -70,7 +70,7 @@ spec: memory: "64Mi" --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick-streamable @@ -97,7 +97,7 @@ spec: # Strategy 1: Prefix-based Conflict Resolution # Tools are prefixed with workload name to avoid conflicts # Example: If tool "echo" exists in backend "yardstick-sse", it becomes "yardstick-sse_echo" -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: vmcp-prefix-strategy @@ -138,7 +138,7 @@ spec: # Strategy 2: Priority-based Conflict Resolution # Backends are prioritized; higher priority wins conflicts # Lower numbers = higher priority (1 is highest) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: vmcp-priority-strategy @@ -182,7 +182,7 @@ spec: # Strategy 3: Manual Conflict Resolution with Tool Filtering # Use manual strategy combined with per-workload tool filtering # This provides explicit control over which tools are exposed from each backend -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: vmcp-manual-strategy diff --git a/examples/operator/virtual-mcps/vmcp_inline_incoming_auth.yaml b/examples/operator/virtual-mcps/vmcp_inline_incoming_auth.yaml index 959068f2b1..adbfd43200 100644 --- a/examples/operator/virtual-mcps/vmcp_inline_incoming_auth.yaml +++ b/examples/operator/virtual-mcps/vmcp_inline_incoming_auth.yaml @@ -1,4 +1,4 @@ -# DEPRECATED: Inline oidcConfig in incomingAuth will be removed in v1beta1. +# DEPRECATED: Inline oidcConfig in incomingAuth will be removed in a future API version. # Prefer using a shared MCPOIDCConfig with oidcConfigRef instead. # See vmcp_with_oidcconfig_ref.yaml for the recommended pattern. # @@ -40,7 +40,7 @@ stringData: --- # Create MCPGroup -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: my-services @@ -50,7 +50,7 @@ spec: --- # Create backend MCPServer: yardstick with SSE transport -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick-sse @@ -75,7 +75,7 @@ spec: --- # Create backend MCPServer: fetch with streamable-http transport -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch @@ -97,7 +97,7 @@ spec: --- # Create backend MCPServer: yardstick with streamable-http transport -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick-streamable @@ -121,7 +121,7 @@ spec: memory: "64Mi" --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: inline-auth-vmcp diff --git a/examples/operator/virtual-mcps/vmcp_optimizer_all_options.yaml b/examples/operator/virtual-mcps/vmcp_optimizer_all_options.yaml index 4094f7af67..e37c1e2c43 100644 --- a/examples/operator/virtual-mcps/vmcp_optimizer_all_options.yaml +++ b/examples/operator/virtual-mcps/vmcp_optimizer_all_options.yaml @@ -30,7 +30,7 @@ --- # Step 1: Create MCPGroup -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: optimizer-services @@ -40,7 +40,7 @@ spec: --- # Step 2: Create MCPServer backend - yardstick -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick @@ -61,7 +61,7 @@ spec: --- # Step 3: Create MCPServer backend - fetch (URL content fetching) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch @@ -87,7 +87,7 @@ spec: # IMPORTANT: Images must come from HuggingFace Text Embeddings Inference (TEI): # https://github.com/huggingface/text-embeddings-inference # Available tags include :cpu-latest, :latest (GPU), and version-pinned variants. -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: optimizer-embedding @@ -140,7 +140,7 @@ spec: # This example sets every optimizer field explicitly rather than relying on # auto-configuration. The embeddingServerRef still resolves the URL from the # EmbeddingServer status, but the optimizer tuning parameters are user-controlled. -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: optimizer-vmcp diff --git a/examples/operator/virtual-mcps/vmcp_optimizer_quickstart.yaml b/examples/operator/virtual-mcps/vmcp_optimizer_quickstart.yaml index 9f9e7d877b..35694a384c 100644 --- a/examples/operator/virtual-mcps/vmcp_optimizer_quickstart.yaml +++ b/examples/operator/virtual-mcps/vmcp_optimizer_quickstart.yaml @@ -73,7 +73,7 @@ --- # Step 1: Create MCPGroup -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: optimizer-services @@ -83,7 +83,7 @@ spec: --- # Step 2a: MCPServer backend - yardstick (unit conversion) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick @@ -107,7 +107,7 @@ spec: --- # Step 2b: MCPServer backend - fetch (URL content fetching) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch @@ -132,7 +132,7 @@ spec: # Requires a Kubernetes Secret named "github-token" with key "token" # containing a GitHub Personal Access Token: # kubectl create secret generic github-token --from-literal=token=ghp_YOUR_TOKEN -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: github @@ -157,7 +157,7 @@ spec: --- # Step 2d: MCPServer backend - memory (knowledge graph-based persistent memory) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: memory @@ -178,7 +178,7 @@ spec: --- # Step 2e: MCPServer backend - puppeteer (browser automation and web scraping) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: puppeteer @@ -199,7 +199,7 @@ spec: --- # Step 2f: MCPServer backend - osv (OSV vulnerability database) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: osv @@ -220,7 +220,7 @@ spec: --- # Step 2g: MCPServer backend - terraform (Terraform registry and workspace management) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: terraform @@ -246,7 +246,7 @@ spec: --- # Step 2h: MCPServer backend - playwright (browser automation and testing) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: playwright @@ -267,7 +267,7 @@ spec: --- # Step 2i: MCPServer backend - everything (MCP reference/test server) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: everything @@ -288,7 +288,7 @@ spec: --- # Step 2j: MCPServer backend - ida-pro-mcp (IDA Pro reverse engineering) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: ida-pro-mcp @@ -312,7 +312,7 @@ spec: # Requires a Kubernetes Secret named "pagerduty-token" with key "token" # containing a PagerDuty User API Key: # kubectl create secret generic pagerduty-token --from-literal=token=YOUR_KEY -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: pagerduty @@ -343,7 +343,7 @@ spec: # port: 8080 # imagePullPolicy: IfNotPresent # replicas: 1 -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: optimizer-embedding @@ -355,7 +355,7 @@ spec: {} # Note: No explicit "optimizer" config is needed. The operator detects that # embeddingServerRef is set, auto-populates the optimizer with default values, # resolves the EmbeddingServer URL, and emits an "OptimizerAutoConfigured" event. -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: optimizer-vmcp diff --git a/examples/operator/virtual-mcps/vmcp_production_full.yaml b/examples/operator/virtual-mcps/vmcp_production_full.yaml index 243b560591..2ac25a05c6 100644 --- a/examples/operator/virtual-mcps/vmcp_production_full.yaml +++ b/examples/operator/virtual-mcps/vmcp_production_full.yaml @@ -44,7 +44,7 @@ stringData: --- # Create MCPGroup for backend servers -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: production-services @@ -56,7 +56,7 @@ spec: --- # Create backend MCPServer: yardstick with streamable-http transport -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick-streamable @@ -83,7 +83,7 @@ spec: --- # Create backend MCPServer: fetch with streamable-http transport -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch @@ -107,7 +107,7 @@ spec: --- # Create backend MCPServer: yardstick with sse transport -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick-sse @@ -133,7 +133,7 @@ spec: memory: "64Mi" --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: production-vmcp diff --git a/examples/operator/virtual-mcps/vmcp_simple_discovered.yaml b/examples/operator/virtual-mcps/vmcp_simple_discovered.yaml index 73b8d32261..837ded5198 100644 --- a/examples/operator/virtual-mcps/vmcp_simple_discovered.yaml +++ b/examples/operator/virtual-mcps/vmcp_simple_discovered.yaml @@ -16,7 +16,7 @@ --- # Step 1: Create MCPGroup first -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: my-services @@ -26,7 +26,7 @@ spec: --- # Step 2: Create MCPServer backend that references the group -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick-simple @@ -47,7 +47,7 @@ spec: --- # Step 3: Create VirtualMCPServer -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: simple-vmcp diff --git a/examples/operator/virtual-mcps/vmcp_with_oidcconfig_ref.yaml b/examples/operator/virtual-mcps/vmcp_with_oidcconfig_ref.yaml index 2b8fbe40b6..7effb743e1 100644 --- a/examples/operator/virtual-mcps/vmcp_with_oidcconfig_ref.yaml +++ b/examples/operator/virtual-mcps/vmcp_with_oidcconfig_ref.yaml @@ -1,14 +1,14 @@ # VirtualMCPServer using oidcConfigRef for incoming authentication. # # This is the preferred pattern — the inline oidcConfig field is deprecated -# and will be removed in v1beta1. +# and will be removed in a future API version. # # The oidcConfigRef references a shared MCPOIDCConfig resource, allowing # multiple VirtualMCPServers (and MCPServers) to share the same provider config. --- # Shared MCPOIDCConfig for incoming auth -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPOIDCConfig metadata: name: corporate-idp @@ -24,7 +24,7 @@ spec: --- # MCPGroup for backend discovery -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: my-services @@ -34,7 +34,7 @@ spec: --- # VirtualMCPServer with oidcConfigRef in incomingAuth -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: shared-auth-vmcp diff --git a/examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml b/examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml index 499639f0d5..2339acb9e6 100644 --- a/examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml +++ b/examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml @@ -3,7 +3,7 @@ # This example demonstrates using a shared MCPTelemetryConfig resource with a # VirtualMCPServer via spec.telemetryConfigRef. This is the preferred pattern # for configuring telemetry — the inline spec.config.telemetry field is -# deprecated and will be removed in v1beta1. +# deprecated and will be removed in a future API version. # # The MCPTelemetryConfig enables OTLP tracing, OTLP metrics, and Prometheus # metrics. The VirtualMCPServer references it and provides a unique serviceName @@ -25,7 +25,7 @@ --- # Step 1: Shared telemetry configuration # Define once, reference from multiple MCPServers and VirtualMCPServers. -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPTelemetryConfig metadata: name: shared-otel @@ -45,7 +45,7 @@ spec: --- # Step 2: MCPGroup for backend discovery -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: telemetry-demo @@ -55,7 +55,7 @@ spec: --- # Step 3: MCPServer backend that references the group -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick-telemetry @@ -78,7 +78,7 @@ spec: # Step 4: VirtualMCPServer with telemetryConfigRef # The telemetryConfigRef replaces the deprecated inline spec.config.telemetry field. # serviceName provides a unique OTel service name for this vMCP's telemetry. -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: telemetry-demo-vmcp diff --git a/pkg/audit/zz_generated.deepcopy.go b/pkg/audit/zz_generated.deepcopy.go index 3142dfc484..7af0eff790 100644 --- a/pkg/audit/zz_generated.deepcopy.go +++ b/pkg/audit/zz_generated.deepcopy.go @@ -35,6 +35,11 @@ func (in *Config) DeepCopyInto(out *Config) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.DetectApplicationErrors != nil { + in, out := &in.DetectApplicationErrors, &out.DetectApplicationErrors + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Config. diff --git a/pkg/export/k8s.go b/pkg/export/k8s.go index 6cdc84893a..c0f8e9b5bc 100644 --- a/pkg/export/k8s.go +++ b/pkg/export/k8s.go @@ -13,7 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" - v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" "github.com/stacklok/toolhive/pkg/runner" "github.com/stacklok/toolhive/pkg/transport/types" @@ -37,7 +37,7 @@ func WriteK8sManifest(config *runner.RunConfig, w io.Writer) error { // runConfigToMCPServer converts a RunConfig to a Kubernetes MCPServer resource // nolint:gocyclo // Complexity due to mapping multiple config fields to K8s resource -func runConfigToMCPServer(config *runner.RunConfig) (*v1alpha1.MCPServer, error) { +func runConfigToMCPServer(config *runner.RunConfig) (*v1beta1.MCPServer, error) { // Check if this is a remote server - not supported in Kubernetes if config.RemoteURL != "" { return nil, fmt.Errorf("remote MCP servers are not supported in Kubernetes deployments") @@ -60,15 +60,15 @@ func runConfigToMCPServer(config *runner.RunConfig) (*v1alpha1.MCPServer, error) // Sanitize the name to be a valid Kubernetes resource name name = sanitizeK8sName(name) - mcpServer := &v1alpha1.MCPServer{ + mcpServer := &v1beta1.MCPServer{ TypeMeta: metav1.TypeMeta{ - APIVersion: "toolhive.stacklok.dev/v1alpha1", + APIVersion: "toolhive.stacklok.dev/v1beta1", Kind: "MCPServer", }, ObjectMeta: metav1.ObjectMeta{ Name: name, }, - Spec: v1alpha1.MCPServerSpec{ + Spec: v1beta1.MCPServerSpec{ Image: config.Image, Transport: string(config.Transport), Args: config.CmdArgs, @@ -94,9 +94,9 @@ func runConfigToMCPServer(config *runner.RunConfig) (*v1alpha1.MCPServer, error) // Convert environment variables if len(config.EnvVars) > 0 { - mcpServer.Spec.Env = make([]v1alpha1.EnvVar, 0, len(config.EnvVars)) + mcpServer.Spec.Env = make([]v1beta1.EnvVar, 0, len(config.EnvVars)) for key, value := range config.EnvVars { - mcpServer.Spec.Env = append(mcpServer.Spec.Env, v1alpha1.EnvVar{ + mcpServer.Spec.Env = append(mcpServer.Spec.Env, v1beta1.EnvVar{ Name: key, Value: value, }) @@ -105,7 +105,7 @@ func runConfigToMCPServer(config *runner.RunConfig) (*v1alpha1.MCPServer, error) // Convert volumes if len(config.Volumes) > 0 { - mcpServer.Spec.Volumes = make([]v1alpha1.Volume, 0, len(config.Volumes)) + mcpServer.Spec.Volumes = make([]v1beta1.Volume, 0, len(config.Volumes)) for i, vol := range config.Volumes { volume, err := parseVolumeString(vol, i) if err != nil { @@ -119,8 +119,8 @@ func runConfigToMCPServer(config *runner.RunConfig) (*v1alpha1.MCPServer, error) if config.PermissionProfile != nil { // For now, we export permission profiles as inline ConfigMaps would need to be created separately // This is a simplified export - users may need to adjust this - mcpServer.Spec.PermissionProfile = &v1alpha1.PermissionProfileRef{ - Type: v1alpha1.PermissionProfileTypeBuiltin, + mcpServer.Spec.PermissionProfile = &v1beta1.PermissionProfileRef{ + Type: v1beta1.PermissionProfileTypeBuiltin, Name: "none", // Default to none, user should adjust based on their needs } } @@ -135,9 +135,9 @@ func runConfigToMCPServer(config *runner.RunConfig) (*v1alpha1.MCPServer, error) var cedarConfig cedar.Config if err := json.Unmarshal(config.AuthzConfig.RawConfig(), &cedarConfig); err == nil && cedarConfig.Options != nil && len(cedarConfig.Options.Policies) > 0 { - mcpServer.Spec.AuthzConfig = &v1alpha1.AuthzConfigRef{ - Type: v1alpha1.AuthzConfigTypeInline, - Inline: &v1alpha1.InlineAuthzConfig{ + mcpServer.Spec.AuthzConfig = &v1beta1.AuthzConfigRef{ + Type: v1beta1.AuthzConfigTypeInline, + Inline: &v1beta1.InlineAuthzConfig{ Policies: cedarConfig.Options.Policies, }, } @@ -150,7 +150,7 @@ func runConfigToMCPServer(config *runner.RunConfig) (*v1alpha1.MCPServer, error) // Convert audit config - audit is always enabled if config exists if config.AuditConfig != nil { - mcpServer.Spec.Audit = &v1alpha1.AuditConfig{ + mcpServer.Spec.Audit = &v1beta1.AuditConfig{ Enabled: true, } } @@ -165,13 +165,13 @@ func runConfigToMCPServer(config *runner.RunConfig) (*v1alpha1.MCPServer, error) } // parseVolumeString parses a volume string in the format "host-path:container-path[:ro]" -func parseVolumeString(volStr string, index int) (v1alpha1.Volume, error) { +func parseVolumeString(volStr string, index int) (v1beta1.Volume, error) { parts := strings.Split(volStr, ":") if len(parts) < 2 { - return v1alpha1.Volume{}, fmt.Errorf("invalid volume format, expected 'host-path:container-path[:ro]'") + return v1beta1.Volume{}, fmt.Errorf("invalid volume format, expected 'host-path:container-path[:ro]'") } - volume := v1alpha1.Volume{ + volume := v1beta1.Volume{ Name: fmt.Sprintf("volume-%d", index), HostPath: parts[0], MountPath: parts[1], diff --git a/pkg/export/k8s_test.go b/pkg/export/k8s_test.go index 337a728479..a3070c3f16 100644 --- a/pkg/export/k8s_test.go +++ b/pkg/export/k8s_test.go @@ -13,7 +13,7 @@ import ( "sigs.k8s.io/yaml" "github.com/stacklok/toolhive-core/permissions" - v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/audit" "github.com/stacklok/toolhive/pkg/authz" "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" @@ -40,7 +40,7 @@ func TestWriteK8sManifest(t *testing.T) { name string config *runner.RunConfig wantErr bool - validateFn func(t *testing.T, mcpServer *v1alpha1.MCPServer) + validateFn func(t *testing.T, mcpServer *v1beta1.MCPServer) }{ { name: "basic stdio config", @@ -54,9 +54,9 @@ func TestWriteK8sManifest(t *testing.T) { Port: 8080, CmdArgs: []string{"--verbose"}, }, - validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + validateFn: func(t *testing.T, mcpServer *v1beta1.MCPServer) { t.Helper() - assert.Equal(t, "toolhive.stacklok.dev/v1alpha1", mcpServer.APIVersion) + assert.Equal(t, "toolhive.stacklok.dev/v1beta1", mcpServer.APIVersion) assert.Equal(t, "MCPServer", mcpServer.Kind) assert.Equal(t, "github", mcpServer.Name) assert.Equal(t, "ghcr.io/stacklok/mcp-server-github:latest", mcpServer.Spec.Image) @@ -76,7 +76,7 @@ func TestWriteK8sManifest(t *testing.T) { Port: 8081, TargetPort: 3000, }, - validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + validateFn: func(t *testing.T, mcpServer *v1beta1.MCPServer) { t.Helper() assert.Equal(t, "sse", mcpServer.Spec.Transport) assert.Equal(t, int32(8081), mcpServer.GetProxyPort()) @@ -95,7 +95,7 @@ func TestWriteK8sManifest(t *testing.T) { "DEBUG": "true", }, }, - validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + validateFn: func(t *testing.T, mcpServer *v1beta1.MCPServer) { t.Helper() require.Len(t, mcpServer.Spec.Env, 2) envMap := make(map[string]string) @@ -118,7 +118,7 @@ func TestWriteK8sManifest(t *testing.T) { "/readonly:/data:ro", }, }, - validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + validateFn: func(t *testing.T, mcpServer *v1beta1.MCPServer) { t.Helper() require.Len(t, mcpServer.Spec.Volumes, 2) assert.Equal(t, "/host/path", mcpServer.Spec.Volumes[0].HostPath) @@ -141,10 +141,10 @@ func TestWriteK8sManifest(t *testing.T) { Write: []permissions.MountDeclaration{"/output"}, }, }, - validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + validateFn: func(t *testing.T, mcpServer *v1beta1.MCPServer) { t.Helper() require.NotNil(t, mcpServer.Spec.PermissionProfile) - assert.Equal(t, v1alpha1.PermissionProfileTypeBuiltin, mcpServer.Spec.PermissionProfile.Type) + assert.Equal(t, v1beta1.PermissionProfileTypeBuiltin, mcpServer.Spec.PermissionProfile.Type) assert.Equal(t, "none", mcpServer.Spec.PermissionProfile.Name) }, }, @@ -162,10 +162,10 @@ func TestWriteK8sManifest(t *testing.T) { EntitiesJSON: "[]", }), }, - validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + validateFn: func(t *testing.T, mcpServer *v1beta1.MCPServer) { t.Helper() require.NotNil(t, mcpServer.Spec.AuthzConfig) - assert.Equal(t, v1alpha1.AuthzConfigTypeInline, mcpServer.Spec.AuthzConfig.Type) + assert.Equal(t, v1beta1.AuthzConfigTypeInline, mcpServer.Spec.AuthzConfig.Type) require.NotNil(t, mcpServer.Spec.AuthzConfig.Inline) require.Len(t, mcpServer.Spec.AuthzConfig.Inline.Policies, 1) assert.Equal(t, "permit(principal, action, resource);", mcpServer.Spec.AuthzConfig.Inline.Policies[0]) @@ -183,7 +183,7 @@ func TestWriteK8sManifest(t *testing.T) { Component: "test-component", }, }, - validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + validateFn: func(t *testing.T, mcpServer *v1beta1.MCPServer) { t.Helper() require.NotNil(t, mcpServer.Spec.Audit) assert.True(t, mcpServer.Spec.Audit.Enabled) @@ -198,7 +198,7 @@ func TestWriteK8sManifest(t *testing.T) { Transport: types.TransportTypeStdio, ToolsFilter: []string{"tool1", "tool2"}, }, - validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + validateFn: func(t *testing.T, mcpServer *v1beta1.MCPServer) { t.Helper() // ToolsFilter is not exported to the CRD; use MCPToolConfig with toolConfigRef instead assert.Nil(t, mcpServer.Spec.ToolConfigRef, "toolConfigRef should not be set by export") @@ -256,7 +256,7 @@ func TestWriteK8sManifest(t *testing.T) { assert.NotEmpty(t, buf.String()) // Parse the YAML to validate structure - var mcpServer v1alpha1.MCPServer + var mcpServer v1beta1.MCPServer err = yaml.Unmarshal(buf.Bytes(), &mcpServer) require.NoError(t, err) @@ -275,14 +275,14 @@ func TestParseVolumeString(t *testing.T) { name string volStr string index int - wantVol v1alpha1.Volume + wantVol v1beta1.Volume wantErr bool }{ { name: "basic volume", volStr: "/host/path:/container/path", index: 0, - wantVol: v1alpha1.Volume{ + wantVol: v1beta1.Volume{ Name: "volume-0", HostPath: "/host/path", MountPath: "/container/path", @@ -293,7 +293,7 @@ func TestParseVolumeString(t *testing.T) { name: "read-only volume", volStr: "/host/path:/container/path:ro", index: 1, - wantVol: v1alpha1.Volume{ + wantVol: v1beta1.Volume{ Name: "volume-1", HostPath: "/host/path", MountPath: "/container/path", diff --git a/pkg/groups/crd_manager.go b/pkg/groups/crd_manager.go index a7f7a4ed54..718860c374 100644 --- a/pkg/groups/crd_manager.go +++ b/pkg/groups/crd_manager.go @@ -18,7 +18,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" groupval "github.com/stacklok/toolhive-core/validation/group" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // crdManager implements the Manager interface using Kubernetes CRDs @@ -52,12 +52,12 @@ func (m *crdManager) Create(ctx context.Context, name string) error { } // Create the MCPGroup CRD - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: m.namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, } if err := m.k8sClient.Create(ctx, mcpGroup); err != nil { @@ -70,7 +70,7 @@ func (m *crdManager) Create(ctx context.Context, name string) error { // Get retrieves a group by name. func (m *crdManager) Get(ctx context.Context, name string) (*Group, error) { - mcpGroup := &mcpv1alpha1.MCPGroup{} + mcpGroup := &mcpv1beta1.MCPGroup{} err := m.k8sClient.Get(ctx, types.NamespacedName{ Name: name, Namespace: m.namespace, @@ -88,7 +88,7 @@ func (m *crdManager) Get(ctx context.Context, name string) (*Group, error) { // List returns all groups. func (m *crdManager) List(ctx context.Context) ([]*Group, error) { - mcpGroupList := &mcpv1alpha1.MCPGroupList{} + mcpGroupList := &mcpv1beta1.MCPGroupList{} err := m.k8sClient.List(ctx, mcpGroupList, client.InNamespace(m.namespace)) if err != nil { return nil, fmt.Errorf("failed to list MCPGroups: %w", err) @@ -106,7 +106,7 @@ func (m *crdManager) List(ctx context.Context) ([]*Group, error) { // Delete removes a group by name. func (m *crdManager) Delete(ctx context.Context, name string) error { - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: m.namespace, @@ -127,7 +127,7 @@ func (m *crdManager) Delete(ctx context.Context, name string) error { // Exists checks if a group with the specified name exists. func (m *crdManager) Exists(ctx context.Context, name string) (bool, error) { - mcpGroup := &mcpv1alpha1.MCPGroup{} + mcpGroup := &mcpv1beta1.MCPGroup{} err := m.k8sClient.Get(ctx, types.NamespacedName{ Name: name, Namespace: m.namespace, @@ -159,7 +159,7 @@ func (*crdManager) Update(context.Context, *Group) error { } // mcpGroupListToGroups converts an MCPGroupList to a slice of Groups -func mcpGroupListToGroups(mcpGroupList *mcpv1alpha1.MCPGroupList) []*Group { +func mcpGroupListToGroups(mcpGroupList *mcpv1beta1.MCPGroupList) []*Group { groups := make([]*Group, 0, len(mcpGroupList.Items)) for i := range mcpGroupList.Items { groups = append(groups, mcpGroupToGroup(&mcpGroupList.Items[i])) @@ -168,7 +168,7 @@ func mcpGroupListToGroups(mcpGroupList *mcpv1alpha1.MCPGroupList) []*Group { } // mcpGroupToGroup converts an MCPGroup CRD to a Group -func mcpGroupToGroup(mcpGroup *mcpv1alpha1.MCPGroup) *Group { +func mcpGroupToGroup(mcpGroup *mcpv1beta1.MCPGroup) *Group { // In Kubernetes, RegisteredClients is not applicable - always return empty slice return &Group{ Name: mcpGroup.Name, diff --git a/pkg/groups/crd_manager_test.go b/pkg/groups/crd_manager_test.go index edb5e23a45..18d7df854d 100644 --- a/pkg/groups/crd_manager_test.go +++ b/pkg/groups/crd_manager_test.go @@ -18,14 +18,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // createTestScheme creates a test scheme with required types func createTestScheme() *runtime.Scheme { scheme := runtime.NewScheme() utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(mcpv1alpha1.AddToScheme(scheme)) + utilruntime.Must(mcpv1beta1.AddToScheme(scheme)) return scheme } @@ -58,12 +58,12 @@ func TestCRDManager_Create(t *testing.T) { name: "group already exists", groupName: "existinggroup", setupObjs: []client.Object{ - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "existinggroup", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, }, expectError: true, @@ -130,12 +130,12 @@ func TestCRDManager_Get(t *testing.T) { name: "successful get", groupName: "testgroup", setupObjs: []client.Object{ - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "testgroup", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, }, expectError: false, @@ -155,12 +155,12 @@ func TestCRDManager_Get(t *testing.T) { name: "group with no registered clients", groupName: "emptygroup", setupObjs: []client.Object{ - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "emptygroup", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, }, expectError: false, @@ -212,26 +212,26 @@ func TestCRDManager_List(t *testing.T) { { name: "list multiple groups", setupObjs: []client.Object{ - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "group1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "group2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "agroup", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, }, expected: []*Group{ @@ -285,12 +285,12 @@ func TestCRDManager_Delete(t *testing.T) { name: "successful deletion", groupName: "testgroup", setupObjs: []client.Object{ - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "testgroup", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, }, expectError: false, @@ -322,7 +322,7 @@ func TestCRDManager_Delete(t *testing.T) { assert.NoError(t, err) // Verify the group was deleted - mcpGroup := &mcpv1alpha1.MCPGroup{} + mcpGroup := &mcpv1beta1.MCPGroup{} err := fakeClient.Get(ctx, client.ObjectKey{ Name: tt.groupName, Namespace: "default", @@ -347,12 +347,12 @@ func TestCRDManager_Exists(t *testing.T) { name: "group exists", groupName: "testgroup", setupObjs: []client.Object{ - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "testgroup", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, }, expected: true, @@ -401,12 +401,12 @@ func TestCRDManager_RegisterClients(t *testing.T) { groupNames: []string{"testgroup"}, clientNames: []string{"client1", "client2"}, setupObjs: []client.Object{ - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "testgroup", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, }, expectError: false, @@ -416,19 +416,19 @@ func TestCRDManager_RegisterClients(t *testing.T) { groupNames: []string{"group1", "group2"}, clientNames: []string{"client1"}, setupObjs: []client.Object{ - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "group1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "group2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, }, expectError: false, @@ -475,12 +475,12 @@ func TestCRDManager_UnregisterClients(t *testing.T) { groupNames: []string{"testgroup"}, clientNames: []string{"client1"}, setupObjs: []client.Object{ - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "testgroup", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, }, expectError: false, @@ -490,12 +490,12 @@ func TestCRDManager_UnregisterClients(t *testing.T) { groupNames: []string{"testgroup"}, clientNames: []string{"client1", "client2"}, setupObjs: []client.Object{ - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "testgroup", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, }, expectError: false, @@ -505,19 +505,19 @@ func TestCRDManager_UnregisterClients(t *testing.T) { groupNames: []string{"group1", "group2"}, clientNames: []string{"client1"}, setupObjs: []client.Object{ - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "group1", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, - &mcpv1alpha1.MCPGroup{ + &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "group2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, }, expectError: false, @@ -554,16 +554,16 @@ func TestMCPGroupToGroup(t *testing.T) { tests := []struct { name string - mcpGroup *mcpv1alpha1.MCPGroup + mcpGroup *mcpv1beta1.MCPGroup expected *Group }{ { name: "basic group conversion", - mcpGroup: &mcpv1alpha1.MCPGroup{ + mcpGroup: &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "testgroup", }, - Spec: mcpv1alpha1.MCPGroupSpec{}, + Spec: mcpv1beta1.MCPGroupSpec{}, }, expected: &Group{ Name: "testgroup", diff --git a/pkg/groups/manager.go b/pkg/groups/manager.go index 8764d6e333..45dffb8fff 100644 --- a/pkg/groups/manager.go +++ b/pkg/groups/manager.go @@ -10,7 +10,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" rt "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/k8s" ) @@ -37,8 +37,8 @@ func newCRDManager() (Manager, error) { if err := clientgoscheme.AddToScheme(scheme); err != nil { return nil, fmt.Errorf("failed to add client-go scheme: %w", err) } - if err := mcpv1alpha1.AddToScheme(scheme); err != nil { - return nil, fmt.Errorf("failed to add MCP v1alpha1 scheme: %w", err) + if err := mcpv1beta1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("failed to add MCP v1beta1 scheme: %w", err) } // Create controller-runtime client with custom scheme diff --git a/pkg/operator/accessors/mcpserver_accessor.go b/pkg/operator/accessors/mcpserver_accessor.go index 6acaf0a249..8645906ce2 100644 --- a/pkg/operator/accessors/mcpserver_accessor.go +++ b/pkg/operator/accessors/mcpserver_accessor.go @@ -7,16 +7,16 @@ package accessors import ( "maps" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // MCPServerFieldAccessor provides accessor methods for handling labels and annotations type MCPServerFieldAccessor interface { // GetProxyDeploymentLabelsAndAnnotations returns labels and annotations for the deployment - GetProxyDeploymentLabelsAndAnnotations(mcpServer *mcpv1alpha1.MCPServer) (labels, annotations map[string]string) + GetProxyDeploymentLabelsAndAnnotations(mcpServer *mcpv1beta1.MCPServer) (labels, annotations map[string]string) // GetProxyDeploymentTemplateLabelsAndAnnotations returns labels and annotations for the deployment pod template - GetProxyDeploymentTemplateLabelsAndAnnotations(mcpServer *mcpv1alpha1.MCPServer) (labels, annotations map[string]string) + GetProxyDeploymentTemplateLabelsAndAnnotations(mcpServer *mcpv1beta1.MCPServer) (labels, annotations map[string]string) } // mcpServerFieldAccessor implements MCPServerFieldAccessor @@ -29,7 +29,7 @@ func NewMCPServerFieldAccessor() MCPServerFieldAccessor { // GetProxyDeploymentLabelsAndAnnotations returns labels and annotations for the deployment func (*mcpServerFieldAccessor) GetProxyDeploymentLabelsAndAnnotations( - mcpServer *mcpv1alpha1.MCPServer, + mcpServer *mcpv1beta1.MCPServer, ) (map[string]string, map[string]string) { baseAnnotations := make(map[string]string) baseLabels := make(map[string]string) @@ -54,7 +54,7 @@ func (*mcpServerFieldAccessor) GetProxyDeploymentLabelsAndAnnotations( // GetProxyDeploymentTemplateLabelsAndAnnotations returns labels and annotations for the deployment pod template func (*mcpServerFieldAccessor) GetProxyDeploymentTemplateLabelsAndAnnotations( - mcpServer *mcpv1alpha1.MCPServer, + mcpServer *mcpv1beta1.MCPServer, ) (map[string]string, map[string]string) { baseAnnotations := make(map[string]string) baseLabels := make(map[string]string) diff --git a/pkg/operator/accessors/mcpserver_accessor_test.go b/pkg/operator/accessors/mcpserver_accessor_test.go index dd557d8f68..10e49504ea 100644 --- a/pkg/operator/accessors/mcpserver_accessor_test.go +++ b/pkg/operator/accessors/mcpserver_accessor_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func TestNewMCPServerFieldAccessor(t *testing.T) { @@ -24,14 +24,14 @@ func TestGetProxyDeploymentLabelsAndAnnotations(t *testing.T) { t.Parallel() tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer expectedLabels map[string]string expectedAnnotations map[string]string }{ { name: "nil resource overrides", - mcpServer: &mcpv1alpha1.MCPServer{ - Spec: mcpv1alpha1.MCPServerSpec{ + mcpServer: &mcpv1beta1.MCPServer{ + Spec: mcpv1beta1.MCPServerSpec{ ResourceOverrides: nil, }, }, @@ -40,9 +40,9 @@ func TestGetProxyDeploymentLabelsAndAnnotations(t *testing.T) { }, { name: "nil proxy deployment overrides", - mcpServer: &mcpv1alpha1.MCPServer{ - Spec: mcpv1alpha1.MCPServerSpec{ - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ + mcpServer: &mcpv1beta1.MCPServer{ + Spec: mcpv1beta1.MCPServerSpec{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ ProxyDeployment: nil, }, }, @@ -52,11 +52,11 @@ func TestGetProxyDeploymentLabelsAndAnnotations(t *testing.T) { }, { name: "with labels only", - mcpServer: &mcpv1alpha1.MCPServer{ - Spec: mcpv1alpha1.MCPServerSpec{ - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - ResourceMetadataOverrides: mcpv1alpha1.ResourceMetadataOverrides{ + mcpServer: &mcpv1beta1.MCPServer{ + Spec: mcpv1beta1.MCPServerSpec{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + ResourceMetadataOverrides: mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "app": "my-app", "version": "v1", @@ -74,11 +74,11 @@ func TestGetProxyDeploymentLabelsAndAnnotations(t *testing.T) { }, { name: "with annotations only", - mcpServer: &mcpv1alpha1.MCPServer{ - Spec: mcpv1alpha1.MCPServerSpec{ - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - ResourceMetadataOverrides: mcpv1alpha1.ResourceMetadataOverrides{ + mcpServer: &mcpv1beta1.MCPServer{ + Spec: mcpv1beta1.MCPServerSpec{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + ResourceMetadataOverrides: mcpv1beta1.ResourceMetadataOverrides{ Annotations: map[string]string{ "prometheus.io/scrape": "true", "prometheus.io/port": "9090", @@ -96,11 +96,11 @@ func TestGetProxyDeploymentLabelsAndAnnotations(t *testing.T) { }, { name: "with both labels and annotations", - mcpServer: &mcpv1alpha1.MCPServer{ - Spec: mcpv1alpha1.MCPServerSpec{ - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - ResourceMetadataOverrides: mcpv1alpha1.ResourceMetadataOverrides{ + mcpServer: &mcpv1beta1.MCPServer{ + Spec: mcpv1beta1.MCPServerSpec{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + ResourceMetadataOverrides: mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "env": "production", "team": "platform", @@ -129,11 +129,11 @@ func TestGetProxyDeploymentLabelsAndAnnotations(t *testing.T) { }, { name: "nil labels and annotations maps", - mcpServer: &mcpv1alpha1.MCPServer{ - Spec: mcpv1alpha1.MCPServerSpec{ - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - ResourceMetadataOverrides: mcpv1alpha1.ResourceMetadataOverrides{ + mcpServer: &mcpv1beta1.MCPServer{ + Spec: mcpv1beta1.MCPServerSpec{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + ResourceMetadataOverrides: mcpv1beta1.ResourceMetadataOverrides{ Labels: nil, Annotations: nil, }, @@ -146,11 +146,11 @@ func TestGetProxyDeploymentLabelsAndAnnotations(t *testing.T) { }, { name: "empty labels and annotations maps", - mcpServer: &mcpv1alpha1.MCPServer{ - Spec: mcpv1alpha1.MCPServerSpec{ - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - ResourceMetadataOverrides: mcpv1alpha1.ResourceMetadataOverrides{ + mcpServer: &mcpv1beta1.MCPServer{ + Spec: mcpv1beta1.MCPServerSpec{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + ResourceMetadataOverrides: mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{}, Annotations: map[string]string{}, }, @@ -179,14 +179,14 @@ func TestGetProxyDeploymentTemplateLabelsAndAnnotations(t *testing.T) { t.Parallel() tests := []struct { name string - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer expectedLabels map[string]string expectedAnnotations map[string]string }{ { name: "nil resource overrides", - mcpServer: &mcpv1alpha1.MCPServer{ - Spec: mcpv1alpha1.MCPServerSpec{ + mcpServer: &mcpv1beta1.MCPServer{ + Spec: mcpv1beta1.MCPServerSpec{ ResourceOverrides: nil, }, }, @@ -195,9 +195,9 @@ func TestGetProxyDeploymentTemplateLabelsAndAnnotations(t *testing.T) { }, { name: "nil proxy deployment overrides", - mcpServer: &mcpv1alpha1.MCPServer{ - Spec: mcpv1alpha1.MCPServerSpec{ - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ + mcpServer: &mcpv1beta1.MCPServer{ + Spec: mcpv1beta1.MCPServerSpec{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ ProxyDeployment: nil, }, }, @@ -207,10 +207,10 @@ func TestGetProxyDeploymentTemplateLabelsAndAnnotations(t *testing.T) { }, { name: "nil pod template metadata overrides", - mcpServer: &mcpv1alpha1.MCPServer{ - Spec: mcpv1alpha1.MCPServerSpec{ - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ + mcpServer: &mcpv1beta1.MCPServer{ + Spec: mcpv1beta1.MCPServerSpec{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ PodTemplateMetadataOverrides: nil, }, }, @@ -221,11 +221,11 @@ func TestGetProxyDeploymentTemplateLabelsAndAnnotations(t *testing.T) { }, { name: "with pod template labels only", - mcpServer: &mcpv1alpha1.MCPServer{ - Spec: mcpv1alpha1.MCPServerSpec{ - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - PodTemplateMetadataOverrides: &mcpv1alpha1.ResourceMetadataOverrides{ + mcpServer: &mcpv1beta1.MCPServer{ + Spec: mcpv1beta1.MCPServerSpec{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + PodTemplateMetadataOverrides: &mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "pod-label-1": "value1", "pod-label-2": "value2", @@ -243,11 +243,11 @@ func TestGetProxyDeploymentTemplateLabelsAndAnnotations(t *testing.T) { }, { name: "with pod template annotations only", - mcpServer: &mcpv1alpha1.MCPServer{ - Spec: mcpv1alpha1.MCPServerSpec{ - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - PodTemplateMetadataOverrides: &mcpv1alpha1.ResourceMetadataOverrides{ + mcpServer: &mcpv1beta1.MCPServer{ + Spec: mcpv1beta1.MCPServerSpec{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + PodTemplateMetadataOverrides: &mcpv1beta1.ResourceMetadataOverrides{ Annotations: map[string]string{ "pod-annotation-1": "value1", "pod-annotation-2": "value2", @@ -265,11 +265,11 @@ func TestGetProxyDeploymentTemplateLabelsAndAnnotations(t *testing.T) { }, { name: "with both pod template labels and annotations", - mcpServer: &mcpv1alpha1.MCPServer{ - Spec: mcpv1alpha1.MCPServerSpec{ - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - PodTemplateMetadataOverrides: &mcpv1alpha1.ResourceMetadataOverrides{ + mcpServer: &mcpv1beta1.MCPServer{ + Spec: mcpv1beta1.MCPServerSpec{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + PodTemplateMetadataOverrides: &mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "app.kubernetes.io/component": "proxy", "app.kubernetes.io/instance": "server-1", @@ -294,11 +294,11 @@ func TestGetProxyDeploymentTemplateLabelsAndAnnotations(t *testing.T) { }, { name: "deployment overrides don't affect pod template", - mcpServer: &mcpv1alpha1.MCPServer{ - Spec: mcpv1alpha1.MCPServerSpec{ - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - ResourceMetadataOverrides: mcpv1alpha1.ResourceMetadataOverrides{ + mcpServer: &mcpv1beta1.MCPServer{ + Spec: mcpv1beta1.MCPServerSpec{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + ResourceMetadataOverrides: mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "deployment-label": "should-not-appear", }, @@ -306,7 +306,7 @@ func TestGetProxyDeploymentTemplateLabelsAndAnnotations(t *testing.T) { "deployment-annotation": "should-not-appear", }, }, - PodTemplateMetadataOverrides: &mcpv1alpha1.ResourceMetadataOverrides{ + PodTemplateMetadataOverrides: &mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "pod-label": "should-appear", }, diff --git a/pkg/ratelimit/limiter.go b/pkg/ratelimit/limiter.go index 0ff06f412f..3490c1cbaa 100644 --- a/pkg/ratelimit/limiter.go +++ b/pkg/ratelimit/limiter.go @@ -14,7 +14,7 @@ import ( "github.com/redis/go-redis/v9" - v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/ratelimit/internal/bucket" ) @@ -39,7 +39,7 @@ type Decision struct { // NewLimiter constructs a Limiter from CRD configuration. // Returns a no-op limiter (always allows) when crd is nil. // namespace and name identify the MCP server for Redis key derivation. -func NewLimiter(client redis.Cmdable, namespace, name string, crd *v1alpha1.RateLimitConfig) (Limiter, error) { +func NewLimiter(client redis.Cmdable, namespace, name string, crd *v1beta1.RateLimitConfig) (Limiter, error) { if crd == nil { return noopLimiter{}, nil } @@ -177,7 +177,7 @@ func (noopLimiter) Allow(context.Context, string, string) (*Decision, error) { } // validateBucketCRD checks that a CRD bucket spec has valid parameters. -func validateBucketCRD(b *v1alpha1.RateLimitBucket) (int32, time.Duration, error) { +func validateBucketCRD(b *v1beta1.RateLimitBucket) (int32, time.Duration, error) { if b.MaxTokens < 1 { return 0, 0, fmt.Errorf("maxTokens must be >= 1, got %d", b.MaxTokens) } @@ -189,7 +189,7 @@ func validateBucketCRD(b *v1alpha1.RateLimitBucket) (int32, time.Duration, error } // newBucket validates a CRD bucket spec and creates a TokenBucket. -func newBucket(namespace, serverName, suffix string, b *v1alpha1.RateLimitBucket) (*bucket.TokenBucket, error) { +func newBucket(namespace, serverName, suffix string, b *v1beta1.RateLimitBucket) (*bucket.TokenBucket, error) { maxTokens, refillPeriod, err := validateBucketCRD(b) if err != nil { return nil, err @@ -199,7 +199,7 @@ func newBucket(namespace, serverName, suffix string, b *v1alpha1.RateLimitBucket // newBucketSpec validates a CRD bucket spec and creates a deferred bucketSpec // for per-user buckets that are materialized at Allow() time. -func newBucketSpec(namespace, serverName string, b *v1alpha1.RateLimitBucket) (bucketSpec, error) { +func newBucketSpec(namespace, serverName string, b *v1beta1.RateLimitBucket) (bucketSpec, error) { maxTokens, refillPeriod, err := validateBucketCRD(b) if err != nil { return bucketSpec{}, err diff --git a/pkg/ratelimit/limiter_test.go b/pkg/ratelimit/limiter_test.go index a825303d99..a007191d3c 100644 --- a/pkg/ratelimit/limiter_test.go +++ b/pkg/ratelimit/limiter_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) func newTestClient(t *testing.T) (*redis.Client, *miniredis.Miniredis) { @@ -42,8 +42,8 @@ func TestNewLimiter_ZeroMaxTokens(t *testing.T) { t.Parallel() client, _ := newTestClient(t) - crd := &v1alpha1.RateLimitConfig{ - Shared: &v1alpha1.RateLimitBucket{ + crd := &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{ MaxTokens: 0, RefillPeriod: metav1.Duration{Duration: time.Minute}, }, @@ -58,8 +58,8 @@ func TestNewLimiter_ZeroDuration(t *testing.T) { t.Parallel() client, _ := newTestClient(t) - crd := &v1alpha1.RateLimitConfig{ - Shared: &v1alpha1.RateLimitBucket{ + crd := &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{ MaxTokens: 100, RefillPeriod: metav1.Duration{Duration: 0}, }, @@ -75,8 +75,8 @@ func TestLimiter_ServerGlobalExhausted(t *testing.T) { client, _ := newTestClient(t) ctx := t.Context() - crd := &v1alpha1.RateLimitConfig{ - Shared: &v1alpha1.RateLimitBucket{MaxTokens: 2, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + crd := &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{MaxTokens: 2, RefillPeriod: metav1.Duration{Duration: time.Minute}}, } l, err := NewLimiter(client, "ns", "srv", crd) require.NoError(t, err) @@ -98,11 +98,11 @@ func TestLimiter_PerToolIsolation(t *testing.T) { client, _ := newTestClient(t) ctx := t.Context() - crd := &v1alpha1.RateLimitConfig{ - Tools: []v1alpha1.ToolRateLimitConfig{ + crd := &v1beta1.RateLimitConfig{ + Tools: []v1beta1.ToolRateLimitConfig{ { Name: "search", - Shared: &v1alpha1.RateLimitBucket{MaxTokens: 1, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + Shared: &v1beta1.RateLimitBucket{MaxTokens: 1, RefillPeriod: metav1.Duration{Duration: time.Minute}}, }, }, } @@ -128,12 +128,12 @@ func TestLimiter_ServerAndPerToolBothRequired(t *testing.T) { client, _ := newTestClient(t) ctx := t.Context() - crd := &v1alpha1.RateLimitConfig{ - Shared: &v1alpha1.RateLimitBucket{MaxTokens: 5, RefillPeriod: metav1.Duration{Duration: time.Minute}}, - Tools: []v1alpha1.ToolRateLimitConfig{ + crd := &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{MaxTokens: 5, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + Tools: []v1beta1.ToolRateLimitConfig{ { Name: "search", - Shared: &v1alpha1.RateLimitBucket{MaxTokens: 2, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + Shared: &v1beta1.RateLimitBucket{MaxTokens: 2, RefillPeriod: metav1.Duration{Duration: time.Minute}}, }, }, } @@ -161,8 +161,8 @@ func TestLimiter_RedisUnavailableReturnsError(t *testing.T) { t.Parallel() client, mr := newTestClient(t) - crd := &v1alpha1.RateLimitConfig{ - Shared: &v1alpha1.RateLimitBucket{MaxTokens: 10, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + crd := &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{MaxTokens: 10, RefillPeriod: metav1.Duration{Duration: time.Minute}}, } l, err := NewLimiter(client, "ns", "srv", crd) require.NoError(t, err) @@ -178,8 +178,8 @@ func TestLimiter_PerUserServerLevel(t *testing.T) { client, _ := newTestClient(t) ctx := t.Context() - crd := &v1alpha1.RateLimitConfig{ - PerUser: &v1alpha1.RateLimitBucket{MaxTokens: 2, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + crd := &v1beta1.RateLimitConfig{ + PerUser: &v1beta1.RateLimitBucket{MaxTokens: 2, RefillPeriod: metav1.Duration{Duration: time.Minute}}, } l, err := NewLimiter(client, "ns", "srv", crd) require.NoError(t, err) @@ -206,11 +206,11 @@ func TestLimiter_PerToolPerUserIsolation(t *testing.T) { client, _ := newTestClient(t) ctx := t.Context() - crd := &v1alpha1.RateLimitConfig{ - Tools: []v1alpha1.ToolRateLimitConfig{ + crd := &v1beta1.RateLimitConfig{ + Tools: []v1beta1.ToolRateLimitConfig{ { Name: "search", - PerUser: &v1alpha1.RateLimitBucket{MaxTokens: 1, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + PerUser: &v1beta1.RateLimitBucket{MaxTokens: 1, RefillPeriod: metav1.Duration{Duration: time.Minute}}, }, }, } @@ -243,12 +243,12 @@ func TestLimiter_ServerAndToolPerUserBothRequired(t *testing.T) { client, _ := newTestClient(t) ctx := t.Context() - crd := &v1alpha1.RateLimitConfig{ - PerUser: &v1alpha1.RateLimitBucket{MaxTokens: 5, RefillPeriod: metav1.Duration{Duration: time.Minute}}, - Tools: []v1alpha1.ToolRateLimitConfig{ + crd := &v1beta1.RateLimitConfig{ + PerUser: &v1beta1.RateLimitBucket{MaxTokens: 5, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + Tools: []v1beta1.ToolRateLimitConfig{ { Name: "search", - PerUser: &v1alpha1.RateLimitBucket{MaxTokens: 2, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + PerUser: &v1beta1.RateLimitBucket{MaxTokens: 2, RefillPeriod: metav1.Duration{Duration: time.Minute}}, }, }, } @@ -280,9 +280,9 @@ func TestLimiter_PerUserRejectionDoesNotDrainShared(t *testing.T) { // Shared: 3 tokens, PerUser: 1 token. // A noisy user hitting their per-user limit must not consume shared tokens. - crd := &v1alpha1.RateLimitConfig{ - Shared: &v1alpha1.RateLimitBucket{MaxTokens: 3, RefillPeriod: metav1.Duration{Duration: time.Minute}}, - PerUser: &v1alpha1.RateLimitBucket{MaxTokens: 1, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + crd := &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{MaxTokens: 3, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + PerUser: &v1beta1.RateLimitBucket{MaxTokens: 1, RefillPeriod: metav1.Duration{Duration: time.Minute}}, } l, err := NewLimiter(client, "ns", "srv", crd) require.NoError(t, err) @@ -316,8 +316,8 @@ func TestLimiter_RedisUnavailablePerUser(t *testing.T) { t.Parallel() client, mr := newTestClient(t) - crd := &v1alpha1.RateLimitConfig{ - PerUser: &v1alpha1.RateLimitBucket{MaxTokens: 10, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + crd := &v1beta1.RateLimitConfig{ + PerUser: &v1beta1.RateLimitBucket{MaxTokens: 10, RefillPeriod: metav1.Duration{Duration: time.Minute}}, } l, err := NewLimiter(client, "ns", "srv", crd) require.NoError(t, err) @@ -332,8 +332,8 @@ func TestNewLimiter_PerUserZeroMaxTokens(t *testing.T) { t.Parallel() client, _ := newTestClient(t) - crd := &v1alpha1.RateLimitConfig{ - PerUser: &v1alpha1.RateLimitBucket{MaxTokens: 0, RefillPeriod: metav1.Duration{Duration: time.Minute}}, + crd := &v1beta1.RateLimitConfig{ + PerUser: &v1beta1.RateLimitBucket{MaxTokens: 0, RefillPeriod: metav1.Duration{Duration: time.Minute}}, } _, err := NewLimiter(client, "ns", "srv", crd) assert.Error(t, err) @@ -344,11 +344,11 @@ func TestNewLimiter_ToolPerUserZeroDuration(t *testing.T) { t.Parallel() client, _ := newTestClient(t) - crd := &v1alpha1.RateLimitConfig{ - Tools: []v1alpha1.ToolRateLimitConfig{ + crd := &v1beta1.RateLimitConfig{ + Tools: []v1beta1.ToolRateLimitConfig{ { Name: "search", - PerUser: &v1alpha1.RateLimitBucket{MaxTokens: 5, RefillPeriod: metav1.Duration{Duration: 0}}, + PerUser: &v1beta1.RateLimitBucket{MaxTokens: 5, RefillPeriod: metav1.Duration{Duration: 0}}, }, }, } diff --git a/pkg/ratelimit/middleware.go b/pkg/ratelimit/middleware.go index c7ecfafd20..ee108fdf87 100644 --- a/pkg/ratelimit/middleware.go +++ b/pkg/ratelimit/middleware.go @@ -15,7 +15,7 @@ import ( "github.com/redis/go-redis/v9" - v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/mcp" "github.com/stacklok/toolhive/pkg/transport/types" @@ -39,11 +39,11 @@ const ( // MiddlewareParams holds the parameters for the rate limit middleware factory. type MiddlewareParams struct { - Namespace string `json:"namespace"` - ServerName string `json:"server_name"` - Config *v1alpha1.RateLimitConfig `json:"config"` - RedisAddr string `json:"redis_addr,omitempty"` - RedisDB int32 `json:"redis_db,omitempty"` + Namespace string `json:"namespace"` + ServerName string `json:"server_name"` + Config *v1beta1.RateLimitConfig `json:"config"` + RedisAddr string `json:"redis_addr,omitempty"` + RedisDB int32 `json:"redis_db,omitempty"` } // rateLimitMiddleware wraps rate limiting functionality for the factory pattern. diff --git a/pkg/runner/config.go b/pkg/runner/config.go index 519f277abd..2cb997087b 100644 --- a/pkg/runner/config.go +++ b/pkg/runner/config.go @@ -12,7 +12,7 @@ import ( "log/slog" "github.com/stacklok/toolhive-core/permissions" - v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/audit" "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/auth/awssts" @@ -160,7 +160,7 @@ type RunConfig struct { // RateLimitConfig contains the CRD rate limiting configuration. // When set, rate limiting middleware is added to the proxy middleware chain. - RateLimitConfig *v1alpha1.RateLimitConfig `json:"rate_limit_config,omitempty" yaml:"rate_limit_config,omitempty"` + RateLimitConfig *v1beta1.RateLimitConfig `json:"rate_limit_config,omitempty" yaml:"rate_limit_config,omitempty"` // RateLimitNamespace is the Kubernetes namespace for Redis key derivation. RateLimitNamespace string `json:"rate_limit_namespace,omitempty" yaml:"rate_limit_namespace,omitempty"` diff --git a/pkg/runner/config_builder.go b/pkg/runner/config_builder.go index 89cdf2c318..13113f4a35 100644 --- a/pkg/runner/config_builder.go +++ b/pkg/runner/config_builder.go @@ -17,7 +17,7 @@ import ( "github.com/stacklok/toolhive-core/permissions" regtypes "github.com/stacklok/toolhive-core/registry/types" - v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/audit" "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/auth/awssts" @@ -546,7 +546,7 @@ func WithTelemetryConfig(config *telemetry.Config) RunConfigBuilderOption { } // WithRateLimitConfig sets the rate limiting configuration. -func WithRateLimitConfig(namespace string, config *v1alpha1.RateLimitConfig) RunConfigBuilderOption { +func WithRateLimitConfig(namespace string, config *v1beta1.RateLimitConfig) RunConfigBuilderOption { return func(b *runConfigBuilder) error { b.config.RateLimitConfig = config b.config.RateLimitNamespace = namespace diff --git a/pkg/runner/middleware_test.go b/pkg/runner/middleware_test.go index cf604da16c..ee890dc52a 100644 --- a/pkg/runner/middleware_test.go +++ b/pkg/runner/middleware_test.go @@ -14,7 +14,7 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/audit" "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/auth/awssts" @@ -764,8 +764,8 @@ func TestAddRateLimitMiddleware(t *testing.T) { { name: "rate limit without Redis returns error", config: &RunConfig{ - RateLimitConfig: &v1alpha1.RateLimitConfig{ - Shared: &v1alpha1.RateLimitBucket{ + RateLimitConfig: &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{ MaxTokens: 10, RefillPeriod: metav1.Duration{Duration: time.Minute}, }, @@ -778,8 +778,8 @@ func TestAddRateLimitMiddleware(t *testing.T) { config: &RunConfig{ Name: "test-server", RateLimitNamespace: "default", - RateLimitConfig: &v1alpha1.RateLimitConfig{ - Shared: &v1alpha1.RateLimitBucket{ + RateLimitConfig: &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{ MaxTokens: 10, RefillPeriod: metav1.Duration{Duration: time.Minute}, }, @@ -840,8 +840,8 @@ func TestPopulateMiddlewareConfigs_RateLimit(t *testing.T) { config: &RunConfig{ Name: "test-server", RateLimitNamespace: "default", - RateLimitConfig: &v1alpha1.RateLimitConfig{ - Shared: &v1alpha1.RateLimitBucket{ + RateLimitConfig: &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{ MaxTokens: 5, RefillPeriod: metav1.Duration{Duration: time.Minute}, }, diff --git a/pkg/vmcp/auth/converters/header_injection.go b/pkg/vmcp/auth/converters/header_injection.go index 3d951e4638..b656463cb1 100644 --- a/pkg/vmcp/auth/converters/header_injection.go +++ b/pkg/vmcp/auth/converters/header_injection.go @@ -12,7 +12,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" ) @@ -28,7 +28,7 @@ func (*HeaderInjectionConverter) StrategyType() string { // Sets HeaderValueEnv when ValueSecretRef is present, similar to token exchange. // Secrets are mounted as environment variables, not resolved into ConfigMap. func (*HeaderInjectionConverter) ConvertToStrategy( - externalAuth *mcpv1alpha1.MCPExternalAuthConfig, + externalAuth *mcpv1beta1.MCPExternalAuthConfig, ) (*authtypes.BackendAuthStrategy, error) { headerInjection := externalAuth.Spec.HeaderInjection if headerInjection == nil { @@ -52,7 +52,7 @@ func (*HeaderInjectionConverter) ConvertToStrategy( // (see ConvertToStrategy). func (*HeaderInjectionConverter) ResolveSecrets( ctx context.Context, - externalAuth *mcpv1alpha1.MCPExternalAuthConfig, + externalAuth *mcpv1beta1.MCPExternalAuthConfig, k8sClient client.Client, namespace string, strategy *authtypes.BackendAuthStrategy, diff --git a/pkg/vmcp/auth/converters/header_injection_test.go b/pkg/vmcp/auth/converters/header_injection_test.go index 6f6b5c1c1d..5b4d4426f4 100644 --- a/pkg/vmcp/auth/converters/header_injection_test.go +++ b/pkg/vmcp/auth/converters/header_injection_test.go @@ -14,7 +14,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" ) @@ -30,23 +30,23 @@ func TestHeaderInjectionConverter_ConvertToStrategy(t *testing.T) { tests := []struct { name string - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + externalAuth *mcpv1beta1.MCPExternalAuthConfig wantStrategy *authtypes.BackendAuthStrategy wantErr bool errContains string }{ { name: "converts header injection config to strategy", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "api-secret", Key: "key", }, @@ -63,13 +63,13 @@ func TestHeaderInjectionConverter_ConvertToStrategy(t *testing.T) { }, { name: "nil header injection config", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, HeaderInjection: nil, }, }, @@ -104,7 +104,7 @@ func TestHeaderInjectionConverter_ResolveSecrets(t *testing.T) { tests := []struct { name string - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + externalAuth *mcpv1beta1.MCPExternalAuthConfig secret *corev1.Secret inputStrategy *authtypes.BackendAuthStrategy wantStrategy *authtypes.BackendAuthStrategy @@ -113,16 +113,16 @@ func TestHeaderInjectionConverter_ResolveSecrets(t *testing.T) { }{ { name: "successful secret resolution", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "api-secret", Key: "key", }, @@ -155,16 +155,16 @@ func TestHeaderInjectionConverter_ResolveSecrets(t *testing.T) { }, { name: "missing secret", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "missing-secret", Key: "key", }, @@ -182,16 +182,16 @@ func TestHeaderInjectionConverter_ResolveSecrets(t *testing.T) { }, { name: "missing key in secret", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "api-secret", Key: "missing-key", }, @@ -218,13 +218,13 @@ func TestHeaderInjectionConverter_ResolveSecrets(t *testing.T) { }, { name: "nil strategy", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, HeaderInjection: nil, }, }, @@ -234,13 +234,13 @@ func TestHeaderInjectionConverter_ResolveSecrets(t *testing.T) { }, { name: "nil header injection config", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, HeaderInjection: nil, }, }, @@ -253,14 +253,14 @@ func TestHeaderInjectionConverter_ResolveSecrets(t *testing.T) { }, { name: "nil valueSecretRef", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", ValueSecretRef: nil, }, @@ -284,7 +284,7 @@ func TestHeaderInjectionConverter_ResolveSecrets(t *testing.T) { // Create fake client with scheme scheme := runtime.NewScheme() _ = corev1.AddToScheme(scheme) - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) // Add secret if provided var objects []runtime.Object diff --git a/pkg/vmcp/auth/converters/interface.go b/pkg/vmcp/auth/converters/interface.go index e589be5567..ff78c0cb5a 100644 --- a/pkg/vmcp/auth/converters/interface.go +++ b/pkg/vmcp/auth/converters/interface.go @@ -12,7 +12,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" ) @@ -25,7 +25,7 @@ type StrategyConverter interface { // ConvertToStrategy converts an MCPExternalAuthConfig to a BackendAuthStrategy with typed fields. // Secret references should be represented as environment variable names (e.g., "TOOLHIVE_*") // that will be resolved later by ResolveSecrets or at runtime. - ConvertToStrategy(externalAuth *mcpv1alpha1.MCPExternalAuthConfig) (*authtypes.BackendAuthStrategy, error) + ConvertToStrategy(externalAuth *mcpv1beta1.MCPExternalAuthConfig) (*authtypes.BackendAuthStrategy, error) // ResolveSecrets fetches secrets from Kubernetes and replaces environment variable references // with actual secret values in the strategy configuration. This is used in discovered auth mode where @@ -35,7 +35,7 @@ type StrategyConverter interface { // For non-discovered mode (where secrets are mounted as env vars), this is typically a no-op. ResolveSecrets( ctx context.Context, - externalAuth *mcpv1alpha1.MCPExternalAuthConfig, + externalAuth *mcpv1beta1.MCPExternalAuthConfig, k8sClient client.Client, namespace string, strategy *authtypes.BackendAuthStrategy, @@ -45,7 +45,7 @@ type StrategyConverter interface { // Registry holds registered strategy converters type Registry struct { mu sync.RWMutex - converters map[mcpv1alpha1.ExternalAuthType]StrategyConverter + converters map[mcpv1beta1.ExternalAuthType]StrategyConverter } var ( @@ -66,27 +66,27 @@ func DefaultRegistry() *Registry { // For most use cases, use DefaultRegistry() instead to avoid unnecessary allocations. func NewRegistry() *Registry { r := &Registry{ - converters: make(map[mcpv1alpha1.ExternalAuthType]StrategyConverter), + converters: make(map[mcpv1beta1.ExternalAuthType]StrategyConverter), } // Register built-in converters - r.Register(mcpv1alpha1.ExternalAuthTypeTokenExchange, &TokenExchangeConverter{}) - r.Register(mcpv1alpha1.ExternalAuthTypeHeaderInjection, &HeaderInjectionConverter{}) - r.Register(mcpv1alpha1.ExternalAuthTypeUnauthenticated, &UnauthenticatedConverter{}) - r.Register(mcpv1alpha1.ExternalAuthTypeUpstreamInject, &UpstreamInjectConverter{}) + r.Register(mcpv1beta1.ExternalAuthTypeTokenExchange, &TokenExchangeConverter{}) + r.Register(mcpv1beta1.ExternalAuthTypeHeaderInjection, &HeaderInjectionConverter{}) + r.Register(mcpv1beta1.ExternalAuthTypeUnauthenticated, &UnauthenticatedConverter{}) + r.Register(mcpv1beta1.ExternalAuthTypeUpstreamInject, &UpstreamInjectConverter{}) return r } // Register adds a converter to the registry -func (r *Registry) Register(authType mcpv1alpha1.ExternalAuthType, converter StrategyConverter) { +func (r *Registry) Register(authType mcpv1beta1.ExternalAuthType, converter StrategyConverter) { r.mu.Lock() defer r.mu.Unlock() r.converters[authType] = converter } // GetConverter retrieves a converter by auth type -func (r *Registry) GetConverter(authType mcpv1alpha1.ExternalAuthType) (StrategyConverter, error) { +func (r *Registry) GetConverter(authType mcpv1beta1.ExternalAuthType) (StrategyConverter, error) { r.mu.RLock() defer r.mu.RUnlock() @@ -101,7 +101,7 @@ func (r *Registry) GetConverter(authType mcpv1alpha1.ExternalAuthType) (Strategy // an external auth config to a BackendAuthStrategy with typed fields. // This is the main entry point for converting auth configs at runtime. func ConvertToStrategy( - externalAuth *mcpv1alpha1.MCPExternalAuthConfig, + externalAuth *mcpv1beta1.MCPExternalAuthConfig, ) (*authtypes.BackendAuthStrategy, error) { if externalAuth == nil { return nil, fmt.Errorf("external auth config is nil") @@ -125,7 +125,7 @@ func ConvertToStrategy( // secrets for a given strategy. func ResolveSecretsForStrategy( ctx context.Context, - externalAuth *mcpv1alpha1.MCPExternalAuthConfig, + externalAuth *mcpv1beta1.MCPExternalAuthConfig, k8sClient client.Client, namespace string, strategy *authtypes.BackendAuthStrategy, @@ -154,7 +154,7 @@ func ResolveSecretsForStrategy( // Returns nil strategy and nil error if externalAuthConfigRef is nil (no auth configured). func DiscoverAndResolveAuth( ctx context.Context, - externalAuthConfigRef *mcpv1alpha1.ExternalAuthConfigRef, + externalAuthConfigRef *mcpv1beta1.ExternalAuthConfigRef, namespace string, k8sClient client.Client, ) (*authtypes.BackendAuthStrategy, error) { @@ -165,7 +165,7 @@ func DiscoverAndResolveAuth( } // Fetch the MCPExternalAuthConfig - externalAuth := &mcpv1alpha1.MCPExternalAuthConfig{} + externalAuth := &mcpv1beta1.MCPExternalAuthConfig{} key := client.ObjectKey{ Name: externalAuthConfigRef.Name, Namespace: namespace, diff --git a/pkg/vmcp/auth/converters/registry_test.go b/pkg/vmcp/auth/converters/registry_test.go index a365a2148d..d793167865 100644 --- a/pkg/vmcp/auth/converters/registry_test.go +++ b/pkg/vmcp/auth/converters/registry_test.go @@ -15,7 +15,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" ) @@ -72,25 +72,25 @@ func TestDefaultRegistry(t *testing.T) { registry := DefaultRegistry() // Test token exchange converter - tokenExchangeConverter, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeTokenExchange) + tokenExchangeConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeTokenExchange) require.NoError(t, err) require.NotNil(t, tokenExchangeConverter) assert.Equal(t, "token_exchange", tokenExchangeConverter.StrategyType()) // Test header injection converter - headerInjectionConverter, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeHeaderInjection) + headerInjectionConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeHeaderInjection) require.NoError(t, err) require.NotNil(t, headerInjectionConverter) assert.Equal(t, "header_injection", headerInjectionConverter.StrategyType()) // Test unauthenticated converter - unauthenticatedConverter, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeUnauthenticated) + unauthenticatedConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeUnauthenticated) require.NoError(t, err) require.NotNil(t, unauthenticatedConverter) assert.Equal(t, "unauthenticated", unauthenticatedConverter.StrategyType()) // Test upstream inject converter - upstreamInjectConverter, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeUpstreamInject) + upstreamInjectConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeUpstreamInject) require.NoError(t, err) require.NotNil(t, upstreamInjectConverter) assert.Equal(t, "upstream_inject", upstreamInjectConverter.StrategyType()) @@ -108,19 +108,19 @@ func TestNewRegistry(t *testing.T) { require.NotNil(t, registry.converters) // Verify built-in converters are registered - tokenExchangeConverter, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeTokenExchange) + tokenExchangeConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeTokenExchange) require.NoError(t, err) assert.NotNil(t, tokenExchangeConverter) - headerInjectionConverter, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeHeaderInjection) + headerInjectionConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeHeaderInjection) require.NoError(t, err) assert.NotNil(t, headerInjectionConverter) - unauthenticatedConverter, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeUnauthenticated) + unauthenticatedConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeUnauthenticated) require.NoError(t, err) assert.NotNil(t, unauthenticatedConverter) - upstreamInjectConverter, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeUpstreamInject) + upstreamInjectConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeUpstreamInject) require.NoError(t, err) assert.NotNil(t, upstreamInjectConverter) }) @@ -142,13 +142,13 @@ func TestNewRegistry(t *testing.T) { // Verify correct types are registered testCases := []struct { - authType mcpv1alpha1.ExternalAuthType + authType mcpv1beta1.ExternalAuthType expectedType string }{ - {mcpv1alpha1.ExternalAuthTypeTokenExchange, "token_exchange"}, - {mcpv1alpha1.ExternalAuthTypeHeaderInjection, "header_injection"}, - {mcpv1alpha1.ExternalAuthTypeUnauthenticated, "unauthenticated"}, - {mcpv1alpha1.ExternalAuthTypeUpstreamInject, "upstream_inject"}, + {mcpv1beta1.ExternalAuthTypeTokenExchange, "token_exchange"}, + {mcpv1beta1.ExternalAuthTypeHeaderInjection, "header_injection"}, + {mcpv1beta1.ExternalAuthTypeUnauthenticated, "unauthenticated"}, + {mcpv1beta1.ExternalAuthTypeUpstreamInject, "upstream_inject"}, } for _, tc := range testCases { @@ -167,13 +167,13 @@ func TestRegistry_Register(t *testing.T) { t.Parallel() registry := &Registry{ - converters: make(map[mcpv1alpha1.ExternalAuthType]StrategyConverter), + converters: make(map[mcpv1beta1.ExternalAuthType]StrategyConverter), } converter := &HeaderInjectionConverter{} - registry.Register(mcpv1alpha1.ExternalAuthTypeHeaderInjection, converter) + registry.Register(mcpv1beta1.ExternalAuthTypeHeaderInjection, converter) - retrieved, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeHeaderInjection) + retrieved, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeHeaderInjection) require.NoError(t, err) assert.Same(t, converter, retrieved) }) @@ -182,24 +182,24 @@ func TestRegistry_Register(t *testing.T) { t.Parallel() registry := &Registry{ - converters: make(map[mcpv1alpha1.ExternalAuthType]StrategyConverter), + converters: make(map[mcpv1beta1.ExternalAuthType]StrategyConverter), } // Register a HeaderInjectionConverter first converter1 := &HeaderInjectionConverter{} - registry.Register(mcpv1alpha1.ExternalAuthTypeHeaderInjection, converter1) + registry.Register(mcpv1beta1.ExternalAuthTypeHeaderInjection, converter1) // Verify first converter is registered - retrieved, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeHeaderInjection) + retrieved, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeHeaderInjection) require.NoError(t, err) assert.Equal(t, "header_injection", retrieved.StrategyType()) // Register a TokenExchangeConverter with same auth type (should overwrite) converter2 := &TokenExchangeConverter{} - registry.Register(mcpv1alpha1.ExternalAuthTypeHeaderInjection, converter2) + registry.Register(mcpv1beta1.ExternalAuthTypeHeaderInjection, converter2) // Verify second converter overwrote the first - retrieved, err = registry.GetConverter(mcpv1alpha1.ExternalAuthTypeHeaderInjection) + retrieved, err = registry.GetConverter(mcpv1beta1.ExternalAuthTypeHeaderInjection) require.NoError(t, err) assert.Equal(t, "token_exchange", retrieved.StrategyType(), "should return second converter with different strategy type") }) @@ -208,7 +208,7 @@ func TestRegistry_Register(t *testing.T) { t.Parallel() registry := &Registry{ - converters: make(map[mcpv1alpha1.ExternalAuthType]StrategyConverter), + converters: make(map[mcpv1beta1.ExternalAuthType]StrategyConverter), } const numGoroutines = 50 @@ -220,7 +220,7 @@ func TestRegistry_Register(t *testing.T) { go func(id int) { defer wg.Done() // Use different auth types to avoid overwrites - authType := mcpv1alpha1.ExternalAuthType("test-type-" + string(rune('A'+id%26))) + authType := mcpv1beta1.ExternalAuthType("test-type-" + string(rune('A'+id%26))) converter := &HeaderInjectionConverter{} registry.Register(authType, converter) }(i) @@ -241,7 +241,7 @@ func TestRegistry_GetConverter(t *testing.T) { registry := NewRegistry() - converter, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeHeaderInjection) + converter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeHeaderInjection) require.NoError(t, err) require.NotNil(t, converter) assert.IsType(t, &HeaderInjectionConverter{}, converter) @@ -252,7 +252,7 @@ func TestRegistry_GetConverter(t *testing.T) { registry := NewRegistry() - converter, err := registry.GetConverter(mcpv1alpha1.ExternalAuthType("unsupported")) + converter, err := registry.GetConverter(mcpv1beta1.ExternalAuthType("unsupported")) assert.Error(t, err) assert.Nil(t, converter) assert.Contains(t, err.Error(), "unsupported auth type") @@ -273,7 +273,7 @@ func TestRegistry_GetConverter(t *testing.T) { for i := 0; i < numGoroutines; i++ { go func() { defer wg.Done() - converter, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeHeaderInjection) + converter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeHeaderInjection) if err != nil { errs <- err return @@ -300,16 +300,16 @@ func TestConvertToStrategy(t *testing.T) { t.Run("converts header injection config", func(t *testing.T) { t.Parallel() - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "api-secret", Key: "key", }, @@ -328,17 +328,17 @@ func TestConvertToStrategy(t *testing.T) { t.Run("converts token exchange config", func(t *testing.T) { t.Parallel() - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "oauth-secret", Key: "client-secret", }, @@ -369,13 +369,13 @@ func TestConvertToStrategy(t *testing.T) { t.Run("returns error for unsupported auth type", func(t *testing.T) { t.Parallel() - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthType("unsupported"), + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthType("unsupported"), }, } @@ -388,13 +388,13 @@ func TestConvertToStrategy(t *testing.T) { t.Run("returns error for invalid config", func(t *testing.T) { t.Parallel() - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, HeaderInjection: nil, // Invalid: missing required config }, } @@ -417,7 +417,7 @@ func TestResolveSecretsForStrategyFunc(t *testing.T) { // Create fake client with secret scheme := runtime.NewScheme() _ = corev1.AddToScheme(scheme) - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -434,16 +434,16 @@ func TestResolveSecretsForStrategyFunc(t *testing.T) { WithRuntimeObjects(secret). Build() - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "api-secret", Key: "key", }, @@ -492,13 +492,13 @@ func TestResolveSecretsForStrategyFunc(t *testing.T) { WithScheme(scheme). Build() - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthType("unsupported"), + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthType("unsupported"), }, } @@ -517,22 +517,22 @@ func TestResolveSecretsForStrategyFunc(t *testing.T) { // Create empty fake client (no secrets) scheme := runtime.NewScheme() _ = corev1.AddToScheme(scheme) - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) k8sClient := fake.NewClientBuilder(). WithScheme(scheme). Build() - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "missing-secret", Key: "key", }, diff --git a/pkg/vmcp/auth/converters/token_exchange.go b/pkg/vmcp/auth/converters/token_exchange.go index 5b4041bbbb..a1ae813266 100644 --- a/pkg/vmcp/auth/converters/token_exchange.go +++ b/pkg/vmcp/auth/converters/token_exchange.go @@ -12,7 +12,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" ) @@ -27,7 +27,7 @@ func (*TokenExchangeConverter) StrategyType() string { // ConvertToStrategy converts TokenExchangeConfig to a BackendAuthStrategy with typed fields. // Secret references are represented as environment variable names that will be resolved by ResolveSecrets. func (*TokenExchangeConverter) ConvertToStrategy( - externalAuth *mcpv1alpha1.MCPExternalAuthConfig, + externalAuth *mcpv1beta1.MCPExternalAuthConfig, ) (*authtypes.BackendAuthStrategy, error) { tokenExchange := externalAuth.Spec.TokenExchange if tokenExchange == nil { @@ -80,7 +80,7 @@ func (*TokenExchangeConverter) ConvertToStrategy( // If ClientSecretEnv is not set, the strategy is returned unchanged. func (*TokenExchangeConverter) ResolveSecrets( ctx context.Context, - externalAuth *mcpv1alpha1.MCPExternalAuthConfig, + externalAuth *mcpv1beta1.MCPExternalAuthConfig, k8sClient client.Client, namespace string, strategy *authtypes.BackendAuthStrategy, diff --git a/pkg/vmcp/auth/converters/token_exchange_test.go b/pkg/vmcp/auth/converters/token_exchange_test.go index 387b4be5a1..50e5e55ae8 100644 --- a/pkg/vmcp/auth/converters/token_exchange_test.go +++ b/pkg/vmcp/auth/converters/token_exchange_test.go @@ -15,7 +15,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" ) @@ -31,24 +31,24 @@ func TestTokenExchangeConverter_ConvertToStrategy(t *testing.T) { tests := []struct { name string - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + externalAuth *mcpv1beta1.MCPExternalAuthConfig wantStrategy *authtypes.BackendAuthStrategy wantErr bool errContains string }{ { name: "full token exchange config", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "client-secret", Key: "secret", }, @@ -74,14 +74,14 @@ func TestTokenExchangeConverter_ConvertToStrategy(t *testing.T) { }, { name: "minimal token exchange config", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "minimal-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", }, }, @@ -96,14 +96,14 @@ func TestTokenExchangeConverter_ConvertToStrategy(t *testing.T) { }, { name: "token exchange without client secret", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "no-secret-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", ClientID: "test-client", Audience: "https://api.example.com", @@ -122,14 +122,14 @@ func TestTokenExchangeConverter_ConvertToStrategy(t *testing.T) { }, { name: "subject token type id_token short form", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "id-token-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", SubjectTokenType: "id_token", }, @@ -146,14 +146,14 @@ func TestTokenExchangeConverter_ConvertToStrategy(t *testing.T) { }, { name: "subject token type jwt short form", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "jwt-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", SubjectTokenType: "jwt", }, @@ -170,14 +170,14 @@ func TestTokenExchangeConverter_ConvertToStrategy(t *testing.T) { }, { name: "subject token type already full URN", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "urn-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", SubjectTokenType: "urn:ietf:params:oauth:token-type:access_token", }, @@ -194,14 +194,14 @@ func TestTokenExchangeConverter_ConvertToStrategy(t *testing.T) { }, { name: "with scopes array", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "scopes-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", Scopes: []string{"openid", "profile", "email"}, }, @@ -218,14 +218,14 @@ func TestTokenExchangeConverter_ConvertToStrategy(t *testing.T) { }, { name: "with subject provider name", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "subject-provider-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", Audience: "https://api.example.com", SubjectProviderName: "github", @@ -244,14 +244,14 @@ func TestTokenExchangeConverter_ConvertToStrategy(t *testing.T) { }, { name: "subject provider name absent defaults to empty", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "no-subject-provider-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", Audience: "https://api.example.com", }, @@ -268,13 +268,13 @@ func TestTokenExchangeConverter_ConvertToStrategy(t *testing.T) { }, { name: "nil token exchange config", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "nil-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, TokenExchange: nil, }, }, @@ -309,7 +309,7 @@ func TestTokenExchangeConverter_ResolveSecrets(t *testing.T) { tests := []struct { name string - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + externalAuth *mcpv1beta1.MCPExternalAuthConfig setupSecrets func(client.Client) error inputStrategy *authtypes.BackendAuthStrategy wantStrategy *authtypes.BackendAuthStrategy @@ -318,17 +318,17 @@ func TestTokenExchangeConverter_ResolveSecrets(t *testing.T) { }{ { name: "successful secret resolution", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "client-secret", Key: "secret", }, @@ -368,14 +368,14 @@ func TestTokenExchangeConverter_ResolveSecrets(t *testing.T) { }, { name: "no-op when client_secret_ref not present", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", ClientID: "test-client", ClientSecretRef: nil, // No secret ref, so no-op @@ -400,14 +400,14 @@ func TestTokenExchangeConverter_ResolveSecrets(t *testing.T) { }, { name: "minimal config no-op", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "minimal-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", }, }, @@ -428,13 +428,13 @@ func TestTokenExchangeConverter_ResolveSecrets(t *testing.T) { }, { name: "nil strategy", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, TokenExchange: nil, }, }, @@ -444,13 +444,13 @@ func TestTokenExchangeConverter_ResolveSecrets(t *testing.T) { }, { name: "nil token exchange config in external auth", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, TokenExchange: nil, }, }, @@ -463,14 +463,14 @@ func TestTokenExchangeConverter_ResolveSecrets(t *testing.T) { }, { name: "nil clientSecretRef", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", ClientID: "test-client", ClientSecretRef: nil, @@ -493,17 +493,17 @@ func TestTokenExchangeConverter_ResolveSecrets(t *testing.T) { }, { name: "missing secret", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "nonexistent-secret", Key: "secret", }, @@ -521,17 +521,17 @@ func TestTokenExchangeConverter_ResolveSecrets(t *testing.T) { }, { name: "missing key in secret", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "client-secret", Key: "wrong-key", }, @@ -567,7 +567,7 @@ func TestTokenExchangeConverter_ResolveSecrets(t *testing.T) { // Create fake client with schemes scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() diff --git a/pkg/vmcp/auth/converters/unauthenticated.go b/pkg/vmcp/auth/converters/unauthenticated.go index 31294ad3ad..d586aeeacc 100644 --- a/pkg/vmcp/auth/converters/unauthenticated.go +++ b/pkg/vmcp/auth/converters/unauthenticated.go @@ -8,7 +8,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" ) @@ -24,7 +24,7 @@ func (*UnauthenticatedConverter) StrategyType() string { // ConvertToStrategy converts an MCPExternalAuthConfig with type "unauthenticated" to a BackendAuthStrategy. // Since unauthenticated requires no configuration, this simply returns a strategy with the correct type. func (*UnauthenticatedConverter) ConvertToStrategy( - _ *mcpv1alpha1.MCPExternalAuthConfig, + _ *mcpv1beta1.MCPExternalAuthConfig, ) (*authtypes.BackendAuthStrategy, error) { return &authtypes.BackendAuthStrategy{ Type: authtypes.StrategyTypeUnauthenticated, @@ -35,7 +35,7 @@ func (*UnauthenticatedConverter) ConvertToStrategy( // ResolveSecrets is a no-op for unauthenticated strategy since there are no secrets to resolve. func (*UnauthenticatedConverter) ResolveSecrets( _ context.Context, - _ *mcpv1alpha1.MCPExternalAuthConfig, + _ *mcpv1beta1.MCPExternalAuthConfig, _ client.Client, _ string, strategy *authtypes.BackendAuthStrategy, diff --git a/pkg/vmcp/auth/converters/unauthenticated_test.go b/pkg/vmcp/auth/converters/unauthenticated_test.go index f8088a926b..325b42ac52 100644 --- a/pkg/vmcp/auth/converters/unauthenticated_test.go +++ b/pkg/vmcp/auth/converters/unauthenticated_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" ) @@ -27,19 +27,19 @@ func TestUnauthenticatedConverter_ConvertToStrategy(t *testing.T) { tests := []struct { name string - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + externalAuth *mcpv1beta1.MCPExternalAuthConfig expectedType string expectedError bool }{ { name: "valid unauthenticated config", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-unauthenticated", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUnauthenticated, }, }, expectedType: authtypes.StrategyTypeUnauthenticated, @@ -47,13 +47,13 @@ func TestUnauthenticatedConverter_ConvertToStrategy(t *testing.T) { }, { name: "unauthenticated with no extra fields", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-unauthenticated-minimal", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUnauthenticated, // No TokenExchange or HeaderInjection }, }, @@ -88,13 +88,13 @@ func TestUnauthenticatedConverter_ResolveSecrets(t *testing.T) { t.Parallel() converter := &UnauthenticatedConverter{} - externalAuth := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-unauthenticated", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUnauthenticated, }, } @@ -116,19 +116,19 @@ func TestUnauthenticatedConverter_Integration(t *testing.T) { // Test that unauthenticated converter is registered in default registry registry := DefaultRegistry() - converter, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeUnauthenticated) + converter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeUnauthenticated) require.NoError(t, err) require.NotNil(t, converter) assert.IsType(t, &UnauthenticatedConverter{}, converter) // Test end-to-end conversion using ConvertToStrategy convenience function - externalAuth := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-unauthenticated", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUnauthenticated, }, } diff --git a/pkg/vmcp/auth/converters/upstream_inject.go b/pkg/vmcp/auth/converters/upstream_inject.go index 6f8c1ff984..fdd4ade6ec 100644 --- a/pkg/vmcp/auth/converters/upstream_inject.go +++ b/pkg/vmcp/auth/converters/upstream_inject.go @@ -9,7 +9,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" ) @@ -26,7 +26,7 @@ func (*UpstreamInjectConverter) StrategyType() string { // ConvertToStrategy converts an MCPExternalAuthConfig with type "upstreamInject" to a BackendAuthStrategy. // It maps the CRD's UpstreamInjectSpec.ProviderName to the runtime UpstreamInjectConfig.ProviderName. func (*UpstreamInjectConverter) ConvertToStrategy( - externalAuth *mcpv1alpha1.MCPExternalAuthConfig, + externalAuth *mcpv1beta1.MCPExternalAuthConfig, ) (*authtypes.BackendAuthStrategy, error) { if externalAuth.Spec.UpstreamInject == nil { return nil, fmt.Errorf("upstream inject config is nil") @@ -44,7 +44,7 @@ func (*UpstreamInjectConverter) ConvertToStrategy( // The upstream IDP token is obtained at runtime by the embedded authorization server. func (*UpstreamInjectConverter) ResolveSecrets( _ context.Context, - _ *mcpv1alpha1.MCPExternalAuthConfig, + _ *mcpv1beta1.MCPExternalAuthConfig, _ client.Client, _ string, strategy *authtypes.BackendAuthStrategy, diff --git a/pkg/vmcp/auth/converters/upstream_inject_test.go b/pkg/vmcp/auth/converters/upstream_inject_test.go index 9cfc5eea4f..54323ee4eb 100644 --- a/pkg/vmcp/auth/converters/upstream_inject_test.go +++ b/pkg/vmcp/auth/converters/upstream_inject_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" ) @@ -27,21 +27,21 @@ func TestUpstreamInjectConverter_ConvertToStrategy(t *testing.T) { tests := []struct { name string - externalAuth *mcpv1alpha1.MCPExternalAuthConfig + externalAuth *mcpv1beta1.MCPExternalAuthConfig wantStrategy *authtypes.BackendAuthStrategy wantErr bool errContains string }{ { name: "valid config with ProviderName=github", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-upstream-inject", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUpstreamInject, - UpstreamInject: &mcpv1alpha1.UpstreamInjectSpec{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUpstreamInject, + UpstreamInject: &mcpv1beta1.UpstreamInjectSpec{ ProviderName: "github", }, }, @@ -56,13 +56,13 @@ func TestUpstreamInjectConverter_ConvertToStrategy(t *testing.T) { }, { name: "nil upstream inject spec", - externalAuth: &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "nil-config", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUpstreamInject, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUpstreamInject, UpstreamInject: nil, }, }, @@ -96,14 +96,14 @@ func TestUpstreamInjectConverter_ResolveSecrets(t *testing.T) { t.Parallel() converter := &UpstreamInjectConverter{} - externalAuth := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-upstream-inject", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUpstreamInject, - UpstreamInject: &mcpv1alpha1.UpstreamInjectSpec{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUpstreamInject, + UpstreamInject: &mcpv1beta1.UpstreamInjectSpec{ ProviderName: "github", }, }, @@ -130,20 +130,20 @@ func TestUpstreamInjectConverter_Integration(t *testing.T) { // Test that upstream inject converter is registered in default registry registry := DefaultRegistry() - converter, err := registry.GetConverter(mcpv1alpha1.ExternalAuthTypeUpstreamInject) + converter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeUpstreamInject) require.NoError(t, err) require.NotNil(t, converter) assert.IsType(t, &UpstreamInjectConverter{}, converter) // Test end-to-end conversion using ConvertToStrategy convenience function - externalAuth := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuth := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-upstream-inject", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUpstreamInject, - UpstreamInject: &mcpv1alpha1.UpstreamInjectSpec{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUpstreamInject, + UpstreamInject: &mcpv1beta1.UpstreamInjectSpec{ ProviderName: "github", }, }, diff --git a/pkg/vmcp/auth/factory/integration_test.go b/pkg/vmcp/auth/factory/integration_test.go index 9eef6d272f..0265c0ce7c 100644 --- a/pkg/vmcp/auth/factory/integration_test.go +++ b/pkg/vmcp/auth/factory/integration_test.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/stacklok/toolhive-core/env" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/vmcp/auth/converters" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" ) @@ -44,7 +44,7 @@ func TestHeaderInjectionIntegration(t *testing.T) { // Step 2: Create a fake Kubernetes client with a secret scheme := runtime.NewScheme() _ = corev1.AddToScheme(scheme) - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -62,16 +62,16 @@ func TestHeaderInjectionIntegration(t *testing.T) { Build() // Step 3: Create MCPExternalAuthConfig - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-api-key", Key: "key", }, @@ -127,7 +127,7 @@ func TestHeaderInjectionIntegration(t *testing.T) { // Create fake client with secret scheme := runtime.NewScheme() _ = corev1.AddToScheme(scheme) - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -145,16 +145,16 @@ func TestHeaderInjectionIntegration(t *testing.T) { Build() // Create auth config with custom header - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "custom-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-Custom-Auth-Token", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "custom-header-secret", Key: "token", }, @@ -191,23 +191,23 @@ func TestHeaderInjectionIntegration(t *testing.T) { // Create empty fake client (no secrets) scheme := runtime.NewScheme() _ = corev1.AddToScheme(scheme) - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). Build() // Create auth config referencing non-existent secret - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "missing-secret-auth", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "non-existent-secret", Key: "key", }, diff --git a/pkg/vmcp/k8s/backend_reconciler.go b/pkg/vmcp/k8s/backend_reconciler.go index 3f579a8786..47296f5c32 100644 --- a/pkg/vmcp/k8s/backend_reconciler.go +++ b/pkg/vmcp/k8s/backend_reconciler.go @@ -16,7 +16,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/vmcp" "github.com/stacklok/toolhive/pkg/vmcp/workloads" ) @@ -75,9 +75,9 @@ type BackendReconciler struct { // SetupIndexes registers field indexes required by the reconciler's watch handlers. // Must be called before SetupWithManager. func (*BackendReconciler) SetupIndexes(ctx context.Context, mgr ctrl.Manager) error { - return mgr.GetFieldIndexer().IndexField(ctx, &mcpv1alpha1.MCPServerEntry{}, caBundleConfigMapIndex, + return mgr.GetFieldIndexer().IndexField(ctx, &mcpv1beta1.MCPServerEntry{}, caBundleConfigMapIndex, func(obj client.Object) []string { - entry, ok := obj.(*mcpv1alpha1.MCPServerEntry) + entry, ok := obj.(*mcpv1beta1.MCPServerEntry) if !ok { return nil } @@ -150,7 +150,7 @@ func (r *BackendReconciler) fetchBackendResource( ctxLogger := log.FromContext(ctx) // Try to fetch as MCPServer first - mcpServer := &mcpv1alpha1.MCPServer{} + mcpServer := &mcpv1beta1.MCPServer{} errServer := r.Get(ctx, namespacedName, mcpServer) if errServer == nil { @@ -162,7 +162,7 @@ func (r *BackendReconciler) fetchBackendResource( } // Try to fetch as MCPRemoteProxy - mcpRemoteProxy := &mcpv1alpha1.MCPRemoteProxy{} + mcpRemoteProxy := &mcpv1beta1.MCPRemoteProxy{} errProxy := r.Get(ctx, namespacedName, mcpRemoteProxy) if errProxy == nil { @@ -174,7 +174,7 @@ func (r *BackendReconciler) fetchBackendResource( } // Try to fetch as MCPServerEntry - mcpServerEntry := &mcpv1alpha1.MCPServerEntry{} + mcpServerEntry := &mcpv1beta1.MCPServerEntry{} errEntry := r.Get(ctx, namespacedName, mcpServerEntry) if errEntry == nil { @@ -212,7 +212,7 @@ func (r *BackendReconciler) fetchBackendResource( // MapAuthConfigToEntries returns reconcile requests for MCPServerEntries that reference // the given ExternalAuthConfig name. Used by the ExternalAuthConfig watch handler. func (r *BackendReconciler) MapAuthConfigToEntries(ctx context.Context, authConfigName string) []reconcile.Request { - entryList := &mcpv1alpha1.MCPServerEntryList{} + entryList := &mcpv1beta1.MCPServerEntryList{} if err := r.List(ctx, entryList, client.InNamespace(r.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPServerEntries for ExternalAuthConfig watch") return nil @@ -330,7 +330,7 @@ func (r *BackendReconciler) SetupWithManager(mgr ctrl.Manager) error { // Maps ExternalAuthConfig → MCPServers/MCPRemoteProxies that reference it externalAuthConfigHandler := handler.EnqueueRequestsFromMapFunc( func(ctx context.Context, obj client.Object) []reconcile.Request { - authConfig, ok := obj.(*mcpv1alpha1.MCPExternalAuthConfig) + authConfig, ok := obj.(*mcpv1beta1.MCPExternalAuthConfig) if !ok { return nil } @@ -338,7 +338,7 @@ func (r *BackendReconciler) SetupWithManager(mgr ctrl.Manager) error { var requests []reconcile.Request // Find MCPServers referencing this ExternalAuthConfig - mcpServerList := &mcpv1alpha1.MCPServerList{} + mcpServerList := &mcpv1beta1.MCPServerList{} if err := r.List(ctx, mcpServerList, client.InNamespace(r.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPServers for ExternalAuthConfig watch") return nil @@ -362,7 +362,7 @@ func (r *BackendReconciler) SetupWithManager(mgr ctrl.Manager) error { } // Find MCPRemoteProxies referencing this ExternalAuthConfig - proxyList := &mcpv1alpha1.MCPRemoteProxyList{} + proxyList := &mcpv1beta1.MCPRemoteProxyList{} if err := r.List(ctx, proxyList, client.InNamespace(r.Namespace)); err != nil { log.FromContext(ctx).Error(err, "Failed to list MCPRemoteProxies for ExternalAuthConfig watch") return nil @@ -396,7 +396,7 @@ func (r *BackendReconciler) SetupWithManager(mgr ctrl.Manager) error { // Maps MCPServer events → reconcile requests with groupRef filter serverHandler := handler.EnqueueRequestsFromMapFunc( func(_ context.Context, obj client.Object) []reconcile.Request { - server, ok := obj.(*mcpv1alpha1.MCPServer) + server, ok := obj.(*mcpv1beta1.MCPServer) if !ok { return nil } @@ -421,7 +421,7 @@ func (r *BackendReconciler) SetupWithManager(mgr ctrl.Manager) error { // Maps MCPRemoteProxy events → reconcile requests with groupRef filter proxyHandler := handler.EnqueueRequestsFromMapFunc( func(_ context.Context, obj client.Object) []reconcile.Request { - proxy, ok := obj.(*mcpv1alpha1.MCPRemoteProxy) + proxy, ok := obj.(*mcpv1beta1.MCPRemoteProxy) if !ok { return nil } @@ -446,7 +446,7 @@ func (r *BackendReconciler) SetupWithManager(mgr ctrl.Manager) error { // Maps MCPServerEntry events → reconcile requests with groupRef filter entryHandler := handler.EnqueueRequestsFromMapFunc( func(_ context.Context, obj client.Object) []reconcile.Request { - entry, ok := obj.(*mcpv1alpha1.MCPServerEntry) + entry, ok := obj.(*mcpv1beta1.MCPServerEntry) if !ok { return nil } @@ -477,7 +477,7 @@ func (r *BackendReconciler) SetupWithManager(mgr ctrl.Manager) error { } // Use field index to find MCPServerEntries referencing this ConfigMap - entryList := &mcpv1alpha1.MCPServerEntryList{} + entryList := &mcpv1beta1.MCPServerEntryList{} if err := r.List(ctx, entryList, client.InNamespace(r.Namespace), client.MatchingFields{caBundleConfigMapIndex: configMap.Name}, @@ -506,10 +506,10 @@ func (r *BackendReconciler) SetupWithManager(mgr ctrl.Manager) error { controllerName := "backend-reconciler-" + r.GroupRef return ctrl.NewControllerManagedBy(mgr). Named(controllerName). - Watches(&mcpv1alpha1.MCPServer{}, serverHandler). // Watch MCPServer as secondary controller - Watches(&mcpv1alpha1.MCPRemoteProxy{}, proxyHandler). // Watch MCPRemoteProxy - Watches(&mcpv1alpha1.MCPServerEntry{}, entryHandler). // Watch MCPServerEntry - Watches(&mcpv1alpha1.MCPExternalAuthConfig{}, externalAuthConfigHandler). // Watch auth configs - Watches(&corev1.ConfigMap{}, caBundleConfigMapHandler). // Watch CA bundle ConfigMaps + Watches(&mcpv1beta1.MCPServer{}, serverHandler). // Watch MCPServer as secondary controller + Watches(&mcpv1beta1.MCPRemoteProxy{}, proxyHandler). // Watch MCPRemoteProxy + Watches(&mcpv1beta1.MCPServerEntry{}, entryHandler). // Watch MCPServerEntry + Watches(&mcpv1beta1.MCPExternalAuthConfig{}, externalAuthConfigHandler). // Watch auth configs + Watches(&corev1.ConfigMap{}, caBundleConfigMapHandler). // Watch CA bundle ConfigMaps Complete(r) } diff --git a/pkg/vmcp/k8s/backend_reconciler_integration_test.go b/pkg/vmcp/k8s/backend_reconciler_integration_test.go index e6c37e50a6..c870ff35a6 100644 --- a/pkg/vmcp/k8s/backend_reconciler_integration_test.go +++ b/pkg/vmcp/k8s/backend_reconciler_integration_test.go @@ -24,7 +24,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/vmcp" "github.com/stacklok/toolhive/pkg/vmcp/k8s" "github.com/stacklok/toolhive/pkg/vmcp/workloads" @@ -69,7 +69,7 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) - err = mcpv1alpha1.AddToScheme(scheme.Scheme) + err = mcpv1beta1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) @@ -166,13 +166,13 @@ var _ = Describe("BackendReconciler Integration Tests", func() { Context("MCPServer Lifecycle", func() { It("should add MCPServer to registry when created with matching groupRef", func() { // Create MCPServer - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server-add", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupRef}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupRef}, Image: "test-image:latest", Transport: "streamable-http", }, @@ -194,13 +194,13 @@ var _ = Describe("BackendReconciler Integration Tests", func() { It("should remove MCPServer from registry when groupRef doesn't match", func() { // Create MCPServer with different groupRef - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server-mismatch", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "different-group"}, // Does NOT match testGroupRef + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "different-group"}, // Does NOT match testGroupRef Image: "test-image:latest", Transport: "streamable-http", }, @@ -219,13 +219,13 @@ var _ = Describe("BackendReconciler Integration Tests", func() { It("should handle MCPServer deletion (no-op in envtest)", func() { // Create MCPServer - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server-delete", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupRef}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupRef}, Image: "test-image:latest", Transport: "streamable-http", }, @@ -254,13 +254,13 @@ var _ = Describe("BackendReconciler Integration Tests", func() { Context("MCPRemoteProxy Lifecycle", func() { It("should add MCPRemoteProxy to registry when created with matching groupRef", func() { // Create MCPRemoteProxy - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy-add", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupRef}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupRef}, RemoteURL: "https://example.com/mcp", }, } @@ -279,13 +279,13 @@ var _ = Describe("BackendReconciler Integration Tests", func() { It("should NOT add MCPRemoteProxy with mismatched groupRef", func() { // Create MCPRemoteProxy with different groupRef - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy-mismatch", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}, RemoteURL: "https://example.com/mcp", }, } @@ -307,13 +307,13 @@ var _ = Describe("BackendReconciler Integration Tests", func() { initialVersion := registry.Version() // Create MCPServer - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server-version", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupRef}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupRef}, Image: "test-image:latest", Transport: "streamable-http", }, @@ -338,15 +338,15 @@ var _ = Describe("BackendReconciler Integration Tests", func() { Context("MCPServerEntry Lifecycle", func() { It("should add MCPServerEntry to registry when created with matching groupRef", func() { - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-entry-add", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/mcp", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupRef}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupRef}, }, } @@ -356,7 +356,7 @@ var _ = Describe("BackendReconciler Integration Tests", func() { }() // Set status to Valid so the discoverer accepts it - entry.Status.Phase = mcpv1alpha1.MCPServerEntryPhaseValid + entry.Status.Phase = mcpv1beta1.MCPServerEntryPhaseValid Expect(k8sClient.Status().Update(ctx, entry)).Should(Succeed()) // MCPServerEntry uses Spec.RemoteURL directly (no K8s Service needed), @@ -373,15 +373,15 @@ var _ = Describe("BackendReconciler Integration Tests", func() { }) It("should NOT add MCPServerEntry with mismatched groupRef", func() { - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-entry-mismatch", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/mcp", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}, }, } @@ -390,7 +390,7 @@ var _ = Describe("BackendReconciler Integration Tests", func() { _ = k8sClient.Delete(ctx, entry) }() - entry.Status.Phase = mcpv1alpha1.MCPServerEntryPhaseValid + entry.Status.Phase = mcpv1beta1.MCPServerEntryPhaseValid Expect(k8sClient.Status().Update(ctx, entry)).Should(Succeed()) Consistently(func() int { @@ -399,21 +399,21 @@ var _ = Describe("BackendReconciler Integration Tests", func() { }) It("should remove MCPServerEntry from registry when deleted", func() { - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-entry-delete", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/mcp", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupRef}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupRef}, }, } Expect(k8sClient.Create(ctx, entry)).Should(Succeed()) - entry.Status.Phase = mcpv1alpha1.MCPServerEntryPhaseValid + entry.Status.Phase = mcpv1beta1.MCPServerEntryPhaseValid Expect(k8sClient.Status().Update(ctx, entry)).Should(Succeed()) // Wait for backend to appear @@ -433,21 +433,21 @@ var _ = Describe("BackendReconciler Integration Tests", func() { It("should increment registry version on MCPServerEntry events", func() { initialVersion := registry.Version() - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-entry-version", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/mcp", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: testGroupRef}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupRef}, }, } Expect(k8sClient.Create(ctx, entry)).Should(Succeed()) - entry.Status.Phase = mcpv1alpha1.MCPServerEntryPhaseValid + entry.Status.Phase = mcpv1beta1.MCPServerEntryPhaseValid Expect(k8sClient.Status().Update(ctx, entry)).Should(Succeed()) Eventually(func() uint64 { diff --git a/pkg/vmcp/k8s/backend_reconciler_test.go b/pkg/vmcp/k8s/backend_reconciler_test.go index 5f1c9952e8..949c2a01cf 100644 --- a/pkg/vmcp/k8s/backend_reconciler_test.go +++ b/pkg/vmcp/k8s/backend_reconciler_test.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/vmcp" "github.com/stacklok/toolhive/pkg/vmcp/k8s" "github.com/stacklok/toolhive/pkg/vmcp/workloads" @@ -112,16 +112,16 @@ func TestReconcile_MCPServer_Success(t *testing.T) { // Create test scheme with MCPServer CRD scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // Create MCPServer with matching groupRef - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } @@ -169,16 +169,16 @@ func TestReconcile_GroupRefMismatch(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // Create MCPServer with DIFFERENT groupRef - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "different-group"}, // Does NOT match reconciler's groupRef + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "different-group"}, // Does NOT match reconciler's groupRef }, } @@ -214,7 +214,7 @@ func TestReconcile_Deleted(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // Create fake K8s client WITHOUT the MCPServer (simulates deletion) k8sClient := fake.NewClientBuilder(). @@ -249,15 +249,15 @@ func TestReconcile_AuthFailure(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } @@ -294,16 +294,16 @@ func TestReconcile_MCPRemoteProxy_Success(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // Create MCPRemoteProxy with matching groupRef - mcpRemoteProxy := &mcpv1alpha1.MCPRemoteProxy{ + mcpRemoteProxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: "default", }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } @@ -344,15 +344,15 @@ func TestReconcile_ConversionError(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } @@ -390,7 +390,7 @@ func TestSetupWithManager_RegistersWatches(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // This test validates the structure without actually registering controllers // Full integration testing of watches requires envtest and is covered by integration tests @@ -416,17 +416,17 @@ func TestReconcile_MCPServerEntry_Success(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - mcpServerEntry := &mcpv1alpha1.MCPServerEntry{ + mcpServerEntry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "remote-mcp", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/mcp", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } @@ -468,17 +468,17 @@ func TestReconcile_MCPServerEntry_GroupRefMismatch(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - mcpServerEntry := &mcpv1alpha1.MCPServerEntry{ + mcpServerEntry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "remote-mcp", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/mcp", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}, }, } @@ -512,7 +512,7 @@ func TestReconcile_MCPServerEntry_Deleted(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) // No MCPServerEntry created — simulates deletion k8sClient := fake.NewClientBuilder(). @@ -545,26 +545,26 @@ func TestMapAuthConfigToEntries(t *testing.T) { t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) tests := []struct { name string authConfigName string - entries []mcpv1alpha1.MCPServerEntry + entries []mcpv1beta1.MCPServerEntry groupRef string wantNames []string }{ { name: "matches entry referencing auth config", authConfigName: "my-auth", - entries: []mcpv1alpha1.MCPServerEntry{ + entries: []mcpv1beta1.MCPServerEntry{ { ObjectMeta: metav1.ObjectMeta{Name: "entry-1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerEntrySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.MCPServerEntrySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, RemoteURL: "https://example.com", Transport: "streamable-http", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "my-auth"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "my-auth"}, }, }, }, @@ -574,14 +574,14 @@ func TestMapAuthConfigToEntries(t *testing.T) { { name: "skips entry with different group", authConfigName: "my-auth", - entries: []mcpv1alpha1.MCPServerEntry{ + entries: []mcpv1beta1.MCPServerEntry{ { ObjectMeta: metav1.ObjectMeta{Name: "entry-1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerEntrySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "other-group"}, + Spec: mcpv1beta1.MCPServerEntrySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}, RemoteURL: "https://example.com", Transport: "streamable-http", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "my-auth"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "my-auth"}, }, }, }, @@ -591,14 +591,14 @@ func TestMapAuthConfigToEntries(t *testing.T) { { name: "skips entry referencing different auth config", authConfigName: "my-auth", - entries: []mcpv1alpha1.MCPServerEntry{ + entries: []mcpv1beta1.MCPServerEntry{ { ObjectMeta: metav1.ObjectMeta{Name: "entry-1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerEntrySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.MCPServerEntrySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, RemoteURL: "https://example.com", Transport: "streamable-http", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "other-auth"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "other-auth"}, }, }, }, @@ -608,11 +608,11 @@ func TestMapAuthConfigToEntries(t *testing.T) { { name: "skips entry with no auth config ref", authConfigName: "my-auth", - entries: []mcpv1alpha1.MCPServerEntry{ + entries: []mcpv1beta1.MCPServerEntry{ { ObjectMeta: metav1.ObjectMeta{Name: "entry-1", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerEntrySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + Spec: mcpv1beta1.MCPServerEntrySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, RemoteURL: "https://example.com", Transport: "streamable-http", }, diff --git a/pkg/vmcp/k8s/manager.go b/pkg/vmcp/k8s/manager.go index 4d17d76148..881b34d72d 100644 --- a/pkg/vmcp/k8s/manager.go +++ b/pkg/vmcp/k8s/manager.go @@ -23,7 +23,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/manager" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/vmcp" "github.com/stacklok/toolhive/pkg/vmcp/workloads" ) @@ -124,7 +124,7 @@ func NewBackendWatcher( // Create runtime scheme and register ToolHive CRDs + core Kubernetes types scheme := runtime.NewScheme() - if err := mcpv1alpha1.AddToScheme(scheme); err != nil { + if err := mcpv1beta1.AddToScheme(scheme); err != nil { return nil, fmt.Errorf("failed to register ToolHive CRDs to scheme: %w", err) } diff --git a/pkg/vmcp/status/k8s_reporter.go b/pkg/vmcp/status/k8s_reporter.go index f6b5a3e202..7bd7cac16a 100644 --- a/pkg/vmcp/status/k8s_reporter.go +++ b/pkg/vmcp/status/k8s_reporter.go @@ -17,7 +17,7 @@ import ( "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcptypes "github.com/stacklok/toolhive/pkg/vmcp" ) @@ -57,7 +57,7 @@ func NewK8sReporter(restConfig *rest.Config, name, namespace string) (*K8sReport } // Register VirtualMCPServer CRD types - if err := mcpv1alpha1.AddToScheme(runtimeScheme); err != nil { + if err := mcpv1beta1.AddToScheme(runtimeScheme); err != nil { return nil, fmt.Errorf("failed to add VirtualMCPServer types to scheme: %w", err) } @@ -93,7 +93,7 @@ func (r *K8sReporter) ReportStatus(ctx context.Context, status *vmcptypes.Status // the update with a conflict error, and retry.RetryOnConflict will automatically retry. err := retry.RetryOnConflict(retry.DefaultRetry, func() error { // Get the latest version of the VirtualMCPServer resource - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := r.client.Get(ctx, namespacedName, vmcpServer); err != nil { return fmt.Errorf("failed to get VirtualMCPServer: %w", err) } @@ -128,7 +128,7 @@ func (*K8sReporter) Start(_ context.Context) (func(context.Context) error, error // Note: This method does NOT update the URL field, as that is infrastructure-level // status owned by the operator (the external service URL). The vMCP runtime only // reports operational status (phase, backends, conditions). -func (*K8sReporter) updateStatus(vmcpServer *mcpv1alpha1.VirtualMCPServer, status *vmcptypes.Status) { +func (*K8sReporter) updateStatus(vmcpServer *mcpv1beta1.VirtualMCPServer, status *vmcptypes.Status) { // Update phase vmcpServer.Status.Phase = convertPhase(status.Phase) @@ -139,12 +139,12 @@ func (*K8sReporter) updateStatus(vmcpServer *mcpv1alpha1.VirtualMCPServer, statu vmcpServer.Status.BackendCount = status.BackendCount // Update discovered backends - vmcpServer.Status.DiscoveredBackends = make([]mcpv1alpha1.DiscoveredBackend, 0, len(status.DiscoveredBackends)) + vmcpServer.Status.DiscoveredBackends = make([]mcpv1beta1.DiscoveredBackend, 0, len(status.DiscoveredBackends)) for _, backend := range status.DiscoveredBackends { - // Convert vmcp.DiscoveredBackend to mcpv1alpha1.DiscoveredBackend + // Convert vmcp.DiscoveredBackend to mcpv1beta1.DiscoveredBackend // Both types have identical fields, so we can use type conversion vmcpServer.Status.DiscoveredBackends = append(vmcpServer.Status.DiscoveredBackends, - mcpv1alpha1.DiscoveredBackend(backend)) + mcpv1beta1.DiscoveredBackend(backend)) } // Update conditions using meta.SetStatusCondition to preserve LastTransitionTime @@ -190,18 +190,18 @@ func (*K8sReporter) updateStatus(vmcpServer *mcpv1alpha1.VirtualMCPServer, statu } // convertPhase converts vmcp.Phase to VirtualMCPServerPhase. -func convertPhase(phase vmcptypes.Phase) mcpv1alpha1.VirtualMCPServerPhase { +func convertPhase(phase vmcptypes.Phase) mcpv1beta1.VirtualMCPServerPhase { switch phase { case vmcptypes.PhaseReady: - return mcpv1alpha1.VirtualMCPServerPhaseReady + return mcpv1beta1.VirtualMCPServerPhaseReady case vmcptypes.PhaseDegraded: - return mcpv1alpha1.VirtualMCPServerPhaseDegraded + return mcpv1beta1.VirtualMCPServerPhaseDegraded case vmcptypes.PhaseFailed: - return mcpv1alpha1.VirtualMCPServerPhaseFailed + return mcpv1beta1.VirtualMCPServerPhaseFailed case vmcptypes.PhasePending: - return mcpv1alpha1.VirtualMCPServerPhasePending + return mcpv1beta1.VirtualMCPServerPhasePending default: - return mcpv1alpha1.VirtualMCPServerPhasePending + return mcpv1beta1.VirtualMCPServerPhasePending } } diff --git a/pkg/vmcp/status/k8s_reporter_test.go b/pkg/vmcp/status/k8s_reporter_test.go index 42c6b0c639..5a0eafe73b 100644 --- a/pkg/vmcp/status/k8s_reporter_test.go +++ b/pkg/vmcp/status/k8s_reporter_test.go @@ -18,7 +18,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcptypes "github.com/stacklok/toolhive/pkg/vmcp" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -88,35 +88,35 @@ func TestK8sReporter_ReportStatus_Success(t *testing.T) { tests := []struct { name string phase vmcptypes.Phase - expectedPhase mcpv1alpha1.VirtualMCPServerPhase + expectedPhase mcpv1beta1.VirtualMCPServerPhase backendCount int32 conditionCount int }{ { name: "ready phase with backends", phase: vmcptypes.PhaseReady, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhaseReady, + expectedPhase: mcpv1beta1.VirtualMCPServerPhaseReady, backendCount: 2, conditionCount: 2, }, { name: "degraded phase", phase: vmcptypes.PhaseDegraded, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhaseDegraded, + expectedPhase: mcpv1beta1.VirtualMCPServerPhaseDegraded, backendCount: 1, conditionCount: 1, }, { name: "failed phase", phase: vmcptypes.PhaseFailed, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhaseFailed, + expectedPhase: mcpv1beta1.VirtualMCPServerPhaseFailed, backendCount: 0, conditionCount: 1, }, { name: "pending phase", phase: vmcptypes.PhasePending, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhasePending, + expectedPhase: mcpv1beta1.VirtualMCPServerPhasePending, backendCount: 0, conditionCount: 0, }, @@ -164,7 +164,7 @@ func TestK8sReporter_ReportStatus_Success(t *testing.T) { require.NoError(t, err) // Verify the status was updated - updated := &mcpv1alpha1.VirtualMCPServer{} + updated := &mcpv1beta1.VirtualMCPServer{} err = fakeClient.Get(ctx, types.NamespacedName{ Name: "test-server", Namespace: "default", @@ -226,7 +226,7 @@ func TestK8sReporter_ReportStatus_BackendConversion(t *testing.T) { require.NoError(t, err) // Verify backends were converted correctly - updated := &mcpv1alpha1.VirtualMCPServer{} + updated := &mcpv1beta1.VirtualMCPServer{} err = fakeClient.Get(ctx, types.NamespacedName{ Name: "test-server", Namespace: "default", @@ -304,7 +304,7 @@ func TestK8sReporter_ReportStatus_ConcurrentUpdates(t *testing.T) { } // Verify the final state has the last update - updated := &mcpv1alpha1.VirtualMCPServer{} + updated := &mcpv1beta1.VirtualMCPServer{} err := fakeClient.Get(ctx, types.NamespacedName{ Name: "test-server", Namespace: "default", @@ -348,7 +348,7 @@ func TestK8sReporter_ReportStatus_ConditionUpdates(t *testing.T) { require.NoError(t, err) // Verify condition was created - updated := &mcpv1alpha1.VirtualMCPServer{} + updated := &mcpv1beta1.VirtualMCPServer{} err = fakeClient.Get(ctx, types.NamespacedName{ Name: "test-server", Namespace: "default", @@ -459,7 +459,7 @@ func TestK8sReporter_ReportStatus_RemovesStaleConditions(t *testing.T) { require.NoError(t, err) // Verify both conditions exist - updated := &mcpv1alpha1.VirtualMCPServer{} + updated := &mcpv1beta1.VirtualMCPServer{} err = fakeClient.Get(ctx, types.NamespacedName{ Name: "test-server", Namespace: "default", @@ -574,32 +574,32 @@ func TestConvertPhase(t *testing.T) { tests := []struct { name string input vmcptypes.Phase - expectedPhase mcpv1alpha1.VirtualMCPServerPhase + expectedPhase mcpv1beta1.VirtualMCPServerPhase }{ { name: "ready phase", input: vmcptypes.PhaseReady, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhaseReady, + expectedPhase: mcpv1beta1.VirtualMCPServerPhaseReady, }, { name: "degraded phase", input: vmcptypes.PhaseDegraded, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhaseDegraded, + expectedPhase: mcpv1beta1.VirtualMCPServerPhaseDegraded, }, { name: "failed phase", input: vmcptypes.PhaseFailed, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhaseFailed, + expectedPhase: mcpv1beta1.VirtualMCPServerPhaseFailed, }, { name: "pending phase", input: vmcptypes.PhasePending, - expectedPhase: mcpv1alpha1.VirtualMCPServerPhasePending, + expectedPhase: mcpv1beta1.VirtualMCPServerPhasePending, }, { name: "unknown phase defaults to pending", input: vmcptypes.Phase("unknown"), - expectedPhase: mcpv1alpha1.VirtualMCPServerPhasePending, + expectedPhase: mcpv1beta1.VirtualMCPServerPhasePending, }, } @@ -628,13 +628,13 @@ func createTestReporter(t *testing.T, name, namespace string) (*K8sReporter, cli // Create scheme with VirtualMCPServer types scheme := runtime.NewScheme() - err := mcpv1alpha1.AddToScheme(scheme) + err := mcpv1beta1.AddToScheme(scheme) require.NoError(t, err) // Create fake client with status subresource support fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithStatusSubresource(&mcpv1alpha1.VirtualMCPServer{}). + WithStatusSubresource(&mcpv1beta1.VirtualMCPServer{}). Build() // Create reporter with fake client @@ -650,20 +650,20 @@ func createTestReporter(t *testing.T, name, namespace string) (*K8sReporter, cli // createTestVirtualMCPServer creates a test VirtualMCPServer resource. // //nolint:unparam // name parameter provides flexibility for future tests -func createTestVirtualMCPServer(t *testing.T, fakeClient client.Client, name, namespace string) *mcpv1alpha1.VirtualMCPServer { +func createTestVirtualMCPServer(t *testing.T, fakeClient client.Client, name, namespace string) *mcpv1beta1.VirtualMCPServer { t.Helper() - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, Generation: 1, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{ Group: "test-group", }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, diff --git a/pkg/vmcp/workloads/k8s.go b/pkg/vmcp/workloads/k8s.go index 9fc02c6473..7a8ffa557e 100644 --- a/pkg/vmcp/workloads/k8s.go +++ b/pkg/vmcp/workloads/k8s.go @@ -17,7 +17,7 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/k8s" transporttypes "github.com/stacklok/toolhive/pkg/transport/types" "github.com/stacklok/toolhive/pkg/vmcp" @@ -51,8 +51,8 @@ func NewK8SDiscoverer(namespace ...string) (Discoverer, error) { if err := clientgoscheme.AddToScheme(scheme); err != nil { return nil, fmt.Errorf("failed to add client-go scheme: %w", err) } - if err := mcpv1alpha1.AddToScheme(scheme); err != nil { - return nil, fmt.Errorf("failed to add MCP v1alpha1 scheme: %w", err) + if err := mcpv1beta1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("failed to add MCP v1beta1 scheme: %w", err) } // Create controller-runtime client @@ -87,7 +87,7 @@ func (d *k8sDiscoverer) ListWorkloadsInGroup(ctx context.Context, groupName stri var groupWorkloads []TypedWorkload // List MCPServers in the group - mcpServerList := &mcpv1alpha1.MCPServerList{} + mcpServerList := &mcpv1beta1.MCPServerList{} listOpts := []client.ListOption{ client.InNamespace(d.namespace), } @@ -107,7 +107,7 @@ func (d *k8sDiscoverer) ListWorkloadsInGroup(ctx context.Context, groupName stri } // List MCPRemoteProxies in the group - mcpRemoteProxyList := &mcpv1alpha1.MCPRemoteProxyList{} + mcpRemoteProxyList := &mcpv1beta1.MCPRemoteProxyList{} if err := d.k8sClient.List(ctx, mcpRemoteProxyList, listOpts...); err != nil { return nil, fmt.Errorf("failed to list MCPRemoteProxies: %w", err) } @@ -123,7 +123,7 @@ func (d *k8sDiscoverer) ListWorkloadsInGroup(ctx context.Context, groupName stri } // List MCPServerEntries in the group - mcpServerEntryList := &mcpv1alpha1.MCPServerEntryList{} + mcpServerEntryList := &mcpv1beta1.MCPServerEntryList{} if err := d.k8sClient.List(ctx, mcpServerEntryList, listOpts...); err != nil { return nil, fmt.Errorf("failed to list MCPServerEntries: %w", err) } @@ -159,7 +159,7 @@ func (d *k8sDiscoverer) GetWorkloadAsVMCPBackend(ctx context.Context, workload T // getMCPServerAsBackend retrieves an MCPServer and converts it to a vmcp.Backend. func (d *k8sDiscoverer) getMCPServerAsBackend(ctx context.Context, workloadName string) (*vmcp.Backend, error) { - mcpServer := &mcpv1alpha1.MCPServer{} + mcpServer := &mcpv1beta1.MCPServer{} key := client.ObjectKey{Name: workloadName, Namespace: d.namespace} if err := d.k8sClient.Get(ctx, key, mcpServer); err != nil { if errors.IsNotFound(err) { @@ -188,7 +188,7 @@ func (d *k8sDiscoverer) getMCPServerAsBackend(ctx context.Context, workloadName // getMCPRemoteProxyAsBackend retrieves an MCPRemoteProxy and converts it to a vmcp.Backend. func (d *k8sDiscoverer) getMCPRemoteProxyAsBackend(ctx context.Context, proxyName string) (*vmcp.Backend, error) { - mcpRemoteProxy := &mcpv1alpha1.MCPRemoteProxy{} + mcpRemoteProxy := &mcpv1beta1.MCPRemoteProxy{} key := client.ObjectKey{Name: proxyName, Namespace: d.namespace} if err := d.k8sClient.Get(ctx, key, mcpRemoteProxy); err != nil { if errors.IsNotFound(err) { @@ -218,7 +218,7 @@ func (d *k8sDiscoverer) getMCPRemoteProxyAsBackend(ctx context.Context, proxyNam // mcpServerToBackend converts an MCPServer CRD to a vmcp.Backend. // If the MCPServer has an ExternalAuthConfigRef, it will be fetched and converted to auth strategy metadata. // Auth discovery errors are logged but do not fail backend creation. -func (d *k8sDiscoverer) mcpServerToBackend(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) *vmcp.Backend { +func (d *k8sDiscoverer) mcpServerToBackend(ctx context.Context, mcpServer *mcpv1beta1.MCPServer) *vmcp.Backend { // Parse transport type transportType, err := transporttypes.ParseTransportType(mcpServer.Spec.Transport) if err != nil { @@ -304,7 +304,7 @@ func (d *k8sDiscoverer) mcpServerToBackend(ctx context.Context, mcpServer *mcpv1 // - Returns nil error if ExternalAuthConfigRef is nil (no auth config) - this is expected behavior // - Returns nil error if auth config is discovered and successfully populated into backend // - Returns error if auth config exists but discovery/resolution fails (e.g., missing secret, invalid config) -func (d *k8sDiscoverer) discoverAuthConfig(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer, backend *vmcp.Backend) error { +func (d *k8sDiscoverer) discoverAuthConfig(ctx context.Context, mcpServer *mcpv1beta1.MCPServer, backend *vmcp.Backend) error { return d.discoverAuthConfigFromRef( ctx, mcpServer.Spec.ExternalAuthConfigRef, @@ -324,7 +324,7 @@ func (d *k8sDiscoverer) discoverAuthConfig(ctx context.Context, mcpServer *mcpv1 // - Returns error if auth config exists but discovery/resolution fails (e.g., missing secret, invalid config) func (d *k8sDiscoverer) discoverAuthConfigFromRef( ctx context.Context, - authConfigRef *mcpv1alpha1.ExternalAuthConfigRef, + authConfigRef *mcpv1beta1.ExternalAuthConfigRef, namespace string, resourceName string, resourceKind string, @@ -362,17 +362,17 @@ func (d *k8sDiscoverer) discoverAuthConfigFromRef( } // mapK8SWorkloadPhaseToHealth converts a MCPServerPhase to a backend health status. -func mapK8SWorkloadPhaseToHealth(phase mcpv1alpha1.MCPServerPhase) vmcp.BackendHealthStatus { +func mapK8SWorkloadPhaseToHealth(phase mcpv1beta1.MCPServerPhase) vmcp.BackendHealthStatus { switch phase { - case mcpv1alpha1.MCPServerPhaseReady: + case mcpv1beta1.MCPServerPhaseReady: return vmcp.BackendHealthy - case mcpv1alpha1.MCPServerPhaseFailed: + case mcpv1beta1.MCPServerPhaseFailed: return vmcp.BackendUnhealthy - case mcpv1alpha1.MCPServerPhaseTerminating: + case mcpv1beta1.MCPServerPhaseTerminating: return vmcp.BackendUnhealthy - case mcpv1alpha1.MCPServerPhaseStopped: + case mcpv1beta1.MCPServerPhaseStopped: return vmcp.BackendUnhealthy - case mcpv1alpha1.MCPServerPhasePending: + case mcpv1beta1.MCPServerPhasePending: return vmcp.BackendUnknown default: return vmcp.BackendUnknown @@ -380,15 +380,15 @@ func mapK8SWorkloadPhaseToHealth(phase mcpv1alpha1.MCPServerPhase) vmcp.BackendH } // mapMCPRemoteProxyPhaseToHealth converts a MCPRemoteProxyPhase to a backend health status. -func mapMCPRemoteProxyPhaseToHealth(phase mcpv1alpha1.MCPRemoteProxyPhase) vmcp.BackendHealthStatus { +func mapMCPRemoteProxyPhaseToHealth(phase mcpv1beta1.MCPRemoteProxyPhase) vmcp.BackendHealthStatus { switch phase { - case mcpv1alpha1.MCPRemoteProxyPhaseReady: + case mcpv1beta1.MCPRemoteProxyPhaseReady: return vmcp.BackendHealthy - case mcpv1alpha1.MCPRemoteProxyPhaseFailed: + case mcpv1beta1.MCPRemoteProxyPhaseFailed: return vmcp.BackendUnhealthy - case mcpv1alpha1.MCPRemoteProxyPhaseTerminating: + case mcpv1beta1.MCPRemoteProxyPhaseTerminating: return vmcp.BackendUnhealthy - case mcpv1alpha1.MCPRemoteProxyPhasePending: + case mcpv1beta1.MCPRemoteProxyPhasePending: return vmcp.BackendUnknown default: return vmcp.BackendUnknown @@ -397,7 +397,7 @@ func mapMCPRemoteProxyPhaseToHealth(phase mcpv1alpha1.MCPRemoteProxyPhase) vmcp. // mcpRemoteProxyToBackend converts an MCPRemoteProxy CRD to a vmcp.Backend. // If the MCPRemoteProxy has an ExternalAuthConfigRef, it will be fetched and converted to auth strategy metadata. -func (d *k8sDiscoverer) mcpRemoteProxyToBackend(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) *vmcp.Backend { +func (d *k8sDiscoverer) mcpRemoteProxyToBackend(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) *vmcp.Backend { // Parse transport type from proxy spec transportType, err := transporttypes.ParseTransportType(proxy.Spec.Transport) if err != nil { @@ -465,7 +465,7 @@ func (d *k8sDiscoverer) mcpRemoteProxyToBackend(ctx context.Context, proxy *mcpv // getMCPServerEntryAsBackend retrieves an MCPServerEntry and converts it to a vmcp.Backend. // MCPServerEntry is a zero-infrastructure catalog entry that directly points to a remote URL. func (d *k8sDiscoverer) getMCPServerEntryAsBackend(ctx context.Context, entryName string) (*vmcp.Backend, error) { - mcpServerEntry := &mcpv1alpha1.MCPServerEntry{} + mcpServerEntry := &mcpv1beta1.MCPServerEntry{} key := client.ObjectKey{Name: entryName, Namespace: d.namespace} if err := d.k8sClient.Get(ctx, key, mcpServerEntry); err != nil { if errors.IsNotFound(err) { @@ -477,7 +477,7 @@ func (d *k8sDiscoverer) getMCPServerEntryAsBackend(ctx context.Context, entryNam // Unlike MCPServer/MCPRemoteProxy (which use status.URL, empty until ready), // MCPServerEntry always has spec.remoteUrl set. Explicitly check phase to // avoid routing to entries that failed validation. - if mcpServerEntry.Status.Phase != mcpv1alpha1.MCPServerEntryPhaseValid { + if mcpServerEntry.Status.Phase != mcpv1beta1.MCPServerEntryPhaseValid { slog.Debug("skipping server entry with non-valid phase", "entry", entryName, "phase", mcpServerEntry.Status.Phase) return nil, nil @@ -500,7 +500,7 @@ func (d *k8sDiscoverer) getMCPServerEntryAsBackend(ctx context.Context, entryNam // mcpServerEntryToBackend converts an MCPServerEntry CRD to a vmcp.Backend. // Unlike MCPServer and MCPRemoteProxy, MCPServerEntry uses the remote URL directly // from the spec (no K8s Service needed since it's a zero-infrastructure entry). -func (d *k8sDiscoverer) mcpServerEntryToBackend(ctx context.Context, entry *mcpv1alpha1.MCPServerEntry) *vmcp.Backend { +func (d *k8sDiscoverer) mcpServerEntryToBackend(ctx context.Context, entry *mcpv1beta1.MCPServerEntry) *vmcp.Backend { transportType, err := transporttypes.ParseTransportType(entry.Spec.Transport) if err != nil { slog.Warn("failed to parse transport type for MCPServerEntry", @@ -586,13 +586,13 @@ func (d *k8sDiscoverer) mcpServerEntryToBackend(ctx context.Context, entry *mcpv } // mapMCPServerEntryPhaseToHealth converts a MCPServerEntryPhase to a backend health status. -func mapMCPServerEntryPhaseToHealth(phase mcpv1alpha1.MCPServerEntryPhase) vmcp.BackendHealthStatus { +func mapMCPServerEntryPhaseToHealth(phase mcpv1beta1.MCPServerEntryPhase) vmcp.BackendHealthStatus { switch phase { - case mcpv1alpha1.MCPServerEntryPhaseValid: + case mcpv1beta1.MCPServerEntryPhaseValid: return vmcp.BackendHealthy - case mcpv1alpha1.MCPServerEntryPhaseFailed: + case mcpv1beta1.MCPServerEntryPhaseFailed: return vmcp.BackendUnhealthy - case mcpv1alpha1.MCPServerEntryPhasePending: + case mcpv1beta1.MCPServerEntryPhasePending: return vmcp.BackendUnknown default: return vmcp.BackendUnknown @@ -603,7 +603,7 @@ func mapMCPServerEntryPhaseToHealth(phase mcpv1alpha1.MCPServerEntryPhase) vmcp. // from the MCPServerEntry's ExternalAuthConfigRef. func (d *k8sDiscoverer) discoverServerEntryAuthConfig( ctx context.Context, - entry *mcpv1alpha1.MCPServerEntry, + entry *mcpv1beta1.MCPServerEntry, backend *vmcp.Backend, ) error { return d.discoverAuthConfigFromRef( @@ -618,7 +618,7 @@ func (d *k8sDiscoverer) discoverServerEntryAuthConfig( // fetchCABundleData reads CA certificate PEM data from a ConfigMap referenced by CABundleRef. // Returns the raw PEM bytes for use in dynamic mode where volumes aren't mounted. -func (d *k8sDiscoverer) fetchCABundleData(ctx context.Context, ref *mcpv1alpha1.CABundleSource) ([]byte, error) { +func (d *k8sDiscoverer) fetchCABundleData(ctx context.Context, ref *mcpv1beta1.CABundleSource) ([]byte, error) { if ref.ConfigMapRef == nil { return nil, fmt.Errorf("CABundleRef.configMapRef is nil") } @@ -647,7 +647,7 @@ func (d *k8sDiscoverer) fetchCABundleData(ctx context.Context, ref *mcpv1alpha1. // from the MCPRemoteProxy's ExternalAuthConfigRef. func (d *k8sDiscoverer) discoverRemoteProxyAuthConfig( ctx context.Context, - proxy *mcpv1alpha1.MCPRemoteProxy, + proxy *mcpv1beta1.MCPRemoteProxy, backend *vmcp.Backend, ) error { return d.discoverAuthConfigFromRef( diff --git a/pkg/vmcp/workloads/k8s_test.go b/pkg/vmcp/workloads/k8s_test.go index 02db8e163f..edae76e868 100644 --- a/pkg/vmcp/workloads/k8s_test.go +++ b/pkg/vmcp/workloads/k8s_test.go @@ -15,7 +15,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/vmcp" ) @@ -27,7 +27,7 @@ func setupTestClient(t *testing.T, objs ...client.Object) client.Client { scheme := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(scheme)) - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) return fake.NewClientBuilder(). WithScheme(scheme). @@ -52,17 +52,17 @@ func TestDiscoverAuth_TokenExchange(t *testing.T) { } // Create MCPExternalAuthConfig with token exchange - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-token-exchange", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-client-secret", Key: "client-secret", }, @@ -74,21 +74,21 @@ func TestDiscoverAuth_TokenExchange(t *testing.T) { } // Create MCPServer that references the auth config - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-token-exchange", }, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, URL: "http://localhost:8080", }, } @@ -136,16 +136,16 @@ func TestDiscoverAuth_HeaderInjection(t *testing.T) { } // Create MCPExternalAuthConfig with header injection - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-header-injection", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "api-key-secret", Key: "api-key", }, @@ -154,21 +154,21 @@ func TestDiscoverAuth_HeaderInjection(t *testing.T) { } // Create MCPServer that references the auth config - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-header-injection", }, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, URL: "http://localhost:8080", }, } @@ -203,18 +203,18 @@ func TestDiscoverAuth_NoAuthConfig(t *testing.T) { namespace := testNamespace // Create MCPServer without auth config reference - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", ProxyPort: 8080, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, URL: "http://localhost:8080", }, } @@ -241,21 +241,21 @@ func TestDiscoverAuth_AuthConfigNotFound(t *testing.T) { namespace := testNamespace // Create MCPServer that references non-existent auth config - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "non-existent-auth-config", }, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, URL: "http://localhost:8080", }, } @@ -281,17 +281,17 @@ func TestDiscoverAuth_SecretNotFound(t *testing.T) { namespace := testNamespace // Create MCPExternalAuthConfig with token exchange but secret doesn't exist - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-token-exchange", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "non-existent-secret", Key: "client-secret", }, @@ -300,21 +300,21 @@ func TestDiscoverAuth_SecretNotFound(t *testing.T) { } // Create MCPServer that references the auth config - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", ProxyPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-token-exchange", }, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, URL: "http://localhost:8080", }, } @@ -339,18 +339,18 @@ func TestMCPServerToBackend_BasicFields(t *testing.T) { namespace := testNamespace - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", ProxyPort: 8080, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, URL: "http://localhost:8080", }, } @@ -370,7 +370,7 @@ func TestMCPServerToBackend_BasicFields(t *testing.T) { assert.Equal(t, vmcp.BackendHealthy, backend.HealthStatus) assert.Equal(t, "mcp", backend.Metadata["tool_type"]) assert.Equal(t, "mcp_server", backend.Metadata["workload_type"]) - assert.Equal(t, string(mcpv1alpha1.MCPServerPhaseReady), backend.Metadata["workload_status"]) + assert.Equal(t, string(mcpv1beta1.MCPServerPhaseReady), backend.Metadata["workload_status"]) assert.Equal(t, namespace, backend.Metadata["namespace"]) } @@ -379,19 +379,19 @@ func TestMCPServerToBackend_StdioTransport(t *testing.T) { namespace := testNamespace - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "stdio", ProxyMode: "sse", // Explicit proxy mode ProxyPort: 8080, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, URL: "http://localhost:8080", }, } @@ -413,7 +413,7 @@ func TestMCPServerToBackend_WithAnnotations(t *testing.T) { namespace := testNamespace - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-server", Namespace: namespace, @@ -422,13 +422,13 @@ func TestMCPServerToBackend_WithAnnotations(t *testing.T) { "kubectl.kubernetes.io/foo": "should-be-filtered", }, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", ProxyPort: 8080, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, URL: "http://localhost:8080", }, } @@ -453,39 +453,39 @@ func TestListWorkloadsInGroup(t *testing.T) { namespace := testNamespace // Create multiple MCPServers in different groups - server1 := &mcpv1alpha1.MCPServer{ + server1 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, } - server2 := &mcpv1alpha1.MCPServer{ + server2 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server2", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, } - server3 := &mcpv1alpha1.MCPServer{ + server3 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server3", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-b"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-b"}, }, } @@ -517,33 +517,33 @@ func TestListWorkloadsInGroup_MCPRemoteProxies(t *testing.T) { namespace := testNamespace // Create multiple MCPRemoteProxies in different groups - proxy1 := &mcpv1alpha1.MCPRemoteProxy{ + proxy1 := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "proxy1", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, } - proxy2 := &mcpv1alpha1.MCPRemoteProxy{ + proxy2 := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "proxy2", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, } - proxy3 := &mcpv1alpha1.MCPRemoteProxy{ + proxy3 := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "proxy3", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-b"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-b"}, }, } @@ -575,48 +575,48 @@ func TestListWorkloadsInGroup_MixedWorkloads(t *testing.T) { namespace := testNamespace // Create MCPServers - server1 := &mcpv1alpha1.MCPServer{ + server1 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, } - server2 := &mcpv1alpha1.MCPServer{ + server2 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server2", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-b"}, // Different group + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-b"}, // Different group }, } // Create MCPRemoteProxies - proxy1 := &mcpv1alpha1.MCPRemoteProxy{ + proxy1 := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "proxy1", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, } - proxy2 := &mcpv1alpha1.MCPRemoteProxy{ + proxy2 := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "proxy2", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, } @@ -659,18 +659,18 @@ func TestMCPServerToBackend_EmptyStatusURL(t *testing.T) { // MCPServer is Running with transport and port, but Status.URL is empty // (the controller hasn't reconciled the Service yet). - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "pending-server", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", ProxyPort: 8080, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, // URL intentionally empty — not yet assigned by the operator }, } @@ -697,17 +697,17 @@ func TestMCPRemoteProxyToBackend_EmptyStatusURL(t *testing.T) { namespace := testNamespace // MCPRemoteProxy is Ready with transport, but Status.URL is empty. - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "pending-proxy", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote-mcp.example.com", Transport: "streamable-http", }, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ - Phase: mcpv1alpha1.MCPRemoteProxyPhaseReady, + Status: mcpv1beta1.MCPRemoteProxyStatus{ + Phase: mcpv1beta1.MCPRemoteProxyPhaseReady, // URL intentionally empty — not yet assigned by the operator }, } @@ -731,17 +731,17 @@ func TestMCPRemoteProxyToBackend_BasicFields(t *testing.T) { namespace := testNamespace - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote-mcp.example.com", Transport: "streamable-http", }, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ - Phase: mcpv1alpha1.MCPRemoteProxyPhaseReady, + Status: mcpv1beta1.MCPRemoteProxyStatus{ + Phase: mcpv1beta1.MCPRemoteProxyPhaseReady, URL: "http://proxy-service:8080", }, } @@ -761,7 +761,7 @@ func TestMCPRemoteProxyToBackend_BasicFields(t *testing.T) { assert.Equal(t, vmcp.BackendHealthy, backend.HealthStatus) assert.Equal(t, "mcp", backend.Metadata["tool_type"]) assert.Equal(t, "remote_proxy", backend.Metadata["workload_type"]) - assert.Equal(t, string(mcpv1alpha1.MCPRemoteProxyPhaseReady), backend.Metadata["workload_status"]) + assert.Equal(t, string(mcpv1beta1.MCPRemoteProxyPhaseReady), backend.Metadata["workload_status"]) assert.Equal(t, namespace, backend.Metadata["namespace"]) } @@ -770,7 +770,7 @@ func TestMCPRemoteProxyToBackend_WithAnnotations(t *testing.T) { namespace := testNamespace - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: namespace, @@ -779,12 +779,12 @@ func TestMCPRemoteProxyToBackend_WithAnnotations(t *testing.T) { "kubectl.kubernetes.io/foo": "should-be-filtered", }, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote-mcp.example.com", Transport: "streamable-http", }, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ - Phase: mcpv1alpha1.MCPRemoteProxyPhaseReady, + Status: mcpv1beta1.MCPRemoteProxyStatus{ + Phase: mcpv1beta1.MCPRemoteProxyPhaseReady, URL: "http://proxy-service:8080", }, } @@ -808,27 +808,27 @@ func TestMCPRemoteProxyToBackend_HealthStatusMapping(t *testing.T) { tests := []struct { name string - phase mcpv1alpha1.MCPRemoteProxyPhase + phase mcpv1beta1.MCPRemoteProxyPhase expectedHealth vmcp.BackendHealthStatus }{ { name: "Ready phase maps to Healthy", - phase: mcpv1alpha1.MCPRemoteProxyPhaseReady, + phase: mcpv1beta1.MCPRemoteProxyPhaseReady, expectedHealth: vmcp.BackendHealthy, }, { name: "Failed phase maps to Unhealthy", - phase: mcpv1alpha1.MCPRemoteProxyPhaseFailed, + phase: mcpv1beta1.MCPRemoteProxyPhaseFailed, expectedHealth: vmcp.BackendUnhealthy, }, { name: "Pending phase maps to Unknown", - phase: mcpv1alpha1.MCPRemoteProxyPhasePending, + phase: mcpv1beta1.MCPRemoteProxyPhasePending, expectedHealth: vmcp.BackendUnknown, }, { name: "Terminating phase maps to Unhealthy", - phase: mcpv1alpha1.MCPRemoteProxyPhaseTerminating, + phase: mcpv1beta1.MCPRemoteProxyPhaseTerminating, expectedHealth: vmcp.BackendUnhealthy, }, } @@ -839,16 +839,16 @@ func TestMCPRemoteProxyToBackend_HealthStatusMapping(t *testing.T) { namespace := testNamespace - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote-mcp.example.com", Transport: "streamable-http", }, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ + Status: mcpv1beta1.MCPRemoteProxyStatus{ Phase: tt.phase, URL: "http://proxy-service:8080", }, @@ -871,17 +871,17 @@ func TestGetWorkloadAsVMCPBackend_MCPRemoteProxy(t *testing.T) { namespace := testNamespace - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote-mcp.example.com", Transport: "streamable-http", }, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ - Phase: mcpv1alpha1.MCPRemoteProxyPhaseReady, + Status: mcpv1beta1.MCPRemoteProxyStatus{ + Phase: mcpv1beta1.MCPRemoteProxyPhaseReady, URL: "http://proxy-service:8080", }, } @@ -919,17 +919,17 @@ func TestDiscoverAuth_MCPRemoteProxy_TokenExchange(t *testing.T) { } // Create MCPExternalAuthConfig with token exchange - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-token-exchange", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "test-client-secret", Key: "client-secret", }, @@ -941,20 +941,20 @@ func TestDiscoverAuth_MCPRemoteProxy_TokenExchange(t *testing.T) { } // Create MCPRemoteProxy that references the auth config - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "test-proxy", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ RemoteURL: "https://remote-mcp.example.com", Transport: "streamable-http", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-token-exchange", }, }, - Status: mcpv1alpha1.MCPRemoteProxyStatus{ - Phase: mcpv1alpha1.MCPRemoteProxyPhaseReady, + Status: mcpv1beta1.MCPRemoteProxyStatus{ + Phase: mcpv1beta1.MCPRemoteProxyPhaseReady, URL: "http://proxy-service:8080", }, } @@ -987,39 +987,39 @@ func TestListWorkloadsInGroup_MCPServerEntries(t *testing.T) { namespace := testNamespace - entry1 := &mcpv1alpha1.MCPServerEntry{ + entry1 := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "entry1", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp1.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, } - entry2 := &mcpv1alpha1.MCPServerEntry{ + entry2 := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "entry2", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp2.example.com", Transport: "sse", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, } - entry3 := &mcpv1alpha1.MCPServerEntry{ + entry3 := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "entry3", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp3.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-b"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-b"}, }, } @@ -1049,37 +1049,37 @@ func TestListWorkloadsInGroup_AllWorkloadTypes(t *testing.T) { namespace := testNamespace - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "server1", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "test-image:latest", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, } - proxy := &mcpv1alpha1.MCPRemoteProxy{ + proxy := &mcpv1beta1.MCPRemoteProxy{ ObjectMeta: metav1.ObjectMeta{ Name: "proxy1", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPRemoteProxySpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, } - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "entry1", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, } @@ -1100,18 +1100,18 @@ func TestGetWorkloadAsVMCPBackend_MCPServerEntry(t *testing.T) { namespace := testNamespace - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-entry", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/v1", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, - Status: mcpv1alpha1.MCPServerEntryStatus{ - Phase: mcpv1alpha1.MCPServerEntryPhaseValid, + Status: mcpv1beta1.MCPServerEntryStatus{ + Phase: mcpv1beta1.MCPServerEntryPhaseValid, }, } @@ -1152,18 +1152,18 @@ func TestMCPServerEntryToBackend_BasicFields(t *testing.T) { namespace := testNamespace - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-entry", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/v1", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, - Status: mcpv1alpha1.MCPServerEntryStatus{ - Phase: mcpv1alpha1.MCPServerEntryPhaseValid, + Status: mcpv1beta1.MCPServerEntryStatus{ + Phase: mcpv1beta1.MCPServerEntryPhaseValid, }, } @@ -1182,7 +1182,7 @@ func TestMCPServerEntryToBackend_BasicFields(t *testing.T) { assert.Equal(t, vmcp.BackendHealthy, backend.HealthStatus) assert.Equal(t, "mcp", backend.Metadata["tool_type"]) assert.Equal(t, "server_entry", backend.Metadata["workload_type"]) - assert.Equal(t, string(mcpv1alpha1.MCPServerEntryPhaseValid), backend.Metadata["workload_status"]) + assert.Equal(t, string(mcpv1beta1.MCPServerEntryPhaseValid), backend.Metadata["workload_status"]) assert.Equal(t, "https://mcp.example.com/v1", backend.Metadata["remote_url"]) assert.Equal(t, namespace, backend.Metadata["namespace"]) } @@ -1192,18 +1192,18 @@ func TestMCPServerEntryToBackend_SSETransport(t *testing.T) { namespace := testNamespace - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "sse-entry", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/sse", Transport: "sse", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, - Status: mcpv1alpha1.MCPServerEntryStatus{ - Phase: mcpv1alpha1.MCPServerEntryPhaseValid, + Status: mcpv1beta1.MCPServerEntryStatus{ + Phase: mcpv1beta1.MCPServerEntryPhaseValid, }, } @@ -1221,7 +1221,7 @@ func TestMCPServerEntryToBackend_WithAnnotations(t *testing.T) { namespace := testNamespace - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "annotated-entry", Namespace: namespace, @@ -1230,13 +1230,13 @@ func TestMCPServerEntryToBackend_WithAnnotations(t *testing.T) { "kubectl.kubernetes.io/foo": "should-be-filtered", }, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/v1", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, - Status: mcpv1alpha1.MCPServerEntryStatus{ - Phase: mcpv1alpha1.MCPServerEntryPhaseValid, + Status: mcpv1beta1.MCPServerEntryStatus{ + Phase: mcpv1beta1.MCPServerEntryPhaseValid, }, } @@ -1255,18 +1255,18 @@ func TestMCPServerEntryToBackend_EmptyRemoteURL(t *testing.T) { namespace := testNamespace - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "empty-url-entry", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, - Status: mcpv1alpha1.MCPServerEntryStatus{ - Phase: mcpv1alpha1.MCPServerEntryPhaseValid, + Status: mcpv1beta1.MCPServerEntryStatus{ + Phase: mcpv1beta1.MCPServerEntryPhaseValid, }, } @@ -1290,27 +1290,27 @@ func TestGetWorkloadAsVMCPBackend_MCPServerEntry_NonValidPhaseSkipped(t *testing tests := []struct { name string - phase mcpv1alpha1.MCPServerEntryPhase + phase mcpv1beta1.MCPServerEntryPhase }{ - {name: "Pending phase is skipped", phase: mcpv1alpha1.MCPServerEntryPhasePending}, - {name: "Failed phase is skipped", phase: mcpv1alpha1.MCPServerEntryPhaseFailed}, + {name: "Pending phase is skipped", phase: mcpv1beta1.MCPServerEntryPhasePending}, + {name: "Failed phase is skipped", phase: mcpv1beta1.MCPServerEntryPhaseFailed}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "phase-test-entry", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/v1", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, - Status: mcpv1alpha1.MCPServerEntryStatus{ + Status: mcpv1beta1.MCPServerEntryStatus{ Phase: tt.phase, }, } @@ -1334,27 +1334,27 @@ func TestMCPServerEntryPhaseToHealth(t *testing.T) { tests := []struct { name string - phase mcpv1alpha1.MCPServerEntryPhase + phase mcpv1beta1.MCPServerEntryPhase expectedHealth vmcp.BackendHealthStatus }{ { name: "Valid phase maps to Healthy", - phase: mcpv1alpha1.MCPServerEntryPhaseValid, + phase: mcpv1beta1.MCPServerEntryPhaseValid, expectedHealth: vmcp.BackendHealthy, }, { name: "Failed phase maps to Unhealthy", - phase: mcpv1alpha1.MCPServerEntryPhaseFailed, + phase: mcpv1beta1.MCPServerEntryPhaseFailed, expectedHealth: vmcp.BackendUnhealthy, }, { name: "Pending phase maps to Unknown", - phase: mcpv1alpha1.MCPServerEntryPhasePending, + phase: mcpv1beta1.MCPServerEntryPhasePending, expectedHealth: vmcp.BackendUnknown, }, { name: "Unknown phase maps to Unknown", - phase: mcpv1alpha1.MCPServerEntryPhase("SomeUnknownPhase"), + phase: mcpv1beta1.MCPServerEntryPhase("SomeUnknownPhase"), expectedHealth: vmcp.BackendUnknown, }, } @@ -1372,22 +1372,22 @@ func TestMCPServerEntryToBackend_HealthStatusMapping(t *testing.T) { tests := []struct { name string - phase mcpv1alpha1.MCPServerEntryPhase + phase mcpv1beta1.MCPServerEntryPhase expectedHealth vmcp.BackendHealthStatus }{ { name: "Valid phase maps to Healthy", - phase: mcpv1alpha1.MCPServerEntryPhaseValid, + phase: mcpv1beta1.MCPServerEntryPhaseValid, expectedHealth: vmcp.BackendHealthy, }, { name: "Failed phase maps to Unhealthy", - phase: mcpv1alpha1.MCPServerEntryPhaseFailed, + phase: mcpv1beta1.MCPServerEntryPhaseFailed, expectedHealth: vmcp.BackendUnhealthy, }, { name: "Pending phase maps to Unknown", - phase: mcpv1alpha1.MCPServerEntryPhasePending, + phase: mcpv1beta1.MCPServerEntryPhasePending, expectedHealth: vmcp.BackendUnknown, }, } @@ -1398,17 +1398,17 @@ func TestMCPServerEntryToBackend_HealthStatusMapping(t *testing.T) { namespace := testNamespace - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-entry", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, - Status: mcpv1alpha1.MCPServerEntryStatus{ + Status: mcpv1beta1.MCPServerEntryStatus{ Phase: tt.phase, }, } @@ -1429,18 +1429,18 @@ func TestDiscoverAuth_MCPServerEntry_NoAuthConfig(t *testing.T) { namespace := testNamespace - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "no-auth-entry", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, }, - Status: mcpv1alpha1.MCPServerEntryStatus{ - Phase: mcpv1alpha1.MCPServerEntryPhaseValid, + Status: mcpv1beta1.MCPServerEntryStatus{ + Phase: mcpv1beta1.MCPServerEntryPhaseValid, }, } @@ -1462,21 +1462,21 @@ func TestDiscoverAuth_MCPServerEntry_AuthConfigNotFound(t *testing.T) { namespace := testNamespace - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-missing-entry", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "non-existent-auth-config", }, }, - Status: mcpv1alpha1.MCPServerEntryStatus{ - Phase: mcpv1alpha1.MCPServerEntryPhaseValid, + Status: mcpv1beta1.MCPServerEntryStatus{ + Phase: mcpv1beta1.MCPServerEntryPhaseValid, }, } @@ -1509,17 +1509,17 @@ func TestDiscoverAuth_MCPServerEntry_TokenExchange(t *testing.T) { }, } - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "entry-token-exchange", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: "https://auth.example.com/token", ClientID: "entry-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "entry-client-secret", Key: "client-secret", }, @@ -1530,21 +1530,21 @@ func TestDiscoverAuth_MCPServerEntry_TokenExchange(t *testing.T) { }, } - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-entry", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "group-a"}, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "group-a"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "entry-token-exchange", }, }, - Status: mcpv1alpha1.MCPServerEntryStatus{ - Phase: mcpv1alpha1.MCPServerEntryPhaseValid, + Status: mcpv1beta1.MCPServerEntryStatus{ + Phase: mcpv1beta1.MCPServerEntryPhaseValid, }, } @@ -1571,18 +1571,18 @@ func TestMCPServerEntryToBackend_SetsBackendTypeEntry(t *testing.T) { namespace := testNamespace - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "typed-entry", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://mcp.example.com/mcp", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, - Status: mcpv1alpha1.MCPServerEntryStatus{ - Phase: mcpv1alpha1.MCPServerEntryPhaseValid, + Status: mcpv1beta1.MCPServerEntryStatus{ + Phase: mcpv1beta1.MCPServerEntryPhaseValid, }, } @@ -1611,16 +1611,16 @@ func TestMCPServerEntryToBackend_WithCABundle(t *testing.T) { }, } - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "entry-with-ca", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://internal-mcp.corp:8443/mcp", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - CABundleRef: &mcpv1alpha1.CABundleSource{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ Name: "test-ca-bundle", @@ -1629,8 +1629,8 @@ func TestMCPServerEntryToBackend_WithCABundle(t *testing.T) { }, }, }, - Status: mcpv1alpha1.MCPServerEntryStatus{ - Phase: mcpv1alpha1.MCPServerEntryPhaseValid, + Status: mcpv1beta1.MCPServerEntryStatus{ + Phase: mcpv1beta1.MCPServerEntryPhaseValid, }, } @@ -1650,16 +1650,16 @@ func TestMCPServerEntryToBackend_CABundleMissing_ReturnsNil(t *testing.T) { namespace := testNamespace // No ConfigMap created — simulates missing CA bundle - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "entry-missing-ca", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://internal-mcp.corp:8443/mcp", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - CABundleRef: &mcpv1alpha1.CABundleSource{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ Name: "nonexistent-ca-bundle", @@ -1668,8 +1668,8 @@ func TestMCPServerEntryToBackend_CABundleMissing_ReturnsNil(t *testing.T) { }, }, }, - Status: mcpv1alpha1.MCPServerEntryStatus{ - Phase: mcpv1alpha1.MCPServerEntryPhaseValid, + Status: mcpv1beta1.MCPServerEntryStatus{ + Phase: mcpv1beta1.MCPServerEntryPhaseValid, }, } @@ -1698,16 +1698,16 @@ func TestMCPServerEntryToBackend_WithCABundleDefaultKey(t *testing.T) { }, } - entry := &mcpv1alpha1.MCPServerEntry{ + entry := &mcpv1beta1.MCPServerEntry{ ObjectMeta: metav1.ObjectMeta{ Name: "entry-default-key", Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerEntrySpec{ + Spec: mcpv1beta1.MCPServerEntrySpec{ RemoteURL: "https://internal-mcp.corp:8443/mcp", Transport: "streamable-http", - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: "test-group"}, - CABundleRef: &mcpv1alpha1.CABundleSource{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ Name: "default-key-ca", @@ -1716,8 +1716,8 @@ func TestMCPServerEntryToBackend_WithCABundleDefaultKey(t *testing.T) { }, }, }, - Status: mcpv1alpha1.MCPServerEntryStatus{ - Phase: mcpv1alpha1.MCPServerEntryPhaseValid, + Status: mcpv1beta1.MCPServerEntryStatus{ + Phase: mcpv1beta1.MCPServerEntryPhaseValid, }, } @@ -1735,21 +1735,21 @@ func TestFetchCABundleData(t *testing.T) { tests := []struct { name string - ref *mcpv1alpha1.CABundleSource + ref *mcpv1beta1.CABundleSource objs []client.Object wantData []byte wantErr string }{ { name: "nil ConfigMapRef returns error", - ref: &mcpv1alpha1.CABundleSource{ + ref: &mcpv1beta1.CABundleSource{ ConfigMapRef: nil, }, wantErr: "configMapRef is nil", }, { name: "ConfigMap not found returns error", - ref: &mcpv1alpha1.CABundleSource{ + ref: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "missing-cm"}, }, @@ -1758,7 +1758,7 @@ func TestFetchCABundleData(t *testing.T) { }, { name: "key missing from ConfigMap returns error", - ref: &mcpv1alpha1.CABundleSource{ + ref: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "test-ca"}, Key: "nonexistent.pem", @@ -1774,7 +1774,7 @@ func TestFetchCABundleData(t *testing.T) { }, { name: "default key ca.crt used when key is empty", - ref: &mcpv1alpha1.CABundleSource{ + ref: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "test-ca"}, Key: "", @@ -1790,7 +1790,7 @@ func TestFetchCABundleData(t *testing.T) { }, { name: "explicit key used", - ref: &mcpv1alpha1.CABundleSource{ + ref: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "test-ca"}, Key: "custom.pem", diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/assert-embeddingserver-ns1-running.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/assert-embeddingserver-ns1-running.yaml index ca17b4bb09..a3d28eeb47 100644 --- a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/assert-embeddingserver-ns1-running.yaml +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/assert-embeddingserver-ns1-running.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: mt-embedding diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/assert-embeddingserver-ns2-running.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/assert-embeddingserver-ns2-running.yaml index a35c2374c1..c536e54004 100644 --- a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/assert-embeddingserver-ns2-running.yaml +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/assert-embeddingserver-ns2-running.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: mt-embedding diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/embeddingserver-ns1.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/embeddingserver-ns1.yaml index 12e23de197..318f8dea12 100644 --- a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/embeddingserver-ns1.yaml +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/embeddingserver-ns1.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: ($testPrefix) diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/embeddingserver-ns2.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/embeddingserver-ns2.yaml index 260e9532a4..75351c32a2 100644 --- a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/embeddingserver-ns2.yaml +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/embeddingserver/embeddingserver-ns2.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: ($testPrefix) diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/sse/assert-mcpserver-running.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/sse/assert-mcpserver-running.yaml index 8f086d0729..ad18e95dd2 100644 --- a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/sse/assert-mcpserver-running.yaml +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/sse/assert-mcpserver-running.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/sse/mcpserver.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/sse/mcpserver.yaml index 1dda68815c..0b41db3be2 100644 --- a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/sse/mcpserver.yaml +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/sse/mcpserver.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio-streamable-http/assert-mcpserver-running.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio-streamable-http/assert-mcpserver-running.yaml index 8f086d0729..ad18e95dd2 100644 --- a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio-streamable-http/assert-mcpserver-running.yaml +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio-streamable-http/assert-mcpserver-running.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio-streamable-http/mcpserver.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio-streamable-http/mcpserver.yaml index 9798bc26b1..dfde6772f0 100644 --- a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio-streamable-http/mcpserver.yaml +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio-streamable-http/mcpserver.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio/assert-mcpserver-running.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio/assert-mcpserver-running.yaml index 8f086d0729..ad18e95dd2 100644 --- a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio/assert-mcpserver-running.yaml +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio/assert-mcpserver-running.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio/mcpserver.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio/mcpserver.yaml index 5df7803117..a7f4775cc0 100644 --- a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio/mcpserver.yaml +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/stdio/mcpserver.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/streamable-http/assert-mcpserver-running.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/streamable-http/assert-mcpserver-running.yaml index 8f086d0729..ad18e95dd2 100644 --- a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/streamable-http/assert-mcpserver-running.yaml +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/streamable-http/assert-mcpserver-running.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick diff --git a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/streamable-http/mcpserver.yaml b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/streamable-http/mcpserver.yaml index afe42cd271..36764a934f 100644 --- a/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/streamable-http/mcpserver.yaml +++ b/test/e2e/chainsaw/operator/multi-tenancy/test-scenarios/streamable-http/mcpserver.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: yardstick diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/basic/assert-embeddingserver-running.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/basic/assert-embeddingserver-running.yaml index ff4cf53e37..6376cfb5b9 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/basic/assert-embeddingserver-running.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/basic/assert-embeddingserver-running.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: st-embedding-basic diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/basic/embeddingserver.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/basic/embeddingserver.yaml index 97eb1eada1..f731650920 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/basic/embeddingserver.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/basic/embeddingserver.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: ($testPrefix) diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/assert-embeddingserver-running.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/assert-embeddingserver-running.yaml index 0e47d1c7a9..009c2b667c 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/assert-embeddingserver-running.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/assert-embeddingserver-running.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: st-embedding-lifecycle diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/assert-embeddingserver-scaled.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/assert-embeddingserver-scaled.yaml index 6e3da079c4..76e05e6db8 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/assert-embeddingserver-scaled.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/assert-embeddingserver-scaled.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: st-embedding-lifecycle diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/chainsaw-test.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/chainsaw-test.yaml index 4dc652183c..abd821dfd6 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/chainsaw-test.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/chainsaw-test.yaml @@ -70,7 +70,7 @@ spec: try: - delete: ref: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer name: ($testPrefix) namespace: toolhive-system diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/embeddingserver-initial.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/embeddingserver-initial.yaml index da72c25b90..ebe6fef06c 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/embeddingserver-initial.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/embeddingserver-initial.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: ($testPrefix) diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/embeddingserver-scaled.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/embeddingserver-scaled.yaml index 48e19545b9..2bedf6dd8f 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/embeddingserver-scaled.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/embeddingserver-scaled.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: ($testPrefix) diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/embeddingserver-updated-env.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/embeddingserver-updated-env.yaml index 4efd73ec44..19dd49e5a8 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/embeddingserver-updated-env.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/lifecycle/embeddingserver-updated-env.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: ($testPrefix) diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/with-cache/assert-embeddingserver-running.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/with-cache/assert-embeddingserver-running.yaml index 1bc08dec0a..0e966ca9db 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/with-cache/assert-embeddingserver-running.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/with-cache/assert-embeddingserver-running.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: st-embedding-cache diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/with-cache/embeddingserver.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/with-cache/embeddingserver.yaml index 28cef57bae..0b529ee139 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/with-cache/embeddingserver.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/embeddingserver/with-cache/embeddingserver.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: EmbeddingServer metadata: name: ($testPrefix) diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/pod-annotations/assert-mcpserver-running.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/pod-annotations/assert-mcpserver-running.yaml index 8cddf549f1..fbb3f9e765 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/pod-annotations/assert-mcpserver-running.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/pod-annotations/assert-mcpserver-running.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: ($testPrefix) diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/pod-annotations/mcpserver.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/pod-annotations/mcpserver.yaml index 2d263b46f2..d5bd363aa2 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/pod-annotations/mcpserver.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/pod-annotations/mcpserver.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: ($testPrefix) diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/sse/assert-mcpserver-running.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/sse/assert-mcpserver-running.yaml index f3e5d9b9a1..ae951df84e 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/sse/assert-mcpserver-running.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/sse/assert-mcpserver-running.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: st-sse diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/sse/mcpserver.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/sse/mcpserver.yaml index 9433421519..48136be9be 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/sse/mcpserver.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/sse/mcpserver.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: ($testPrefix) diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio-streamable-http/assert-mcpserver-running.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio-streamable-http/assert-mcpserver-running.yaml index 0f17c4958a..a05b9fec71 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio-streamable-http/assert-mcpserver-running.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio-streamable-http/assert-mcpserver-running.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: st-stdio-streamable-http diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio-streamable-http/mcpserver.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio-streamable-http/mcpserver.yaml index 731df8f4d5..ffffe71cf8 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio-streamable-http/mcpserver.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio-streamable-http/mcpserver.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: ($testPrefix) diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio/assert-mcpserver-running.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio/assert-mcpserver-running.yaml index ad39e23016..58697018f6 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio/assert-mcpserver-running.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio/assert-mcpserver-running.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: st-stdio diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio/mcpserver.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio/mcpserver.yaml index 12af85b699..f28ccdefe7 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio/mcpserver.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/stdio/mcpserver.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: ($testPrefix) diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/streamable-http/assert-mcpserver-running.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/streamable-http/assert-mcpserver-running.yaml index dcee089921..f079dd60a8 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/streamable-http/assert-mcpserver-running.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/streamable-http/assert-mcpserver-running.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: st-streamable-http diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/streamable-http/mcpserver.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/streamable-http/mcpserver.yaml index a717b94fda..7ee63f3710 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/streamable-http/mcpserver.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/streamable-http/mcpserver.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: ($testPrefix) diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/assert-vmcp-status-ready.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/assert-vmcp-status-ready.yaml index c4aab92bee..cf1ed25d21 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/assert-vmcp-status-ready.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/assert-vmcp-status-ready.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-controller diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/audit-chainsaw-test.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/audit-chainsaw-test.yaml index 059ecb72d6..b6a38b69cd 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/audit-chainsaw-test.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/audit-chainsaw-test.yaml @@ -21,7 +21,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: test-audit-group @@ -34,7 +34,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-audit-enabled @@ -49,7 +49,7 @@ spec: type: anonymous - assert: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-audit-enabled @@ -83,7 +83,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-no-audit @@ -95,7 +95,7 @@ spec: type: anonymous - assert: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-no-audit @@ -140,7 +140,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-audit-disabled @@ -155,7 +155,7 @@ spec: type: anonymous - assert: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-audit-disabled diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/basic/chainsaw-test.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/basic/chainsaw-test.yaml index 29041b0e4e..fa8b3f9ad7 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/basic/chainsaw-test.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/basic/chainsaw-test.yaml @@ -9,7 +9,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: test-group @@ -20,7 +20,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPOIDCConfig metadata: name: test-vmcp-oidc @@ -33,7 +33,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp @@ -52,7 +52,7 @@ spec: source: discovered - assert: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/chainsaw-test.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/chainsaw-test.yaml index 7e50b23491..77ef8e48e5 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/chainsaw-test.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/chainsaw-test.yaml @@ -20,7 +20,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: deploy-workflow @@ -48,7 +48,7 @@ spec: timeout: 2m - assert: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: deploy-workflow @@ -79,7 +79,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: duplicate-steps @@ -118,7 +118,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: bad-tool-ref @@ -150,7 +150,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: circular-deps @@ -189,7 +189,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: with-parameters @@ -238,7 +238,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPCompositeToolDefinition metadata: name: with-error-handling diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/controller-chainsaw-test.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/controller-chainsaw-test.yaml index d5a53825e6..8b930f7242 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/controller-chainsaw-test.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/controller-chainsaw-test.yaml @@ -182,7 +182,7 @@ spec: - patch: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-controller diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/mcpgroup-controller.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/mcpgroup-controller.yaml index 7a3bac8173..8709f78f53 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/mcpgroup-controller.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/mcpgroup-controller.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: test-group-controller diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-controller.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-controller.yaml index caa000d6ee..04add28bef 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-controller.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-controller.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-controller diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-oidc-config.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-oidc-config.yaml index e9847eee8c..ac4cd0fbb4 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-oidc-config.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-oidc-config.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPOIDCConfig metadata: name: test-vmcp-oidc-config diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-with-oidc.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-with-oidc.yaml index b732de713e..141ad4f91e 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-with-oidc.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-with-oidc.yaml @@ -1,4 +1,4 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-oidc diff --git a/test/e2e/chainsaw/operator/validation/mcpexternalauthconfig/chainsaw-test.yaml b/test/e2e/chainsaw/operator/validation/mcpexternalauthconfig/chainsaw-test.yaml index 498e926736..7af9de1cf1 100644 --- a/test/e2e/chainsaw/operator/validation/mcpexternalauthconfig/chainsaw-test.yaml +++ b/test/e2e/chainsaw/operator/validation/mcpexternalauthconfig/chainsaw-test.yaml @@ -17,7 +17,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-invalid-tokenexchange @@ -35,7 +35,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-invalid-unauthenticated @@ -56,7 +56,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-valid-unauthenticated @@ -65,7 +65,7 @@ spec: type: unauthenticated - assert: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-valid-unauthenticated @@ -77,7 +77,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-valid-tokenexchange @@ -89,7 +89,7 @@ spec: audience: example-audience - assert: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-valid-tokenexchange @@ -101,7 +101,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-invalid-headerinjection @@ -119,7 +119,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-valid-headerinjection @@ -133,7 +133,7 @@ spec: key: api-key - assert: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-valid-headerinjection @@ -145,7 +145,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-invalid-bearertoken @@ -163,7 +163,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-valid-bearertoken @@ -176,7 +176,7 @@ spec: key: token - assert: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-valid-bearertoken @@ -188,7 +188,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-invalid-embeddedauthserver @@ -206,7 +206,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-valid-embeddedauthserver @@ -230,7 +230,7 @@ spec: redirectURI: https://auth.example.com/callback - assert: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-valid-embeddedauthserver @@ -242,7 +242,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-invalid-awssts @@ -260,7 +260,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-valid-awssts @@ -272,7 +272,7 @@ spec: fallbackRoleArn: arn:aws:iam::123456789012:role/TestRole - assert: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: test-valid-awssts diff --git a/test/e2e/chainsaw/operator/validation/virtualmcpserver/chainsaw-test.yaml b/test/e2e/chainsaw/operator/validation/virtualmcpserver/chainsaw-test.yaml index 22601f4b25..c0110d3b5b 100644 --- a/test/e2e/chainsaw/operator/validation/virtualmcpserver/chainsaw-test.yaml +++ b/test/e2e/chainsaw/operator/validation/virtualmcpserver/chainsaw-test.yaml @@ -17,7 +17,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-invalid-oidc-missing-config @@ -38,7 +38,7 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-valid-minimal @@ -51,7 +51,7 @@ spec: name: test-group - assert: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-valid-minimal diff --git a/test/e2e/export_test.go b/test/e2e/export_test.go index 58f14eb1df..ab92ed3d0b 100644 --- a/test/e2e/export_test.go +++ b/test/e2e/export_test.go @@ -14,7 +14,7 @@ import ( . "github.com/onsi/gomega" "sigs.k8s.io/yaml" - v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/runner" "github.com/stacklok/toolhive/test/e2e" ) @@ -95,12 +95,12 @@ var _ = Describe("Export Command", Label("core", "export", "e2e"), func() { fileContent, err := os.ReadFile(exportPath) Expect(err).ToNot(HaveOccurred()) - var mcpServer v1alpha1.MCPServer + var mcpServer v1beta1.MCPServer err = yaml.Unmarshal(fileContent, &mcpServer) Expect(err).ToNot(HaveOccurred(), "Exported file should be valid YAML") By("Verifying the exported MCPServer has correct structure") - Expect(mcpServer.APIVersion).To(Equal("toolhive.stacklok.dev/v1alpha1")) + Expect(mcpServer.APIVersion).To(Equal("toolhive.stacklok.dev/v1beta1")) Expect(mcpServer.Kind).To(Equal("MCPServer")) Expect(mcpServer.Name).ToNot(BeEmpty(), "Name should be set") Expect(mcpServer.Spec.Image).ToNot(BeEmpty(), "Image should be set") @@ -145,7 +145,7 @@ var _ = Describe("Export Command", Label("core", "export", "e2e"), func() { fileContent, err = os.ReadFile(yamlPath) Expect(err).ToNot(HaveOccurred()) - var mcpServer v1alpha1.MCPServer + var mcpServer v1beta1.MCPServer err = yaml.Unmarshal(fileContent, &mcpServer) Expect(err).ToNot(HaveOccurred()) diff --git a/test/e2e/thv-operator/acceptance_tests/ratelimit_test.go b/test/e2e/thv-operator/acceptance_tests/ratelimit_test.go index 6e0cf6a965..c1fc7372d7 100644 --- a/test/e2e/thv-operator/acceptance_tests/ratelimit_test.go +++ b/test/e2e/thv-operator/acceptance_tests/ratelimit_test.go @@ -14,7 +14,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/test/e2e/images" "github.com/stacklok/toolhive/test/e2e/thv-operator/testutil" ) @@ -47,25 +47,25 @@ var _ = Describe("MCPServer Rate Limiting", Ordered, func() { BeforeAll(func() { By("Creating MCPServer with shared rate limit (maxTokens=3, refillPeriod=1m)") - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: serverName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "TRANSPORT", Value: "streamable-http"}, }, - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ + SessionStorage: &mcpv1beta1.SessionStorageConfig{ Provider: "redis", Address: fmt.Sprintf("redis.%s.svc.cluster.local:6379", testNamespace), }, - RateLimiting: &mcpv1alpha1.RateLimitConfig{ - Shared: &mcpv1alpha1.RateLimitBucket{ + RateLimiting: &mcpv1beta1.RateLimitConfig{ + Shared: &mcpv1beta1.RateLimitBucket{ MaxTokens: 3, RefillPeriod: metav1.Duration{Duration: time.Minute}, }, @@ -104,7 +104,7 @@ var _ = Describe("MCPServer Rate Limiting", Ordered, func() { ObjectMeta: metav1.ObjectMeta{Name: serverName + "-nodeport", Namespace: testNamespace}, }) By("Cleaning up MCPServer") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: serverName, Namespace: testNamespace}, }) }) @@ -139,32 +139,32 @@ var _ = Describe("MCPServer Rate Limiting", Ordered, func() { It("should accept CRD with both shared and per-tool config (AC8)", func() { By("Creating a second MCPServer with both shared and tools config") server2Name := "ratelimit-both" - server2 := &mcpv1alpha1.MCPServer{ + server2 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: server2Name, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "TRANSPORT", Value: "streamable-http"}, }, - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ + SessionStorage: &mcpv1beta1.SessionStorageConfig{ Provider: "redis", Address: fmt.Sprintf("redis.%s.svc.cluster.local:6379", testNamespace), }, - RateLimiting: &mcpv1alpha1.RateLimitConfig{ - Shared: &mcpv1alpha1.RateLimitBucket{ + RateLimiting: &mcpv1beta1.RateLimitConfig{ + Shared: &mcpv1beta1.RateLimitBucket{ MaxTokens: 100, RefillPeriod: metav1.Duration{Duration: time.Minute}, }, - Tools: []mcpv1alpha1.ToolRateLimitConfig{ + Tools: []mcpv1beta1.ToolRateLimitConfig{ { Name: "echo", - Shared: &mcpv1alpha1.RateLimitBucket{ + Shared: &mcpv1beta1.RateLimitBucket{ MaxTokens: 10, RefillPeriod: metav1.Duration{Duration: time.Minute}, }, @@ -201,14 +201,14 @@ var _ = Describe("MCPServer Rate Limiting", Ordered, func() { GinkgoWriter.Printf("Mock OIDC server: issuer=%s nodePort=%d\n", issuerURL, oidcNodePort) By("Creating MCPOIDCConfig for inline OIDC auth") - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: oidcConfigName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: issuerURL, JWKSAllowPrivateIP: true, InsecureAllowHTTP: true, @@ -218,29 +218,29 @@ var _ = Describe("MCPServer Rate Limiting", Ordered, func() { Expect(k8sClient.Create(ctx, oidcConfig)).To(Succeed()) By("Creating MCPServer with per-user rate limit and OIDC auth ref") - server := &mcpv1alpha1.MCPServer{ + server := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: serverName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "TRANSPORT", Value: "streamable-http"}, }, - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ + SessionStorage: &mcpv1beta1.SessionStorageConfig{ Provider: "redis", Address: fmt.Sprintf("redis.%s.svc.cluster.local:6379", testNamespace), }, - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: oidcConfigName, Audience: "vmcp-audience", }, - RateLimiting: &mcpv1alpha1.RateLimitConfig{ - PerUser: &mcpv1alpha1.RateLimitBucket{ + RateLimiting: &mcpv1beta1.RateLimitConfig{ + PerUser: &mcpv1beta1.RateLimitBucket{ MaxTokens: 2, RefillPeriod: metav1.Duration{Duration: time.Minute}, }, @@ -279,11 +279,11 @@ var _ = Describe("MCPServer Rate Limiting", Ordered, func() { ObjectMeta: metav1.ObjectMeta{Name: serverName + "-nodeport", Namespace: testNamespace}, }) By("Cleaning up MCPServer") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: serverName, Namespace: testNamespace}, }) By("Cleaning up MCPOIDCConfig") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPOIDCConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: oidcConfigName, Namespace: testNamespace}, }) By("Cleaning up OIDC server") diff --git a/test/e2e/thv-operator/acceptance_tests/suite_test.go b/test/e2e/thv-operator/acceptance_tests/suite_test.go index 2ae1000918..0d4d0c4e0a 100644 --- a/test/e2e/thv-operator/acceptance_tests/suite_test.go +++ b/test/e2e/thv-operator/acceptance_tests/suite_test.go @@ -23,7 +23,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) var ( @@ -64,7 +64,7 @@ var _ = ginkgo.BeforeSuite(func() { cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfig) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - err = mcpv1alpha1.AddToScheme(scheme.Scheme) + err = mcpv1beta1.AddToScheme(scheme.Scheme) gomega.Expect(err).NotTo(gomega.HaveOccurred()) err = appsv1.AddToScheme(scheme.Scheme) gomega.Expect(err).NotTo(gomega.HaveOccurred()) diff --git a/test/e2e/thv-operator/testutil/k8s.go b/test/e2e/thv-operator/testutil/k8s.go index 77e116f04d..17fe03adaa 100644 --- a/test/e2e/thv-operator/testutil/k8s.go +++ b/test/e2e/thv-operator/testutil/k8s.go @@ -22,7 +22,7 @@ import ( "k8s.io/client-go/tools/clientcmd" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // CheckPodsReady checks that at least one pod matching the given labels is running and ready. @@ -104,17 +104,17 @@ func WaitForMCPServerRunning( timeout, pollingInterval time.Duration, ) { gomega.Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := c.Get(ctx, types.NamespacedName{ Name: name, Namespace: namespace, }, server); err != nil { return err } - if server.Status.Phase == mcpv1alpha1.MCPServerPhaseFailed { + if server.Status.Phase == mcpv1beta1.MCPServerPhaseFailed { return gomega.StopTrying(fmt.Sprintf("MCPServer %s failed: %s", name, server.Status.Message)) } - if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("MCPServer %s not ready, phase: %s", name, server.Status.Phase) } return nil diff --git a/test/e2e/thv-operator/virtualmcp/helpers.go b/test/e2e/thv-operator/virtualmcp/helpers.go index a5dfe53226..d91e314b72 100644 --- a/test/e2e/thv-operator/virtualmcp/helpers.go +++ b/test/e2e/thv-operator/virtualmcp/helpers.go @@ -29,7 +29,7 @@ import ( "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/test/e2e/images" "github.com/stacklok/toolhive/test/e2e/thv-operator/testutil" ) @@ -51,7 +51,7 @@ func WaitForVirtualMCPServerReady( timeout time.Duration, pollingInterval time.Duration, ) { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} gomega.Eventually(func() error { if err := c.Get(ctx, types.NamespacedName{ @@ -62,7 +62,7 @@ func WaitForVirtualMCPServerReady( } for _, condition := range vmcpServer.Status.Conditions { - if condition.Type == mcpv1alpha1.ConditionTypeVirtualMCPServerReady { + if condition.Type == mcpv1beta1.ConditionTypeVirtualMCPServerReady { if condition.Status == "True" { // Also check that at least one pod is actually running and ready labels := map[string]string{ @@ -176,8 +176,8 @@ func WaitForPodsReady( // GetMCPGroupBackends returns the list of backend MCPServers in an MCPGroup // Note: MCPGroup status contains the list of servers in the group -func GetMCPGroupBackends(ctx context.Context, c client.Client, groupName, namespace string) ([]mcpv1alpha1.MCPServer, error) { - mcpGroup := &mcpv1alpha1.MCPGroup{} +func GetMCPGroupBackends(ctx context.Context, c client.Client, groupName, namespace string) ([]mcpv1beta1.MCPServer, error) { + mcpGroup := &mcpv1beta1.MCPGroup{} if err := c.Get(ctx, types.NamespacedName{ Name: groupName, Namespace: namespace, @@ -186,14 +186,14 @@ func GetMCPGroupBackends(ctx context.Context, c client.Client, groupName, namesp } // Get all MCPServers in the namespace - mcpServerList := &mcpv1alpha1.MCPServerList{} + mcpServerList := &mcpv1beta1.MCPServerList{} if err := c.List(ctx, mcpServerList, client.InNamespace(namespace)); err != nil { return nil, err } // Filter MCPServers that reference this group - var backends []mcpv1alpha1.MCPServer + var backends []mcpv1beta1.MCPServer for _, mcpServer := range mcpServerList.Items { if mcpServer.Spec.GroupRef.GetName() == groupName { backends = append(backends, mcpServer) @@ -208,8 +208,8 @@ func GetVirtualMCPServerStatus( ctx context.Context, c client.Client, name, namespace string, -) (*mcpv1alpha1.VirtualMCPServerStatus, error) { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} +) (*mcpv1beta1.VirtualMCPServerStatus, error) { + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := c.Get(ctx, types.NamespacedName{ Name: name, Namespace: namespace, @@ -220,7 +220,7 @@ func GetVirtualMCPServerStatus( } // HasCondition checks if a VirtualMCPServer has a specific condition type with expected status -func HasCondition(vmcpServer *mcpv1alpha1.VirtualMCPServer, conditionType string, expectedStatus string) bool { +func HasCondition(vmcpServer *mcpv1beta1.VirtualMCPServer, conditionType string, expectedStatus string) bool { for _, condition := range vmcpServer.Status.Conditions { if condition.Type == conditionType && string(condition.Status) == expectedStatus { return true @@ -240,7 +240,7 @@ func WaitForCondition( pollingInterval time.Duration, ) { gomega.Eventually(func() error { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := c.Get(ctx, types.NamespacedName{ Name: name, Namespace: namespace, @@ -634,13 +634,13 @@ func CreateMCPGroupAndWait( c client.Client, name, namespace, description string, timeout, pollingInterval time.Duration, -) *mcpv1alpha1.MCPGroup { - mcpGroup := &mcpv1alpha1.MCPGroup{ +) *mcpv1beta1.MCPGroup { + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: description, }, } @@ -654,7 +654,7 @@ func CreateMCPGroupAndWait( if err != nil { return false } - return mcpGroup.Status.Phase == mcpv1alpha1.MCPGroupPhaseReady + return mcpGroup.Status.Phase == mcpv1beta1.MCPGroupPhaseReady }, timeout, pollingInterval).Should(gomega.BeTrue(), "MCPGroup should become ready") return mcpGroup @@ -667,20 +667,20 @@ func CreateMCPServerAndWait( c client.Client, name, namespace, groupRef, image string, timeout, pollingInterval time.Duration, -) *mcpv1alpha1.MCPServer { - backend := &mcpv1alpha1.MCPServer{ +) *mcpv1beta1.MCPServer { + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: groupRef}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupRef}, Image: image, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, Resources: defaultMCPServerResources(), - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "TRANSPORT", Value: "streamable-http"}, }, }, @@ -688,7 +688,7 @@ func CreateMCPServerAndWait( gomega.Expect(c.Create(ctx, backend)).To(gomega.Succeed()) gomega.Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} err := c.Get(ctx, types.NamespacedName{ Name: name, Namespace: namespace, @@ -696,7 +696,7 @@ func CreateMCPServerAndWait( if err != nil { return fmt.Errorf("failed to get server: %w", err) } - if server.Status.Phase == mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase == mcpv1beta1.MCPServerPhaseReady { return nil } return fmt.Errorf("%s not ready yet, phase: %s", name, server.Status.Phase) @@ -712,25 +712,25 @@ type BackendConfig struct { GroupRef string Image string Transport string // defaults to "streamable-http" if empty - ExternalAuthConfigRef *mcpv1alpha1.ExternalAuthConfigRef - Secrets []mcpv1alpha1.SecretRef - Env []mcpv1alpha1.EnvVar // additional env vars beyond TRANSPORT + ExternalAuthConfigRef *mcpv1beta1.ExternalAuthConfigRef + Secrets []mcpv1beta1.SecretRef + Env []mcpv1beta1.EnvVar // additional env vars beyond TRANSPORT // Resources overrides the default resource requests/limits. When nil, // defaultMCPServerResources() is used to ensure containers are scheduled // with reasonable resource guarantees and do not compete excessively. - Resources *mcpv1alpha1.ResourceRequirements + Resources *mcpv1beta1.ResourceRequirements } // defaultMCPServerResources returns conservative resource requests/limits that // mirror the quickstart example (vmcp_optimizer_quickstart.yaml) and are // sufficient for functional E2E testing without starving other pods. -func defaultMCPServerResources() mcpv1alpha1.ResourceRequirements { - return mcpv1alpha1.ResourceRequirements{ - Limits: mcpv1alpha1.ResourceList{ +func defaultMCPServerResources() mcpv1beta1.ResourceRequirements { + return mcpv1beta1.ResourceRequirements{ + Limits: mcpv1beta1.ResourceList{ CPU: "200m", Memory: "256Mi", }, - Requests: mcpv1alpha1.ResourceList{ + Requests: mcpv1beta1.ResourceList{ CPU: "100m", Memory: "128Mi", }, @@ -758,13 +758,13 @@ func CreateMultipleMCPServersInParallel( resources = *backends[idx].Resources } - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backends[idx].Name, Namespace: backends[idx].Namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: backends[idx].GroupRef}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: backends[idx].GroupRef}, Image: backends[idx].Image, Transport: backendTransport, ProxyPort: 8080, @@ -772,7 +772,7 @@ func CreateMultipleMCPServersInParallel( ExternalAuthConfigRef: backends[idx].ExternalAuthConfigRef, Secrets: backends[idx].Secrets, Resources: resources, - Env: append([]mcpv1alpha1.EnvVar{ + Env: append([]mcpv1beta1.EnvVar{ {Name: "TRANSPORT", Value: backendTransport}, }, backends[idx].Env...), }, @@ -783,7 +783,7 @@ func CreateMultipleMCPServersInParallel( // Wait for all backends to be ready in parallel (single Eventually checking all) gomega.Eventually(func() error { for _, cfg := range backends { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} err := c.Get(ctx, types.NamespacedName{ Name: cfg.Name, Namespace: cfg.Namespace, @@ -792,10 +792,10 @@ func CreateMultipleMCPServersInParallel( return fmt.Errorf("failed to get server %s: %w", cfg.Name, err) } // Fail-fast if server enters Failed phase (e.g., bad image, crash loop) - if server.Status.Phase == mcpv1alpha1.MCPServerPhaseFailed { + if server.Status.Phase == mcpv1beta1.MCPServerPhaseFailed { return gomega.StopTrying(fmt.Sprintf("%s failed: %s", cfg.Name, server.Status.Message)) } - if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("%s not ready yet, phase: %s", cfg.Name, server.Status.Phase) } } diff --git a/test/e2e/thv-operator/virtualmcp/mcpserver_scaling_test.go b/test/e2e/thv-operator/virtualmcp/mcpserver_scaling_test.go index c4223b6f46..620ef7760f 100644 --- a/test/e2e/thv-operator/virtualmcp/mcpserver_scaling_test.go +++ b/test/e2e/thv-operator/virtualmcp/mcpserver_scaling_test.go @@ -27,7 +27,7 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/test/e2e/images" "github.com/stacklok/toolhive/test/e2e/thv-operator/testutil" ) @@ -262,9 +262,9 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu redisAddr := fmt.Sprintf("%s.%s.svc.cluster.local:6379", redisName, defaultNamespace) ginkgo.By("Creating MCPServer with replicas=2, backendReplicas=2, Redis session storage") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: mcpServerName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: proxyPort, @@ -272,8 +272,8 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu Replicas: &replicas, BackendReplicas: &backendReplicas, SessionAffinity: "None", - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: redisAddr, }, }, @@ -293,13 +293,13 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu }) ginkgo.AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: mcpServerName, Namespace: defaultNamespace}, }) cleanupRedis(redisName) gomega.Eventually(func() bool { - err := k8sClient.Get(ctx, types.NamespacedName{Name: mcpServerName, Namespace: defaultNamespace}, &mcpv1alpha1.MCPServer{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: mcpServerName, Namespace: defaultNamespace}, &mcpv1beta1.MCPServer{}) return apierrors.IsNotFound(err) }, e2eTimeout, e2ePollInterval).Should(gomega.BeTrue()) }) @@ -391,17 +391,17 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu redisAddr := fmt.Sprintf("%s.%s.svc.cluster.local:6379", redisName, defaultNamespace) ginkgo.By("Creating MCPServer with replicas=2, Redis session storage, and sessionAffinity=None") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: mcpServerName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: proxyPort, MCPPort: 8080, Replicas: &replicas, SessionAffinity: "None", - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: redisAddr, }, }, @@ -421,25 +421,25 @@ var _ = ginkgo.Describe("MCPServer Cross-Replica Session Routing with Redis", fu }) ginkgo.AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: mcpServerName, Namespace: defaultNamespace}, }) cleanupRedis(redisName) gomega.Eventually(func() bool { - err := k8sClient.Get(ctx, types.NamespacedName{Name: mcpServerName, Namespace: defaultNamespace}, &mcpv1alpha1.MCPServer{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: mcpServerName, Namespace: defaultNamespace}, &mcpv1beta1.MCPServer{}) return apierrors.IsNotFound(err) }, e2eTimeout, e2ePollInterval).Should(gomega.BeTrue()) }) ginkgo.It("Should have SessionStorageWarning=False since Redis is configured", func() { gomega.Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: mcpServerName, Namespace: defaultNamespace}, server); err != nil { return err } for _, cond := range server.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionSessionStorageWarning { + if cond.Type == mcpv1beta1.ConditionSessionStorageWarning { if string(cond.Status) == "False" { return nil } diff --git a/test/e2e/thv-operator/virtualmcp/suite_test.go b/test/e2e/thv-operator/virtualmcp/suite_test.go index ca7bb259cc..d9dd5da882 100644 --- a/test/e2e/thv-operator/virtualmcp/suite_test.go +++ b/test/e2e/thv-operator/virtualmcp/suite_test.go @@ -24,7 +24,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) // These tests use Ginkgo (BDD-style Go testing framework). Refer to @@ -80,7 +80,7 @@ var _ = ginkgo.BeforeSuite(func() { gomega.Expect(cfg).NotTo(gomega.BeNil()) // Register schemes - err = mcpv1alpha1.AddToScheme(scheme.Scheme) + err = mcpv1beta1.AddToScheme(scheme.Scheme) gomega.Expect(err).NotTo(gomega.HaveOccurred()) err = appsv1.AddToScheme(scheme.Scheme) @@ -132,7 +132,7 @@ func dumpK8sState(header string) { func dumpVirtualMCPServers(namespace string) { ginkgo.GinkgoWriter.Println("\n--- VirtualMCPServers ---") - vmcpList := &mcpv1alpha1.VirtualMCPServerList{} + vmcpList := &mcpv1beta1.VirtualMCPServerList{} if err := k8sClient.List(ctx, vmcpList, client.InNamespace(namespace)); err != nil { ginkgo.GinkgoWriter.Printf("Failed to list VirtualMCPServers: %v\n", err) return @@ -147,7 +147,7 @@ func dumpVirtualMCPServers(namespace string) { func dumpMCPServers(namespace string) { ginkgo.GinkgoWriter.Println("\n--- MCPServers ---") - mcpList := &mcpv1alpha1.MCPServerList{} + mcpList := &mcpv1beta1.MCPServerList{} if err := k8sClient.List(ctx, mcpList, client.InNamespace(namespace)); err != nil { ginkgo.GinkgoWriter.Printf("Failed to list MCPServers: %v\n", err) return diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_filtering_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_filtering_test.go index aa0e743cb0..452c4580e8 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_filtering_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_filtering_test.go @@ -14,7 +14,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -43,13 +43,13 @@ var _ = Describe("VirtualMCPServer Aggregation Filtering", Ordered, func() { }, timeout, pollingInterval) By("Creating VirtualMCPServer with tool filtering - only expose tools from backend1") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -67,7 +67,7 @@ var _ = Describe("VirtualMCPServer Aggregation Filtering", Ordered, func() { }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -86,7 +86,7 @@ var _ = Describe("VirtualMCPServer Aggregation Filtering", Ordered, func() { AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -96,7 +96,7 @@ var _ = Describe("VirtualMCPServer Aggregation Filtering", Ordered, func() { By("Cleaning up backend MCPServers") for _, backendName := range []string{backend1Name, backend2Name} { - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, @@ -106,7 +106,7 @@ var _ = Describe("VirtualMCPServer Aggregation Filtering", Ordered, func() { } By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, @@ -165,7 +165,7 @@ var _ = Describe("VirtualMCPServer Aggregation Filtering", Ordered, func() { Context("when verifying filtering configuration", func() { It("should have correct aggregation configuration with tool filters", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_overrides_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_overrides_test.go index bcd195f1d2..9aa103c0f2 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_overrides_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_aggregation_overrides_test.go @@ -13,7 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -44,13 +44,13 @@ var _ = Describe("VirtualMCPServer Tool Overrides", Ordered, func() { images.YardstickServerImage, timeout, pollingInterval) By("Creating VirtualMCPServer with tool overrides") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -72,7 +72,7 @@ var _ = Describe("VirtualMCPServer Tool Overrides", Ordered, func() { }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -91,7 +91,7 @@ var _ = Describe("VirtualMCPServer Tool Overrides", Ordered, func() { AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -100,7 +100,7 @@ var _ = Describe("VirtualMCPServer Tool Overrides", Ordered, func() { _ = k8sClient.Delete(ctx, vmcpServer) By("Cleaning up backend MCPServer") - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, @@ -109,7 +109,7 @@ var _ = Describe("VirtualMCPServer Tool Overrides", Ordered, func() { _ = k8sClient.Delete(ctx, backend) By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, @@ -178,7 +178,7 @@ var _ = Describe("VirtualMCPServer Tool Overrides", Ordered, func() { Context("when verifying override configuration", func() { It("should have correct aggregation configuration with tool overrides", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go index fef4d837ae..eef5c2c1ad 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_auth_discovery_test.go @@ -24,7 +24,7 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -693,17 +693,17 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: Expect(k8sClient.Create(ctx, oidcClientSecret)).To(Succeed()) By("Creating MCPOIDCConfig for OIDC authentication") - oidcConfig := &mcpv1alpha1.MCPOIDCConfig{ + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "discovery-oidc-config", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: fmt.Sprintf("http://mock-oidc-server.%s.svc.cluster.local", testNamespace), ClientID: "vmcp-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{Name: oidcClientSecretName, Key: "client-secret"}, + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: oidcClientSecretName, Key: "client-secret"}, InsecureAllowHTTP: true, JWKSAllowPrivateIP: true, ProtectedResourceAllowPrivateIP: true, @@ -717,17 +717,17 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: tokenURL := fmt.Sprintf("http://mock-auth-server.%s.svc.cluster.local/token", testNamespace) GinkgoWriter.Printf("Configuring token exchange to use mock server: %s\n", tokenURL) - authConfig1 := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig1 := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: authConfig1Name, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: tokenURL, ClientID: "test-client-id", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: authSecret1Name, Key: "client-secret", }, @@ -740,16 +740,16 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: Expect(k8sClient.Create(ctx, authConfig1)).To(Succeed()) By("Creating MCPExternalAuthConfig for header injection") - authConfig2 := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig2 := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: authConfig2Name, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: authSecret2Name, Key: "api-key", }, @@ -759,12 +759,12 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: Expect(k8sClient.Create(ctx, authConfig2)).To(Succeed()) By("Creating MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPGroupSpec{ + Spec: mcpv1beta1.MCPGroupSpec{ Description: "Test MCP Group for auth discovery E2E tests", }, } @@ -779,7 +779,7 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: if err != nil { return false } - return mcpGroup.Status.Phase == mcpv1alpha1.MCPGroupPhaseReady + return mcpGroup.Status.Phase == mcpv1beta1.MCPGroupPhaseReady }, timeout, pollingInterval).Should(BeTrue()) By("Creating backend MCPServers in parallel with different auth configurations") @@ -789,7 +789,7 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: Namespace: testNamespace, GroupRef: mcpGroupName, Image: images.GofetchServerImage, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: authConfig1Name, }, }, @@ -798,7 +798,7 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: Namespace: testNamespace, GroupRef: mcpGroupName, Image: images.OSVMCPServerImage, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: authConfig2Name, }, }, @@ -845,13 +845,13 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: podTemplateRaw, err := json.Marshal(podTemplateSpec) Expect(err).ToNot(HaveOccurred()) - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, // No TokenCache configured - tokens should be fetched on each request @@ -861,16 +861,16 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: }, // OIDC incoming auth - clients must present valid OIDC tokens // vMCP will validate tokens and then exchange them for backend-specific tokens - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: "discovery-oidc-config", Audience: "vmcp-audience", }, }, // DISCOVERED MODE: vMCP will discover outgoing auth from backend MCPServers // Backend has token exchange configured, vMCP will discover and use it - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, ServiceType: "NodePort", @@ -967,7 +967,7 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: WaitForPodDeletion(ctx, k8sClient, "mock-oidc-server", testNamespace, cleanupTimeout, pollingInterval) By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -977,7 +977,7 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: By("Cleaning up backend MCPServers") for _, backendName := range []string{backend1Name, backend2Name, backend3Name} { - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, @@ -988,7 +988,7 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: By("Cleaning up MCPExternalAuthConfigs") for _, authConfigName := range []string{authConfig1Name, authConfig2Name} { - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: authConfigName, Namespace: testNamespace, @@ -1009,7 +1009,7 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: } By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, @@ -1021,7 +1021,7 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: Context("when verifying discovered auth configuration", func() { It("should have correct discovered auth configuration with all backends and auth configs", func() { By("Verifying VirtualMCPServer has discovered auth mode") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -1042,25 +1042,25 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: Expect(backendNames).To(ContainElements(backend1Name, backend2Name, backend3Name)) By("Verifying ExternalAuthConfigRef on backends with auth") - backend1 := &mcpv1alpha1.MCPServer{} + backend1 := &mcpv1beta1.MCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backend1Name, Namespace: testNamespace}, backend1)).To(Succeed()) Expect(backend1.Spec.ExternalAuthConfigRef).ToNot(BeNil()) Expect(backend1.Spec.ExternalAuthConfigRef.Name).To(Equal(authConfig1Name)) - backend2 := &mcpv1alpha1.MCPServer{} + backend2 := &mcpv1beta1.MCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backend2Name, Namespace: testNamespace}, backend2)).To(Succeed()) Expect(backend2.Spec.ExternalAuthConfigRef).ToNot(BeNil()) Expect(backend2.Spec.ExternalAuthConfigRef.Name).To(Equal(authConfig2Name)) - backend3 := &mcpv1alpha1.MCPServer{} + backend3 := &mcpv1beta1.MCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backend3Name, Namespace: testNamespace}, backend3)).To(Succeed()) Expect(backend3.Spec.ExternalAuthConfigRef).To(BeNil()) By("Verifying token exchange MCPExternalAuthConfig") - authConfig1 := &mcpv1alpha1.MCPExternalAuthConfig{} + authConfig1 := &mcpv1beta1.MCPExternalAuthConfig{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: authConfig1Name, Namespace: testNamespace}, authConfig1)).To(Succeed()) expectedTokenURL := fmt.Sprintf("http://mock-auth-server.%s.svc.cluster.local/token", testNamespace) - Expect(authConfig1.Spec.Type).To(Equal(mcpv1alpha1.ExternalAuthTypeTokenExchange)) + Expect(authConfig1.Spec.Type).To(Equal(mcpv1beta1.ExternalAuthTypeTokenExchange)) Expect(authConfig1.Spec.TokenExchange).ToNot(BeNil()) Expect(authConfig1.Spec.TokenExchange.TokenURL).To(Equal(expectedTokenURL)) Expect(authConfig1.Spec.TokenExchange.ClientID).To(Equal("test-client-id")) @@ -1070,9 +1070,9 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: Expect(authConfig1.Spec.TokenExchange.ClientSecretRef.Key).To(Equal("client-secret")) By("Verifying header injection MCPExternalAuthConfig") - authConfig2 := &mcpv1alpha1.MCPExternalAuthConfig{} + authConfig2 := &mcpv1beta1.MCPExternalAuthConfig{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: authConfig2Name, Namespace: testNamespace}, authConfig2)).To(Succeed()) - Expect(authConfig2.Spec.Type).To(Equal(mcpv1alpha1.ExternalAuthTypeHeaderInjection)) + Expect(authConfig2.Spec.Type).To(Equal(mcpv1beta1.ExternalAuthTypeHeaderInjection)) Expect(authConfig2.Spec.HeaderInjection).ToNot(BeNil()) Expect(authConfig2.Spec.HeaderInjection.HeaderName).To(Equal("X-API-Key")) Expect(authConfig2.Spec.HeaderInjection.ValueSecretRef.Name).To(Equal(authSecret2Name)) @@ -1094,15 +1094,15 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: Context("when verifying VirtualMCPServer state", func() { It("should have VirtualMCPServer and all backends in ready state", func() { By("Verifying VirtualMCPServer is Ready") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: vmcpServerName, Namespace: testNamespace}, vmcpServer)).To(Succeed()) - Expect(vmcpServer.Status.Phase).To(Equal(mcpv1alpha1.VirtualMCPServerPhaseReady)) + Expect(vmcpServer.Status.Phase).To(Equal(mcpv1beta1.VirtualMCPServerPhaseReady)) By("Verifying all backends are Running") for _, backendName := range []string{backend1Name, backend2Name, backend3Name} { - backend := &mcpv1alpha1.MCPServer{} + backend := &mcpv1beta1.MCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backendName, Namespace: testNamespace}, backend)).To(Succeed()) - Expect(backend.Status.Phase).To(Equal(mcpv1alpha1.MCPServerPhaseReady)) + Expect(backend.Status.Phase).To(Equal(mcpv1beta1.MCPServerPhaseReady)) } GinkgoWriter.Println("Discovered auth mode successfully aggregates backends with:") @@ -1433,13 +1433,13 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { BeforeAll(func() { By("Creating a valid MCPExternalAuthConfig") - workingAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + workingAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: workingAuthConfigName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUnauthenticated, }, } Expect(k8sClient.Create(ctx, workingAuthConfig)).To(Succeed()) @@ -1450,21 +1450,21 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { By("Creating two backend MCPServers - one valid, one missing auth config") // Create valid backend - validBackend := &mcpv1alpha1.MCPServer{ + validBackend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendValidAuthName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: workingAuthConfigName, }, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "TRANSPORT", Value: "streamable-http"}, }, }, @@ -1472,22 +1472,22 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { Expect(k8sClient.Create(ctx, validBackend)).To(Succeed()) // Create backend with missing auth config (expected to fail) - invalidBackend := &mcpv1alpha1.MCPServer{ + invalidBackend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendMissingAuthName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, // Reference a non-existent auth config to trigger error - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: missingAuthConfigName, }, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "TRANSPORT", Value: "streamable-http"}, }, }, @@ -1501,7 +1501,7 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { // least one backend is ready before testing degraded mode behavior. By("Waiting for valid backend to become Running") Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: backendValidAuthName, Namespace: testNamespace, @@ -1509,25 +1509,25 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { if err != nil { return fmt.Errorf("failed to get server %s: %w", backendValidAuthName, err) } - if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("%s not ready yet, phase: %s", backendValidAuthName, server.Status.Phase) } return nil }, timeout, pollingInterval).Should(Succeed(), "Valid backend should become Running") By("Creating VirtualMCPServer with discovered auth mode") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, // Use discovered mode to trigger auth discovery - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, ServiceType: "NodePort", @@ -1541,25 +1541,25 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { AfterAll(func() { By("Cleaning up VirtualMCPServer") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpServerName, Namespace: testNamespace}, }) By("Cleaning up MCPServers") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendValidAuthName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendMissingAuthName, Namespace: testNamespace}, }) By("Cleaning up MCPGroup") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: testNamespace}, }) By("Cleaning up MCPExternalAuthConfig") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: workingAuthConfigName, Namespace: testNamespace}, }) }) @@ -1587,7 +1587,7 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { }) It("should report auth config errors via status conditions", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} By("Waiting for controller to set per-backend auth config conditions") Eventually(func() error { @@ -1644,7 +1644,7 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { }) It("should include only valid backend in discoveredBackends list", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} By("Checking discoveredBackends status field") Eventually(func() error { @@ -1683,7 +1683,7 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { It("should document MCPServer phases for both backends", func() { By("Checking MCPServer status for backend with valid auth config") - validServer := &mcpv1alpha1.MCPServer{} + validServer := &mcpv1beta1.MCPServer{} Eventually(func() error { return k8sClient.Get(ctx, types.NamespacedName{ Name: backendValidAuthName, @@ -1691,11 +1691,11 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { }, validServer) }, timeout, pollingInterval).Should(Succeed()) GinkgoWriter.Printf("Backend with valid auth config phase: %s\n", validServer.Status.Phase) - Expect(validServer.Status.Phase).To(Equal(mcpv1alpha1.MCPServerPhaseReady), + Expect(validServer.Status.Phase).To(Equal(mcpv1beta1.MCPServerPhaseReady), "Backend with valid auth config should reach Running phase") By("Checking MCPServer status for backend with missing auth config") - invalidServer := &mcpv1alpha1.MCPServer{} + invalidServer := &mcpv1beta1.MCPServer{} Eventually(func() error { return k8sClient.Get(ctx, types.NamespacedName{ Name: backendMissingAuthName, @@ -1720,7 +1720,7 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { }) It("should not set phase to Failed", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} By("Checking VirtualMCPServer phase") Eventually(func() error { @@ -1732,14 +1732,14 @@ var _ = Describe("Auth Config Error Handling", Ordered, func() { } // Phase should be Pending or Running, not Failed - if vmcpServer.Status.Phase == mcpv1alpha1.VirtualMCPServerPhaseFailed { + if vmcpServer.Status.Phase == mcpv1beta1.VirtualMCPServerPhaseFailed { return fmt.Errorf("VirtualMCPServer phase should not be Failed when continuing in degraded mode") } // Accept Pending, Ready, or Degraded - if vmcpServer.Status.Phase != mcpv1alpha1.VirtualMCPServerPhasePending && - vmcpServer.Status.Phase != mcpv1alpha1.VirtualMCPServerPhaseReady && - vmcpServer.Status.Phase != mcpv1alpha1.VirtualMCPServerPhaseDegraded { + if vmcpServer.Status.Phase != mcpv1beta1.VirtualMCPServerPhasePending && + vmcpServer.Status.Phase != mcpv1beta1.VirtualMCPServerPhaseReady && + vmcpServer.Status.Phase != mcpv1beta1.VirtualMCPServerPhaseDegraded { return fmt.Errorf("expected phase Pending, Ready, or Degraded, got %s", vmcpServer.Status.Phase) } diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_config_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_config_test.go index 7e6ebac341..87f8f681a4 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_config_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_config_test.go @@ -11,7 +11,7 @@ import ( . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) var _ = Describe("VirtualMCPServer AuthServerConfig Validation", Ordered, func() { @@ -30,7 +30,7 @@ var _ = Describe("VirtualMCPServer AuthServerConfig Validation", Ordered, func() AfterAll(func() { By("Cleaning up MCPGroup") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: testNamespace}, }) }) @@ -40,11 +40,11 @@ var _ = Describe("VirtualMCPServer AuthServerConfig Validation", Ordered, func() BeforeAll(func() { By("Creating MCPOIDCConfig for auth server test") - Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPOIDCConfig{ + Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: "authserver-oidc-config", Namespace: testNamespace}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "http://localhost:9090", InsecureAllowHTTP: true, }, @@ -52,28 +52,28 @@ var _ = Describe("VirtualMCPServer AuthServerConfig Validation", Ordered, func() })).To(Succeed()) By("Creating VirtualMCPServer with valid inline AuthServerConfig") - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: "authserver-oidc-config", // Audience must match the auth server's allowed audience (the vMCP service URL) Audience: fmt.Sprintf("http://%s.%s.svc.cluster.local:4483", vmcpName, testNamespace), }, }, - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, - AuthServerConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + AuthServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "http://localhost:9090", - UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ { Name: "test-provider", - Type: mcpv1alpha1.UpstreamProviderTypeOIDC, - OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{ + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ IssuerURL: "https://accounts.google.com", ClientID: "test-client-id", }, @@ -86,14 +86,14 @@ var _ = Describe("VirtualMCPServer AuthServerConfig Validation", Ordered, func() }) AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: testNamespace}, }) }) It("should set AuthServerConfigValidated condition to True", func() { WaitForCondition(ctx, k8sClient, vmcpName, testNamespace, - mcpv1alpha1.ConditionTypeAuthServerConfigValidated, "True", timeout, pollingInterval) + mcpv1beta1.ConditionTypeAuthServerConfigValidated, "True", timeout, pollingInterval) }) }) }) diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go index 7dc83c9a3c..12cebf47d2 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_circuit_breaker_test.go @@ -15,7 +15,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -46,18 +46,18 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { "Test MCP Group for circuit breaker E2E tests", timeout, pollingInterval) By("Creating stable backend MCPServer") - backend1 := &mcpv1alpha1.MCPServer{ + backend1 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backend1Name, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "TRANSPORT", Value: "streamable-http"}, }, }, @@ -65,18 +65,18 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { Expect(k8sClient.Create(ctx, backend1)).To(Succeed()) By("Creating unstable backend MCPServer (will be scaled down to simulate failure)") - backend2 := &mcpv1alpha1.MCPServer{ + backend2 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backend2Name, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "TRANSPORT", Value: "streamable-http"}, }, }, @@ -85,25 +85,25 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { By("Waiting for backend MCPServers to be running") Eventually(func() error { - server1 := &mcpv1alpha1.MCPServer{} + server1 := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backend1Name, Namespace: testNamespace, }, server1); err != nil { return err } - if server1.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server1.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("backend1 not running, phase: %s", server1.Status.Phase) } - server2 := &mcpv1alpha1.MCPServer{} + server2 := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backend2Name, Namespace: testNamespace, }, server2); err != nil { return err } - if server2.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server2.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("backend2 not running, phase: %s", server2.Status.Phase) } @@ -113,7 +113,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { AfterAll(func() { By("Cleaning up test resources") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -121,7 +121,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { Expect(k8sClient.Delete(ctx, vmcpServer)).To(Succeed()) } - backend1 := &mcpv1alpha1.MCPServer{} + backend1 := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backend1Name, Namespace: testNamespace, @@ -129,7 +129,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { Expect(k8sClient.Delete(ctx, backend1)).To(Succeed()) } - backend2 := &mcpv1alpha1.MCPServer{} + backend2 := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backend2Name, Namespace: testNamespace, @@ -137,7 +137,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { Expect(k8sClient.Delete(ctx, backend2)).To(Succeed()) } - group := &mcpv1alpha1.MCPGroup{} + group := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: testNamespace, @@ -148,17 +148,17 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { It("should configure circuit breaker from VirtualMCPServer spec", func() { By("Creating VirtualMCPServer with circuit breaker enabled") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, ServiceType: "NodePort", @@ -219,7 +219,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { It("should discover backends with healthy status initially", func() { By("Checking VirtualMCPServer status has discovered backends") Eventually(func() error { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -234,9 +234,9 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { // Check that backends are initially healthy or ready for _, backend := range vmcpServer.Status.DiscoveredBackends { // Initial status can be ready, degraded, or unknown (during startup) - if backend.Status != mcpv1alpha1.BackendStatusReady && - backend.Status != mcpv1alpha1.BackendStatusDegraded && - backend.Status != mcpv1alpha1.BackendStatusUnknown { + if backend.Status != mcpv1beta1.BackendStatusReady && + backend.Status != mcpv1beta1.BackendStatusDegraded && + backend.Status != mcpv1beta1.BackendStatusUnknown { return fmt.Errorf("backend %s has unexpected status: %s (message: %s)", backend.Name, backend.Status, backend.Message) } @@ -248,7 +248,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { It("should open circuit breaker when backend fails repeatedly", func() { By("Making unstable backend unavailable by changing to non-existent image") - backend := &mcpv1alpha1.MCPServer{} + backend := &mcpv1beta1.MCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: backend2Name, Namespace: testNamespace, @@ -284,7 +284,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { // Timeline: T=0 (check 1 starts), T=2s (fails), T=5s (check 2), T=7s (fails), T=10s (check 3), T=12s (fails) // Circuit opens after 3rd failure at ~12s. Eventually() polls until condition is met. Eventually(func() error { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -293,7 +293,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { } // Find the unstable backend - var unstableBackend *mcpv1alpha1.DiscoveredBackend + var unstableBackend *mcpv1beta1.DiscoveredBackend for i := range vmcpServer.Status.DiscoveredBackends { if vmcpServer.Status.DiscoveredBackends[i].Name == backend2Name { unstableBackend = &vmcpServer.Status.DiscoveredBackends[i] @@ -306,7 +306,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { } // Check backend is unavailable (unhealthy backends map to "unavailable" in CRD) - if unstableBackend.Status != mcpv1alpha1.BackendStatusUnavailable { + if unstableBackend.Status != mcpv1beta1.BackendStatusUnavailable { return fmt.Errorf("backend status is %s (expected unavailable), message: %s", unstableBackend.Status, unstableBackend.Message) } @@ -325,7 +325,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { By("Verifying VirtualMCPServer phase reflects backend failure") Eventually(func() error { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -334,8 +334,8 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { } // Phase should be Degraded (some backends unavailable) or Failed (all unavailable) - if vmcpServer.Status.Phase != mcpv1alpha1.VirtualMCPServerPhaseDegraded && - vmcpServer.Status.Phase != mcpv1alpha1.VirtualMCPServerPhaseFailed { + if vmcpServer.Status.Phase != mcpv1beta1.VirtualMCPServerPhaseDegraded && + vmcpServer.Status.Phase != mcpv1beta1.VirtualMCPServerPhaseFailed { return fmt.Errorf("expected phase Degraded or Failed, got: %s", vmcpServer.Status.Phase) } @@ -451,7 +451,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { It("should close circuit breaker when backend recovers", func() { By("Restoring unstable backend by fixing the image") - backend := &mcpv1alpha1.MCPServer{} + backend := &mcpv1beta1.MCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: backend2Name, Namespace: testNamespace, @@ -481,14 +481,14 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { // need to be recreated. Status reporting test intentionally skips recovery testing // for this reason, but circuit breaker recovery is a key feature we must verify. Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backend2Name, Namespace: testNamespace, }, server); err != nil { return err } - if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("backend not running yet, phase: %s", server.Status.Phase) } return nil @@ -502,7 +502,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { // 4. Close if healthy // We poll instead of sleeping to complete as soon as recovery happens Eventually(func() error { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -511,7 +511,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { } // Find the unstable backend - var unstableBackend *mcpv1alpha1.DiscoveredBackend + var unstableBackend *mcpv1beta1.DiscoveredBackend for i := range vmcpServer.Status.DiscoveredBackends { if vmcpServer.Status.DiscoveredBackends[i].Name == backend2Name { unstableBackend = &vmcpServer.Status.DiscoveredBackends[i] @@ -524,8 +524,8 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { } // Backend should be ready or degraded (recovering) - if unstableBackend.Status != mcpv1alpha1.BackendStatusReady && - unstableBackend.Status != mcpv1alpha1.BackendStatusDegraded { + if unstableBackend.Status != mcpv1beta1.BackendStatusReady && + unstableBackend.Status != mcpv1beta1.BackendStatusDegraded { return fmt.Errorf("backend status is still %s (expected ready/degraded after recovery), message: %s, circuitState: %s", unstableBackend.Status, unstableBackend.Message, unstableBackend.CircuitBreakerState) } @@ -544,7 +544,7 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { By("Verifying VirtualMCPServer phase returns to healthy state") Eventually(func() error { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -553,8 +553,8 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { } // Phase should return to Ready or Degraded (if still recovering) - if vmcpServer.Status.Phase != mcpv1alpha1.VirtualMCPServerPhaseReady && - vmcpServer.Status.Phase != mcpv1alpha1.VirtualMCPServerPhaseDegraded { + if vmcpServer.Status.Phase != mcpv1beta1.VirtualMCPServerPhaseReady && + vmcpServer.Status.Phase != mcpv1beta1.VirtualMCPServerPhaseDegraded { return fmt.Errorf("expected phase Ready or Degraded after recovery, got: %s (message: %s)", vmcpServer.Status.Phase, vmcpServer.Status.Message) } @@ -579,14 +579,14 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { It("should track circuit breaker state per backend independently", func() { By("Verifying stable backend remained healthy throughout test") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, }, vmcpServer)).To(Succeed()) // Find the stable backend - var stableBackend *mcpv1alpha1.DiscoveredBackend + var stableBackend *mcpv1beta1.DiscoveredBackend for i := range vmcpServer.Status.DiscoveredBackends { if vmcpServer.Status.DiscoveredBackends[i].Name == backend1Name { stableBackend = &vmcpServer.Status.DiscoveredBackends[i] @@ -598,8 +598,8 @@ var _ = Describe("VirtualMCPServer Circuit Breaker Lifecycle", Ordered, func() { // Stable backend should be ready or degraded (never unavailable) Expect(stableBackend.Status).To(Or( - Equal(mcpv1alpha1.BackendStatusReady), - Equal(mcpv1alpha1.BackendStatusDegraded)), + Equal(mcpv1beta1.BackendStatusReady), + Equal(mcpv1beta1.BackendStatusDegraded)), "stable backend should remain healthy, got status=%s message=%s", stableBackend.Status, stableBackend.Message) diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_defaultresults_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_defaultresults_test.go index 9a070dc958..3c36ae707a 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_defaultresults_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_defaultresults_test.go @@ -12,7 +12,7 @@ import ( . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" thvjson "github.com/stacklok/toolhive/pkg/json" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" @@ -42,13 +42,13 @@ var _ = Describe("VirtualMCPServer Composite Tool DefaultResults", Ordered, func images.YardstickServerImage, timeout, pollingInterval) By("Creating VirtualMCPServer with composite tool using defaultResults") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -104,7 +104,7 @@ var _ = Describe("VirtualMCPServer Composite Tool DefaultResults", Ordered, func }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -124,7 +124,7 @@ var _ = Describe("VirtualMCPServer Composite Tool DefaultResults", Ordered, func AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -133,7 +133,7 @@ var _ = Describe("VirtualMCPServer Composite Tool DefaultResults", Ordered, func _ = k8sClient.Delete(ctx, vmcpServer) By("Cleaning up backend MCPServer") - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, @@ -142,7 +142,7 @@ var _ = Describe("VirtualMCPServer Composite Tool DefaultResults", Ordered, func _ = k8sClient.Delete(ctx, backend) By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_hidden_tools_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_hidden_tools_test.go index 63a9d84709..bf9b5f9767 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_hidden_tools_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_hidden_tools_test.go @@ -14,7 +14,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" thvjson "github.com/stacklok/toolhive/pkg/json" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" @@ -60,13 +60,13 @@ var _ = Describe("VirtualMCPServer Composite with Hidden Backend Tools", Ordered images.YardstickServerImage, timeout, pollingInterval) By("Creating VirtualMCPServer with mixed ExcludeAll and Filter configuration") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -125,7 +125,7 @@ var _ = Describe("VirtualMCPServer Composite with Hidden Backend Tools", Ordered }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -144,7 +144,7 @@ var _ = Describe("VirtualMCPServer Composite with Hidden Backend Tools", Ordered AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -153,7 +153,7 @@ var _ = Describe("VirtualMCPServer Composite with Hidden Backend Tools", Ordered _ = k8sClient.Delete(ctx, vmcpServer) By("Cleaning up backend A MCPServer") - backendA := &mcpv1alpha1.MCPServer{ + backendA := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendAName, Namespace: testNamespace, @@ -162,7 +162,7 @@ var _ = Describe("VirtualMCPServer Composite with Hidden Backend Tools", Ordered _ = k8sClient.Delete(ctx, backendA) By("Cleaning up backend B MCPServer") - backendB := &mcpv1alpha1.MCPServer{ + backendB := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendBName, Namespace: testNamespace, @@ -171,7 +171,7 @@ var _ = Describe("VirtualMCPServer Composite with Hidden Backend Tools", Ordered _ = k8sClient.Delete(ctx, backendB) By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, @@ -258,7 +258,7 @@ var _ = Describe("VirtualMCPServer Composite with Hidden Backend Tools", Ordered Context("when verifying configuration", func() { It("should have correct ExcludeAll and Filter configuration", func() { - var vmcpServer mcpv1alpha1.VirtualMCPServer + var vmcpServer mcpv1beta1.VirtualMCPServer err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_parallel_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_parallel_test.go index 8e95003ef5..e9378b1b30 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_parallel_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_parallel_test.go @@ -13,7 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" thvjson "github.com/stacklok/toolhive/pkg/json" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" @@ -46,13 +46,13 @@ var _ = Describe("VirtualMCPServer Composite Parallel Workflow", Ordered, func() }, timeout, pollingInterval) By("Creating VirtualMCPServer with composite parallel workflow") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -108,7 +108,7 @@ var _ = Describe("VirtualMCPServer Composite Parallel Workflow", Ordered, func() }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -127,7 +127,7 @@ var _ = Describe("VirtualMCPServer Composite Parallel Workflow", Ordered, func() AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -137,7 +137,7 @@ var _ = Describe("VirtualMCPServer Composite Parallel Workflow", Ordered, func() By("Cleaning up backend MCPServers") for _, backendName := range []string{backend1Name, backend2Name} { - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, @@ -147,7 +147,7 @@ var _ = Describe("VirtualMCPServer Composite Parallel Workflow", Ordered, func() } By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, @@ -224,7 +224,7 @@ var _ = Describe("VirtualMCPServer Composite Parallel Workflow", Ordered, func() Context("when verifying parallel workflow configuration", func() { It("should have correct composite tool spec with parallel steps", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -258,7 +258,7 @@ var _ = Describe("VirtualMCPServer Composite Parallel Workflow", Ordered, func() }) It("should target different backends in parallel steps", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_referenced_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_referenced_test.go index 5e04bba2bf..cc0258a2b6 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_referenced_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_referenced_test.go @@ -13,7 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" thvjson "github.com/stacklok/toolhive/pkg/json" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" @@ -44,12 +44,12 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func images.YardstickServerImage, timeout, pollingInterval) By("Creating VirtualMCPCompositeToolDefinition") - compositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeToolDef := &mcpv1beta1.VirtualMCPCompositeToolDefinition{ ObjectMeta: metav1.ObjectMeta{ Name: compositeToolDefName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: compositeToolName, Description: "Echoes the input message twice in sequence (referenced)", @@ -91,7 +91,7 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func By("Verifying VirtualMCPCompositeToolDefinition was created") // If creation succeeded, the webhook validation passed (no controller sets status) Eventually(func() bool { - def := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{} + def := &mcpv1beta1.VirtualMCPCompositeToolDefinition{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: compositeToolDefName, Namespace: testNamespace, @@ -100,13 +100,13 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func }, 30*time.Second, pollingInterval).Should(BeTrue(), "VirtualMCPCompositeToolDefinition should exist") By("Creating VirtualMCPServer with referenced composite tool") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -119,7 +119,7 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -138,7 +138,7 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -147,7 +147,7 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func _ = k8sClient.Delete(ctx, vmcpServer) By("Cleaning up VirtualMCPCompositeToolDefinition") - compositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeToolDef := &mcpv1beta1.VirtualMCPCompositeToolDefinition{ ObjectMeta: metav1.ObjectMeta{ Name: compositeToolDefName, Namespace: testNamespace, @@ -156,7 +156,7 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func _ = k8sClient.Delete(ctx, compositeToolDef) By("Cleaning up backend MCPServer") - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, @@ -165,7 +165,7 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func _ = k8sClient.Delete(ctx, backend) By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, @@ -242,7 +242,7 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func Context("when verifying referenced composite tool configuration", func() { It("should have correct CompositeToolRefs in VirtualMCPServer", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -258,7 +258,7 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func }) It("should have correct composite tool definition stored", func() { - compositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{} + compositeToolDef := &mcpv1beta1.VirtualMCPCompositeToolDefinition{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: compositeToolDefName, Namespace: testNamespace, @@ -292,7 +292,7 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func }) It("should reflect referenced tool in VirtualMCPServer status", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -300,16 +300,16 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func Expect(err).ToNot(HaveOccurred()) // Check that VirtualMCPServer is in Ready phase - Expect(vmcpServer.Status.Phase).To(Equal(mcpv1alpha1.VirtualMCPServerPhaseReady), + Expect(vmcpServer.Status.Phase).To(Equal(mcpv1beta1.VirtualMCPServerPhaseReady), "VirtualMCPServer should be in Ready phase when using valid CompositeToolRefs") // Check for CompositeToolRefsValidated condition (if it exists) // Note: This condition might not always be set immediately for _, condition := range vmcpServer.Status.Conditions { - if condition.Type == mcpv1alpha1.ConditionTypeCompositeToolRefsValidated { + if condition.Type == mcpv1beta1.ConditionTypeCompositeToolRefsValidated { Expect(condition.Status).To(Equal(metav1.ConditionTrue), "CompositeToolRefs should be validated") - Expect(condition.Reason).To(Equal(mcpv1alpha1.ConditionReasonCompositeToolRefsValid)) + Expect(condition.Reason).To(Equal(mcpv1beta1.ConditionReasonCompositeToolRefsValid)) GinkgoWriter.Printf("Found CompositeToolRefsValidated condition: %s\n", condition.Message) break } diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_sequential_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_sequential_test.go index 33795aad9e..341baba83e 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_sequential_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_sequential_test.go @@ -13,7 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" thvjson "github.com/stacklok/toolhive/pkg/json" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" @@ -43,13 +43,13 @@ var _ = Describe("VirtualMCPServer Composite Sequential Workflow", Ordered, func images.YardstickServerImage, timeout, pollingInterval) By("Creating VirtualMCPServer with composite sequential workflow") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -93,7 +93,7 @@ var _ = Describe("VirtualMCPServer Composite Sequential Workflow", Ordered, func }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -112,7 +112,7 @@ var _ = Describe("VirtualMCPServer Composite Sequential Workflow", Ordered, func AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -121,7 +121,7 @@ var _ = Describe("VirtualMCPServer Composite Sequential Workflow", Ordered, func _ = k8sClient.Delete(ctx, vmcpServer) By("Cleaning up backend MCPServer") - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, @@ -130,7 +130,7 @@ var _ = Describe("VirtualMCPServer Composite Sequential Workflow", Ordered, func _ = k8sClient.Delete(ctx, backend) By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, @@ -207,7 +207,7 @@ var _ = Describe("VirtualMCPServer Composite Sequential Workflow", Ordered, func Context("when verifying composite tool configuration", func() { It("should have correct composite tool spec stored", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_validation_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_validation_test.go index 4a0c100429..ec10028738 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_validation_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_composite_validation_test.go @@ -12,7 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" thvjson "github.com/stacklok/toolhive/pkg/json" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" @@ -44,12 +44,12 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, images.YardstickServerImage, timeout, pollingInterval) By("Creating VirtualMCPCompositeToolDefinition with fromJson template function") - compositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeToolDef := &mcpv1beta1.VirtualMCPCompositeToolDefinition{ ObjectMeta: metav1.ObjectMeta{ Name: compositeToolDefName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPCompositeToolDefinitionSpec{ + Spec: mcpv1beta1.VirtualMCPCompositeToolDefinitionSpec{ CompositeToolConfig: vmcpconfig.CompositeToolConfig{ Name: "parse_json_workflow", Description: "Workflow that parses JSON text responses using fromJson", @@ -92,7 +92,7 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, By("Verifying VirtualMCPCompositeToolDefinition was created") Eventually(func() bool { - def := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{} + def := &mcpv1beta1.VirtualMCPCompositeToolDefinition{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: compositeToolDefName, Namespace: testNamespace, @@ -101,13 +101,13 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, }, 30*time.Second, pollingInterval).Should(BeTrue(), "VirtualMCPCompositeToolDefinition should exist") By("Creating VirtualMCPServer with referenced composite tool") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -119,7 +119,7 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -138,7 +138,7 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -147,7 +147,7 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, _ = k8sClient.Delete(ctx, vmcpServer) By("Cleaning up VirtualMCPCompositeToolDefinition") - compositeToolDef := &mcpv1alpha1.VirtualMCPCompositeToolDefinition{ + compositeToolDef := &mcpv1beta1.VirtualMCPCompositeToolDefinition{ ObjectMeta: metav1.ObjectMeta{ Name: compositeToolDefName, Namespace: testNamespace, @@ -156,7 +156,7 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, _ = k8sClient.Delete(ctx, compositeToolDef) By("Cleaning up backend MCPServer") - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, @@ -165,7 +165,7 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, _ = k8sClient.Delete(ctx, backend) By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, @@ -197,14 +197,14 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, }) It("should have VirtualMCPServer in Ready phase", func() { - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, }, vmcp) Expect(err).ToNot(HaveOccurred()) - Expect(vmcp.Status.Phase).To(Equal(mcpv1alpha1.VirtualMCPServerPhaseReady), + Expect(vmcp.Status.Phase).To(Equal(mcpv1beta1.VirtualMCPServerPhaseReady), "VirtualMCPServer should be Ready - if not, the validator may not recognize fromJson") }) }) diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_conflict_resolution_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_conflict_resolution_test.go index 625feb518f..9a42247838 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_conflict_resolution_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_conflict_resolution_test.go @@ -14,7 +14,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcp "github.com/stacklok/toolhive/pkg/vmcp" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" @@ -47,18 +47,18 @@ func setupConflictResolutionTest(setup conflictResolutionTestSetup) int32 { }, setup.timeout, setup.pollingInterval) By(fmt.Sprintf("Creating VirtualMCPServer: %s with %s conflict resolution", setup.vmcpName, setup.aggregation.ConflictResolution)) - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: setup.vmcpName, Namespace: setup.namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: setup.groupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: setup.groupName}, Config: vmcpconfig.Config{ Group: setup.groupName, Aggregation: setup.aggregation, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -79,7 +79,7 @@ func setupConflictResolutionTest(setup conflictResolutionTestSetup) int32 { // cleanupConflictResolutionTest cleans up VirtualMCPServer, backend MCPServers, and MCPGroup func cleanupConflictResolutionTest(groupName, vmcpName, backend1Name, backend2Name, namespace string) { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, @@ -89,7 +89,7 @@ func cleanupConflictResolutionTest(groupName, vmcpName, backend1Name, backend2Na By("Cleaning up backend MCPServers") for _, backendName := range []string{backend1Name, backend2Name} { - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: namespace, @@ -99,7 +99,7 @@ func cleanupConflictResolutionTest(groupName, vmcpName, backend1Name, backend2Na } By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: groupName, Namespace: namespace, @@ -378,7 +378,7 @@ var _ = Describe("VirtualMCPServer Conflict Resolution", Ordered, func() { }) It("should have correct priority configuration", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -467,7 +467,7 @@ var _ = Describe("VirtualMCPServer Conflict Resolution", Ordered, func() { }) It("should have correct manual configuration with overrides", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go index 57ee46d892..4adaa48515 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_discovered_mode_test.go @@ -19,7 +19,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -49,13 +49,13 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { "Test MCP Group for VirtualMCP discovered mode E2E tests", timeout, pollingInterval) By("Creating first backend MCPServer - fetch (streamable-http)") - backend1 := &mcpv1alpha1.MCPServer{ + backend1 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backend1Name, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -65,13 +65,13 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { Expect(k8sClient.Create(ctx, backend1)).To(Succeed()) By("Creating second backend MCPServer - osv (streamable-http)") - backend2 := &mcpv1alpha1.MCPServer{ + backend2 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backend2Name, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.OSVMCPServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -82,7 +82,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { By("Waiting for backend MCPServers to be ready") Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: backend1Name, Namespace: testNamespace, @@ -92,14 +92,14 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { } // Check for Running phase - if server.Status.Phase == mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase == mcpv1beta1.MCPServerPhaseReady { return nil } return fmt.Errorf("backend 1 not ready yet, phase: %s", server.Status.Phase) }, timeout, pollingInterval).Should(Succeed(), "Backend 1 should be ready") Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: backend2Name, Namespace: testNamespace, @@ -109,7 +109,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { } // Check for Running phase - if server.Status.Phase == mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase == mcpv1beta1.MCPServerPhaseReady { return nil } return fmt.Errorf("backend 2 not ready yet, phase: %s", server.Status.Phase) @@ -120,13 +120,13 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { By("Backend MCPServers are running (ClusterIP services)") By("Creating VirtualMCPServer in discovered mode") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, // Discovered mode is the default - tools from all backends in the group are automatically discovered @@ -134,7 +134,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { ConflictResolution: "prefix", // Use prefix strategy to avoid conflicts }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -159,7 +159,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -170,7 +170,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { } By("Cleaning up backend MCPServers") - backend1 := &mcpv1alpha1.MCPServer{ + backend1 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backend1Name, Namespace: testNamespace, @@ -180,7 +180,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { GinkgoWriter.Printf("Warning: failed to delete backend 1: %v\n", err) } - backend2 := &mcpv1alpha1.MCPServer{ + backend2 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backend2Name, Namespace: testNamespace, @@ -191,7 +191,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { } By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, @@ -392,7 +392,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { Context("when verifying discovered mode behavior", func() { It("should have correct aggregation configuration", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -424,7 +424,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { AfterAll(func() { // Clean up the dynamic backend - backend3 := &mcpv1alpha1.MCPServer{ + backend3 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backend3Name, Namespace: testNamespace, @@ -473,13 +473,13 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { It("should detect new backend and update tool list", func() { By("Adding third backend MCPServer") - backend3 := &mcpv1alpha1.MCPServer{ + backend3 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backend3Name, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -490,7 +490,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { By("Waiting for new backend to be ready") Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: backend3Name, Namespace: testNamespace, @@ -498,7 +498,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { if err != nil { return err } - if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("backend not ready, phase: %s", server.Status.Phase) } return nil @@ -595,7 +595,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { GinkgoWriter.Printf("Before removal: %d tools\n", toolCountBefore) By("Removing backend2 (osv)") - backend2 := &mcpv1alpha1.MCPServer{ + backend2 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backend2Name, Namespace: testNamespace, @@ -605,7 +605,7 @@ var _ = Describe("VirtualMCPServer Discovered Mode", Ordered, func() { By("Waiting for backend deletion") Eventually(func() bool { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: backend2Name, Namespace: testNamespace, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_excludeall_global_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_excludeall_global_test.go index c6d4b2591e..81d677e601 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_excludeall_global_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_excludeall_global_test.go @@ -13,7 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -42,13 +42,13 @@ var _ = Describe("VirtualMCPServer Global ExcludeAllTools", Ordered, func() { }, timeout, pollingInterval) By("Creating VirtualMCPServer with global excludeAllTools: true") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -57,7 +57,7 @@ var _ = Describe("VirtualMCPServer Global ExcludeAllTools", Ordered, func() { ExcludeAllTools: true, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -76,7 +76,7 @@ var _ = Describe("VirtualMCPServer Global ExcludeAllTools", Ordered, func() { AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -86,7 +86,7 @@ var _ = Describe("VirtualMCPServer Global ExcludeAllTools", Ordered, func() { By("Cleaning up backend MCPServers") for _, backendName := range []string{backend1Name, backend2Name} { - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, @@ -96,7 +96,7 @@ var _ = Describe("VirtualMCPServer Global ExcludeAllTools", Ordered, func() { } By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, @@ -142,7 +142,7 @@ var _ = Describe("VirtualMCPServer Global ExcludeAllTools", Ordered, func() { Context("when verifying excludeAllTools configuration", func() { It("should have correct aggregation configuration with excludeAllTools", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -162,7 +162,7 @@ var _ = Describe("VirtualMCPServer Global ExcludeAllTools", Ordered, func() { // Verify each backend is running for _, backend := range backends { - Expect(backend.Status.Phase).To(Equal(mcpv1alpha1.MCPServerPhaseReady), + Expect(backend.Status.Phase).To(Equal(mcpv1beta1.MCPServerPhaseReady), fmt.Sprintf("Backend %s should be running", backend.Name)) } diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go index 161c31255e..9e7f3a6ea2 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go @@ -15,7 +15,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -42,13 +42,13 @@ var _ = Describe("VirtualMCPServer Unauthenticated Backend Auth", Ordered, func( mockHTTPServer = CreateMockHTTPServer(ctx, k8sClient, "mock-http-unauth", testNamespace, timeout, pollingInterval) By("Creating MCPExternalAuthConfig with unauthenticated type") - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: externalAuthConfigName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUnauthenticated, // No TokenExchange or HeaderInjection fields needed }, } @@ -59,19 +59,19 @@ var _ = Describe("VirtualMCPServer Unauthenticated Backend Auth", Ordered, func( "Test MCP Group for VirtualMCP unauthenticated auth", timeout, pollingInterval) By("Creating backend MCPServer without auth (localhost, trusted)") - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, // Reference the unauthenticated external auth config - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: externalAuthConfigName, }, }, @@ -80,7 +80,7 @@ var _ = Describe("VirtualMCPServer Unauthenticated Backend Auth", Ordered, func( By("Waiting for backend MCPServer to be ready") Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: backendName, Namespace: testNamespace, @@ -88,24 +88,24 @@ var _ = Describe("VirtualMCPServer Unauthenticated Backend Auth", Ordered, func( if err != nil { return fmt.Errorf("failed to get server: %w", err) } - if server.Status.Phase == mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase == mcpv1beta1.MCPServerPhaseReady { return nil } return fmt.Errorf("backend not ready yet, phase: %s", server.Status.Phase) }, timeout, pollingInterval).Should(Succeed()) By("Creating VirtualMCPServer with discovered auth mode (should use unauthenticated)") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", // Will discover unauthenticated from backend }, ServiceType: "NodePort", @@ -127,16 +127,16 @@ var _ = Describe("VirtualMCPServer Unauthenticated Backend Auth", Ordered, func( AfterAll(func() { By("Cleaning up test resources") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpServerName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: externalAuthConfigName, Namespace: testNamespace}, }) @@ -147,7 +147,7 @@ var _ = Describe("VirtualMCPServer Unauthenticated Backend Auth", Ordered, func( Context("when using unauthenticated backend auth", func() { It("should discover, validate, and successfully use unauthenticated backend auth", func() { By("Verifying VirtualMCPServer discovered unauthenticated auth") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: vmcpServerName, Namespace: testNamespace}, vmcpServer)).To(Succeed()) Expect(vmcpServer.Spec.OutgoingAuth.Source).To(Equal("discovered")) Expect(vmcpServer.Status.DiscoveredBackends).ToNot(BeEmpty()) @@ -163,9 +163,9 @@ var _ = Describe("VirtualMCPServer Unauthenticated Backend Auth", Ordered, func( Expect(found).To(BeTrue(), "Backend should be discovered with auth config reference") By("Validating MCPExternalAuthConfig") - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + authConfig := &mcpv1beta1.MCPExternalAuthConfig{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: externalAuthConfigName, Namespace: testNamespace}, authConfig)).To(Succeed()) - Expect(authConfig.Spec.Type).To(Equal(mcpv1alpha1.ExternalAuthTypeUnauthenticated)) + Expect(authConfig.Spec.Type).To(Equal(mcpv1beta1.ExternalAuthTypeUnauthenticated)) Expect(authConfig.Spec.TokenExchange).To(BeNil()) Expect(authConfig.Spec.HeaderInjection).To(BeNil()) @@ -218,13 +218,13 @@ var _ = Describe("VirtualMCPServer Inline Unauthenticated Backend Auth", Ordered BeforeAll(func() { By("Creating MCPExternalAuthConfig with unauthenticated type for inline mode") - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: externalAuthConfigName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeUnauthenticated, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeUnauthenticated, }, } Expect(k8sClient.Create(ctx, externalAuthConfig)).To(Succeed()) @@ -234,13 +234,13 @@ var _ = Describe("VirtualMCPServer Inline Unauthenticated Backend Auth", Ordered "Test MCP Group for inline unauthenticated auth", timeout, pollingInterval) By("Creating backend MCPServer") - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -251,7 +251,7 @@ var _ = Describe("VirtualMCPServer Inline Unauthenticated Backend Auth", Ordered By("Waiting for backend MCPServer to be ready") Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: backendName, Namespace: testNamespace, @@ -259,30 +259,30 @@ var _ = Describe("VirtualMCPServer Inline Unauthenticated Backend Auth", Ordered if err != nil { return fmt.Errorf("failed to get server: %w", err) } - if server.Status.Phase == mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase == mcpv1beta1.MCPServerPhaseReady { return nil } return fmt.Errorf("backend not ready yet, phase: %s", server.Status.Phase) }, timeout, pollingInterval).Should(Succeed()) By("Creating VirtualMCPServer with inline unauthenticated auth") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "inline", // Explicitly configure unauthenticated for specific backend - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ backendName: { Type: "externalAuthConfigRef", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: externalAuthConfigName, }, }, @@ -307,16 +307,16 @@ var _ = Describe("VirtualMCPServer Inline Unauthenticated Backend Auth", Ordered AfterAll(func() { By("Cleaning up test resources") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpServerName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: externalAuthConfigName, Namespace: testNamespace}, }) }) @@ -324,7 +324,7 @@ var _ = Describe("VirtualMCPServer Inline Unauthenticated Backend Auth", Ordered Context("when using inline unauthenticated backend auth", func() { It("should configure and successfully use inline unauthenticated backend auth", func() { By("Verifying VirtualMCPServer has inline auth configured") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: vmcpServerName, Namespace: testNamespace}, vmcpServer)).To(Succeed()) Expect(vmcpServer.Spec.OutgoingAuth.Source).To(Equal("inline")) Expect(vmcpServer.Spec.OutgoingAuth.Backends).To(HaveKey(backendName)) @@ -387,16 +387,16 @@ var _ = Describe("VirtualMCPServer HeaderInjection Backend Auth", Ordered, func( Expect(k8sClient.Create(ctx, secret)).To(Succeed()) By("Creating MCPExternalAuthConfig with headerInjection type") - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: externalAuthConfigName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: secretName, Key: "api-key", }, @@ -410,19 +410,19 @@ var _ = Describe("VirtualMCPServer HeaderInjection Backend Auth", Ordered, func( "Test MCP Group for VirtualMCP headerInjection auth", timeout, pollingInterval) By("Creating backend MCPServer with headerInjection auth") - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, // Reference the headerInjection external auth config - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: externalAuthConfigName, }, }, @@ -431,7 +431,7 @@ var _ = Describe("VirtualMCPServer HeaderInjection Backend Auth", Ordered, func( By("Waiting for backend MCPServer to be ready") Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: backendName, Namespace: testNamespace, @@ -439,24 +439,24 @@ var _ = Describe("VirtualMCPServer HeaderInjection Backend Auth", Ordered, func( if err != nil { return fmt.Errorf("failed to get server: %w", err) } - if server.Status.Phase == mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase == mcpv1beta1.MCPServerPhaseReady { return nil } return fmt.Errorf("backend not ready yet, phase: %s", server.Status.Phase) }, timeout, pollingInterval).Should(Succeed()) By("Creating VirtualMCPServer with discovered auth mode (should use headerInjection)") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", // Will discover headerInjection from backend }, ServiceType: "NodePort", @@ -478,16 +478,16 @@ var _ = Describe("VirtualMCPServer HeaderInjection Backend Auth", Ordered, func( AfterAll(func() { By("Cleaning up test resources") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpServerName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: externalAuthConfigName, Namespace: testNamespace}, }) _ = k8sClient.Delete(ctx, &corev1.Secret{ @@ -500,7 +500,7 @@ var _ = Describe("VirtualMCPServer HeaderInjection Backend Auth", Ordered, func( Context("when using headerInjection backend auth", func() { It("should discover, validate, and successfully use headerInjection backend auth", func() { By("Verifying VirtualMCPServer discovered headerInjection auth") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: vmcpServerName, Namespace: testNamespace}, vmcpServer)).To(Succeed()) Expect(vmcpServer.Spec.OutgoingAuth.Source).To(Equal("discovered")) Expect(vmcpServer.Status.DiscoveredBackends).ToNot(BeEmpty()) @@ -516,9 +516,9 @@ var _ = Describe("VirtualMCPServer HeaderInjection Backend Auth", Ordered, func( Expect(found).To(BeTrue(), "Backend should be discovered with auth config reference") By("Validating MCPExternalAuthConfig") - authConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + authConfig := &mcpv1beta1.MCPExternalAuthConfig{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: externalAuthConfigName, Namespace: testNamespace}, authConfig)).To(Succeed()) - Expect(authConfig.Spec.Type).To(Equal(mcpv1alpha1.ExternalAuthTypeHeaderInjection)) + Expect(authConfig.Spec.Type).To(Equal(mcpv1beta1.ExternalAuthTypeHeaderInjection)) Expect(authConfig.Spec.TokenExchange).To(BeNil()) Expect(authConfig.Spec.HeaderInjection).ToNot(BeNil()) Expect(authConfig.Spec.HeaderInjection.HeaderName).To(Equal("X-API-Key")) @@ -610,16 +610,16 @@ var _ = Describe("VirtualMCPServer Inline HeaderInjection Backend Auth", Ordered Expect(k8sClient.Create(ctx, secret)).To(Succeed()) By("Creating MCPExternalAuthConfig with headerInjection type for inline mode") - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: externalAuthConfigName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-Custom-Auth", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: secretName, Key: "api-key", }, @@ -633,13 +633,13 @@ var _ = Describe("VirtualMCPServer Inline HeaderInjection Backend Auth", Ordered "Test MCP Group for inline headerInjection auth", timeout, pollingInterval) By("Creating backend MCPServer") - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -650,7 +650,7 @@ var _ = Describe("VirtualMCPServer Inline HeaderInjection Backend Auth", Ordered By("Waiting for backend MCPServer to be ready") Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: backendName, Namespace: testNamespace, @@ -658,30 +658,30 @@ var _ = Describe("VirtualMCPServer Inline HeaderInjection Backend Auth", Ordered if err != nil { return fmt.Errorf("failed to get server: %w", err) } - if server.Status.Phase == mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase == mcpv1beta1.MCPServerPhaseReady { return nil } return fmt.Errorf("backend not ready yet, phase: %s", server.Status.Phase) }, timeout, pollingInterval).Should(Succeed()) By("Creating VirtualMCPServer with inline headerInjection auth") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "inline", // Explicitly configure headerInjection for specific backend - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ backendName: { Type: "externalAuthConfigRef", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: externalAuthConfigName, }, }, @@ -706,16 +706,16 @@ var _ = Describe("VirtualMCPServer Inline HeaderInjection Backend Auth", Ordered AfterAll(func() { By("Cleaning up test resources") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpServerName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: externalAuthConfigName, Namespace: testNamespace}, }) _ = k8sClient.Delete(ctx, &corev1.Secret{ @@ -726,7 +726,7 @@ var _ = Describe("VirtualMCPServer Inline HeaderInjection Backend Auth", Ordered Context("when using inline headerInjection backend auth", func() { It("should configure and successfully use inline headerInjection backend auth", func() { By("Verifying VirtualMCPServer has inline auth configured") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: vmcpServerName, Namespace: testNamespace}, vmcpServer)).To(Succeed()) Expect(vmcpServer.Spec.OutgoingAuth.Source).To(Equal("inline")) Expect(vmcpServer.Spec.OutgoingAuth.Backends).To(HaveKey(backendName)) @@ -801,16 +801,16 @@ var _ = Describe("VirtualMCPServer Health Check with HeaderInjection Auth", Orde Expect(k8sClient.Create(ctx, secret)).To(Succeed()) By("Creating MCPExternalAuthConfig with headerInjection type") - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: externalAuthConfigName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "X-API-Key", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: secretName, Key: "api-key", }, @@ -824,21 +824,21 @@ var _ = Describe("VirtualMCPServer Health Check with HeaderInjection Auth", Orde "Test group for health check headerInjection auth", timeout, pollingInterval) By("Creating backend MCPServer with headerInjection auth ref") - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "TRANSPORT", Value: "streamable-http"}, }, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: externalAuthConfigName, }, }, @@ -847,27 +847,27 @@ var _ = Describe("VirtualMCPServer Health Check with HeaderInjection Auth", Orde By("Waiting for backend MCPServer to be ready") Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backendName, Namespace: testNamespace, }, server); err != nil { return fmt.Errorf("failed to get server: %w", err) } - if server.Status.Phase == mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase == mcpv1beta1.MCPServerPhaseReady { return nil } return fmt.Errorf("backend not ready yet, phase: %s", server.Status.Phase) }, timeout, pollingInterval).Should(Succeed()) By("Creating VirtualMCPServer with discovered auth and health monitoring enabled") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Operational: &vmcpconfig.OperationalConfig{ @@ -879,10 +879,10 @@ var _ = Describe("VirtualMCPServer Health Check with HeaderInjection Auth", Orde }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, ServiceType: "NodePort", @@ -902,16 +902,16 @@ var _ = Describe("VirtualMCPServer Health Check with HeaderInjection Auth", Orde AfterAll(func() { By("Cleaning up test resources") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpServerName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: externalAuthConfigName, Namespace: testNamespace}, }) _ = k8sClient.Delete(ctx, &corev1.Secret{ @@ -929,7 +929,7 @@ var _ = Describe("VirtualMCPServer Health Check with HeaderInjection Auth", Orde // this window. By("Verifying backend status never becomes unavailable over several health check cycles") Consistently(func() bool { - server := &mcpv1alpha1.VirtualMCPServer{} + server := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -938,7 +938,7 @@ var _ = Describe("VirtualMCPServer Health Check with HeaderInjection Auth", Orde } for _, b := range server.Status.DiscoveredBackends { if b.Name == backendName { - return b.Status == mcpv1alpha1.BackendStatusReady || b.Status == mcpv1alpha1.BackendStatusDegraded + return b.Status == mcpv1beta1.BackendStatusReady || b.Status == mcpv1beta1.BackendStatusDegraded } } // BackendsDiscovered=True was confirmed in BeforeAll, so the backend @@ -1016,17 +1016,17 @@ var _ = Describe("VirtualMCPServer Health Check with TokenExchange Auth", Ordere Expect(k8sClient.Create(ctx, secret)).To(Succeed()) By("Creating MCPExternalAuthConfig with tokenExchange type") - externalAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{ + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: externalAuthConfigName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeTokenExchange, - TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ TokenURL: oauth2TokenURL, ClientID: "test-client", - ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ Name: secretName, Key: "client-secret", }, @@ -1041,21 +1041,21 @@ var _ = Describe("VirtualMCPServer Health Check with TokenExchange Auth", Ordere "Test group for health check tokenExchange auth", timeout, pollingInterval) By("Creating backend MCPServer with tokenExchange auth ref") - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "TRANSPORT", Value: "streamable-http"}, }, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: externalAuthConfigName, }, }, @@ -1064,27 +1064,27 @@ var _ = Describe("VirtualMCPServer Health Check with TokenExchange Auth", Ordere By("Waiting for backend MCPServer to be ready") Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backendName, Namespace: testNamespace, }, server); err != nil { return fmt.Errorf("failed to get server: %w", err) } - if server.Status.Phase == mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase == mcpv1beta1.MCPServerPhaseReady { return nil } return fmt.Errorf("backend not ready yet, phase: %s", server.Status.Phase) }, timeout, pollingInterval).Should(Succeed()) By("Creating VirtualMCPServer with discovered auth and health monitoring enabled") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Operational: &vmcpconfig.OperationalConfig{ @@ -1095,10 +1095,10 @@ var _ = Describe("VirtualMCPServer Health Check with TokenExchange Auth", Ordere }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, ServiceType: "NodePort", @@ -1115,16 +1115,16 @@ var _ = Describe("VirtualMCPServer Health Check with TokenExchange Auth", Ordere AfterAll(func() { By("Cleaning up test resources") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpServerName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: testNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPExternalAuthConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{Name: externalAuthConfigName, Namespace: testNamespace}, }) _ = k8sClient.Delete(ctx, &corev1.Secret{ @@ -1157,7 +1157,7 @@ var _ = Describe("VirtualMCPServer Health Check with TokenExchange Auth", Ordere // Consistently to confirm the backend stays healthy over further cycles. By("Verifying backend status never becomes unavailable over several health check cycles") Consistently(func() bool { - server := &mcpv1alpha1.VirtualMCPServer{} + server := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -1166,7 +1166,7 @@ var _ = Describe("VirtualMCPServer Health Check with TokenExchange Auth", Ordere } for _, b := range server.Status.DiscoveredBackends { if b.Name == backendName { - return b.Status == mcpv1alpha1.BackendStatusReady || b.Status == mcpv1alpha1.BackendStatusDegraded + return b.Status == mcpv1beta1.BackendStatusReady || b.Status == mcpv1beta1.BackendStatusDegraded } } // BackendsDiscovered=True was confirmed in BeforeAll, so the backend diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go index c9035547a4..1886fa492a 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_circuit_breaker_test.go @@ -15,7 +15,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -47,12 +47,12 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun mcpGroupName, images.YardstickServerImage, timeout, pollingInterval) By("Creating EmbeddingServer for optimizer") - embeddingServer := &mcpv1alpha1.EmbeddingServer{ + embeddingServer := &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: embeddingName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "BAAI/bge-small-en-v1.5", Image: images.TextEmbeddingsInferenceImage, }, @@ -60,21 +60,21 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun Expect(k8sClient.Create(ctx, embeddingServer)).To(Succeed()) By("Creating VirtualMCPServer with optimizer and circuit breaker enabled") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, ServiceType: "NodePort", - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{ Name: embeddingName, }, Config: vmcpconfig.Config{ @@ -110,7 +110,7 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun AfterAll(func() { By("Cleaning up test resources") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -118,7 +118,7 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun _ = k8sClient.Delete(ctx, vmcpServer) } - es := &mcpv1alpha1.EmbeddingServer{} + es := &mcpv1beta1.EmbeddingServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: embeddingName, Namespace: testNamespace, @@ -126,7 +126,7 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun _ = k8sClient.Delete(ctx, es) } - stableBackend := &mcpv1alpha1.MCPServer{} + stableBackend := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: stableName, Namespace: testNamespace, @@ -134,7 +134,7 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun _ = k8sClient.Delete(ctx, stableBackend) } - unstableBackend := &mcpv1alpha1.MCPServer{} + unstableBackend := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: unstableName, Namespace: testNamespace, @@ -142,7 +142,7 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun _ = k8sClient.Delete(ctx, unstableBackend) } - mcpGroup := &mcpv1alpha1.MCPGroup{} + mcpGroup := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: testNamespace, @@ -202,7 +202,7 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun It("should exclude unhealthy backend tools from optimizer after circuit breaker opens", func() { By("Making unstable backend unavailable by changing to non-existent image") - backend := &mcpv1alpha1.MCPServer{} + backend := &mcpv1beta1.MCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: unstableName, Namespace: testNamespace, @@ -231,7 +231,7 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun By("Waiting for circuit breaker to open for unstable backend") Eventually(func() error { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -288,7 +288,7 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun It("should restore backend tools in optimizer after circuit breaker recovers", func() { By("Restoring unstable backend by fixing the image") - backend := &mcpv1alpha1.MCPServer{} + backend := &mcpv1beta1.MCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: unstableName, Namespace: testNamespace, @@ -313,14 +313,14 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun By("Waiting for backend to become running again") Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: unstableName, Namespace: testNamespace, }, server); err != nil { return err } - if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("backend not running yet, phase: %s", server.Status.Phase) } return nil @@ -328,7 +328,7 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun By("Waiting for circuit breaker to close after recovery") Eventually(func() error { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -340,8 +340,8 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun if vmcpServer.Status.DiscoveredBackends[i].Name == unstableName { backend := &vmcpServer.Status.DiscoveredBackends[i] if backend.CircuitBreakerState == "closed" && - (backend.Status == mcpv1alpha1.BackendStatusReady || - backend.Status == mcpv1alpha1.BackendStatusDegraded) { + (backend.Status == mcpv1beta1.BackendStatusReady || + backend.Status == mcpv1beta1.BackendStatusDegraded) { GinkgoWriter.Printf("Backend recovered: status=%s, circuitState=%s\n", backend.Status, backend.CircuitBreakerState) return nil @@ -392,13 +392,13 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun It("should not affect stable backend throughout circuit breaker lifecycle", func() { By("Verifying stable backend remained healthy throughout the test") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} Expect(k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, }, vmcpServer)).To(Succeed()) - var stableBackend *mcpv1alpha1.DiscoveredBackend + var stableBackend *mcpv1beta1.DiscoveredBackend for i := range vmcpServer.Status.DiscoveredBackends { if vmcpServer.Status.DiscoveredBackends[i].Name == stableName { stableBackend = &vmcpServer.Status.DiscoveredBackends[i] @@ -409,8 +409,8 @@ var _ = Describe("VirtualMCPServer Optimizer with Circuit Breaker", Ordered, fun Expect(stableBackend).NotTo(BeNil(), "stable backend should be discovered") Expect(stableBackend.Status).To(Or( - Equal(mcpv1alpha1.BackendStatusReady), - Equal(mcpv1alpha1.BackendStatusDegraded)), + Equal(mcpv1beta1.BackendStatusReady), + Equal(mcpv1beta1.BackendStatusDegraded)), "stable backend should remain healthy, got status=%s message=%s", stableBackend.Status, stableBackend.Message) diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_composite_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_composite_test.go index 0b4e344e47..df53ecd95a 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_composite_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_composite_test.go @@ -14,7 +14,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" thvjson "github.com/stacklok/toolhive/pkg/json" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" @@ -78,18 +78,18 @@ var _ = Describe("VirtualMCPServer Optimizer Composite Tools", Ordered, func() { // original backend capability name via the dot convention. fetchStepTool := backendName + "." + backendFetchToolName // "backend-opt-composite.fetch" - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, ServiceType: "NodePort", - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, // Use embeddingService directly instead of EmbeddingServerRef @@ -159,7 +159,7 @@ var _ = Describe("VirtualMCPServer Optimizer Composite Tools", Ordered, func() { AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -168,7 +168,7 @@ var _ = Describe("VirtualMCPServer Optimizer Composite Tools", Ordered, func() { } By("Cleaning up backend MCPServer") - backend := &mcpv1alpha1.MCPServer{} + backend := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backendName, Namespace: testNamespace, @@ -177,7 +177,7 @@ var _ = Describe("VirtualMCPServer Optimizer Composite Tools", Ordered, func() { } By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{} + mcpGroup := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: testNamespace, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_multibackend_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_multibackend_test.go index dc48b99c27..00fdb05460 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_multibackend_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_multibackend_test.go @@ -15,7 +15,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcp "github.com/stacklok/toolhive/pkg/vmcp" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" @@ -83,7 +83,7 @@ var _ = Describe("VirtualMCPServer Optimizer Multi-Backend", Ordered, func() { Name: backend4Name, Namespace: testNamespace, GroupRef: mcpGroupName, Image: images.GitHubMCPServerImage, // 41 tools Transport: "stdio", - Secrets: []mcpv1alpha1.SecretRef{ + Secrets: []mcpv1beta1.SecretRef{ {Name: githubSecretName, Key: "token", TargetEnvName: "GITHUB_PERSONAL_ACCESS_TOKEN"}, }, }, @@ -91,7 +91,7 @@ var _ = Describe("VirtualMCPServer Optimizer Multi-Backend", Ordered, func() { Name: backend5Name, Namespace: testNamespace, GroupRef: mcpGroupName, Image: images.TerraformMCPServerImage, // 9 tools Transport: "streamable-http", - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "TRANSPORT_MODE", Value: "streamable-http"}, {Name: "TRANSPORT_HOST", Value: "0.0.0.0"}, }, @@ -125,7 +125,7 @@ var _ = Describe("VirtualMCPServer Optimizer Multi-Backend", Ordered, func() { Name: backend11Name, Namespace: testNamespace, GroupRef: mcpGroupName, Image: images.PagerDutyMCPServerImage, // 64 tools Transport: "stdio", - Secrets: []mcpv1alpha1.SecretRef{ + Secrets: []mcpv1beta1.SecretRef{ {Name: pagerdutySecretName, Key: "token", TargetEnvName: "PAGERDUTY_USER_API_KEY"}, }, }, @@ -164,12 +164,12 @@ var _ = Describe("VirtualMCPServer Optimizer Multi-Backend", Ordered, func() { CreateMultipleMCPServersInParallel(ctx, k8sClient, allBackends, timeout, pollingInterval) By("Creating EmbeddingServer for optimizer multi-backend") - embeddingServer := &mcpv1alpha1.EmbeddingServer{ + embeddingServer := &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: embeddingName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "BAAI/bge-small-en-v1.5", Image: images.TextEmbeddingsInferenceImage, }, @@ -177,21 +177,21 @@ var _ = Describe("VirtualMCPServer Optimizer Multi-Backend", Ordered, func() { Expect(k8sClient.Create(ctx, embeddingServer)).To(Succeed()) By("Creating VirtualMCPServer with optimizer enabled and prefix aggregation") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, ServiceType: "NodePort", - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{ Name: embeddingName, }, Config: vmcpconfig.Config{ @@ -218,7 +218,7 @@ var _ = Describe("VirtualMCPServer Optimizer Multi-Backend", Ordered, func() { AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -227,7 +227,7 @@ var _ = Describe("VirtualMCPServer Optimizer Multi-Backend", Ordered, func() { } By("Cleaning up EmbeddingServer") - es := &mcpv1alpha1.EmbeddingServer{} + es := &mcpv1beta1.EmbeddingServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: embeddingName, Namespace: testNamespace, @@ -237,7 +237,7 @@ var _ = Describe("VirtualMCPServer Optimizer Multi-Backend", Ordered, func() { By("Cleaning up backend MCPServers") for _, backend := range allBackends { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backend.Name, Namespace: testNamespace, @@ -247,7 +247,7 @@ var _ = Describe("VirtualMCPServer Optimizer Multi-Backend", Ordered, func() { } By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{} + mcpGroup := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: testNamespace, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_test.go index 9023555c9a..50b80a69fd 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_optimizer_test.go @@ -14,7 +14,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" thvjson "github.com/stacklok/toolhive/pkg/json" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" @@ -50,12 +50,12 @@ var _ = Describe("VirtualMCPServer Optimizer Mode", Ordered, func() { mcpGroupName, images.GofetchServerImage, timeout, pollingInterval) By("Creating EmbeddingServer for optimizer") - embeddingServer := &mcpv1alpha1.EmbeddingServer{ + embeddingServer := &mcpv1beta1.EmbeddingServer{ ObjectMeta: metav1.ObjectMeta{ Name: embeddingName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.EmbeddingServerSpec{ + Spec: mcpv1beta1.EmbeddingServerSpec{ Model: "BAAI/bge-small-en-v1.5", Image: images.TextEmbeddingsInferenceImage, }, @@ -76,23 +76,23 @@ var _ = Describe("VirtualMCPServer Optimizer Mode", Ordered, func() { // vmcpFetchToolName for clients, but steps must reference the original. fetchStepTool := backendName + "." + backendFetchToolName // "backend-optimizer-fetch.fetch" - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, ServiceType: "NodePort", - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, // Reference to the standalone EmbeddingServer created above. // The controller auto-populates optimizer.embeddingService from EmbeddingServer status. - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{ Name: embeddingName, }, @@ -160,7 +160,7 @@ var _ = Describe("VirtualMCPServer Optimizer Mode", Ordered, func() { AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -169,7 +169,7 @@ var _ = Describe("VirtualMCPServer Optimizer Mode", Ordered, func() { } By("Cleaning up EmbeddingServer") - es := &mcpv1alpha1.EmbeddingServer{} + es := &mcpv1beta1.EmbeddingServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: embeddingName, Namespace: testNamespace, @@ -178,7 +178,7 @@ var _ = Describe("VirtualMCPServer Optimizer Mode", Ordered, func() { } By("Cleaning up backend MCPServer") - backend := &mcpv1alpha1.MCPServer{} + backend := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backendName, Namespace: testNamespace, @@ -187,7 +187,7 @@ var _ = Describe("VirtualMCPServer Optimizer Mode", Ordered, func() { } By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{} + mcpGroup := &mcpv1beta1.MCPGroup{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: mcpGroupName, Namespace: testNamespace, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go index 94a71097a6..51d24964c3 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go @@ -21,7 +21,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -58,10 +58,10 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() "E2E Redis session group", timeout, pollInterval) ginkgo.By("Creating backend MCPServer") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -71,14 +71,14 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() ginkgo.By("Waiting for backend MCPServer to be ready") gomega.Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backendName, Namespace: defaultNamespace, }, server); err != nil { return fmt.Errorf("failed to get MCPServer: %w", err) } - if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("MCPServer not ready yet, phase: %s", server.Status.Phase) } return nil @@ -88,15 +88,15 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() redisAddr := fmt.Sprintf("%s.%s.svc.cluster.local:6379", redisName, defaultNamespace) ginkgo.By("Creating VirtualMCPServer with replicas=2 and Redis") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, Replicas: &replicas, SessionAffinity: "None", - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: redisAddr, KeyPrefix: "thv:vmcp:e2e:", }, @@ -113,31 +113,31 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() }) ginkgo.AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, }) cleanupRedis(redisName) gomega.Eventually(func() bool { - err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1alpha1.VirtualMCPServer{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1beta1.VirtualMCPServer{}) return apierrors.IsNotFound(err) }, timeout, pollInterval).Should(gomega.BeTrue()) }) ginkgo.It("Should set SessionStorageWarning=False when Redis is configured", func() { WaitForCondition(ctx, k8sClient, vmcpName, defaultNamespace, - mcpv1alpha1.ConditionSessionStorageWarning, "False", + mcpv1beta1.ConditionSessionStorageWarning, "False", timeout, pollInterval) }) ginkgo.It("Should report Ready=True when Redis is configured", func() { WaitForCondition(ctx, k8sClient, vmcpName, defaultNamespace, - mcpv1alpha1.ConditionTypeVirtualMCPServerReady, "True", + mcpv1beta1.ConditionTypeVirtualMCPServerReady, "True", timeout, pollInterval) }) }) @@ -169,10 +169,10 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() "E2E Redis cross-pod session group", timeout, pollInterval) ginkgo.By("Creating backend MCPServer") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -182,14 +182,14 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() ginkgo.By("Waiting for backend MCPServer to be ready") gomega.Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backendName, Namespace: defaultNamespace, }, server); err != nil { return fmt.Errorf("failed to get MCPServer: %w", err) } - if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("MCPServer not ready yet, phase: %s", server.Status.Phase) } return nil @@ -199,15 +199,15 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() redisAddr := fmt.Sprintf("%s.%s.svc.cluster.local:6379", redisName, defaultNamespace) ginkgo.By("Creating VirtualMCPServer with replicas=2 and Redis") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, Replicas: &replicas, SessionAffinity: "None", - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: redisAddr, KeyPrefix: "thv:vmcp:e2e:", }, @@ -224,18 +224,18 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() }) ginkgo.AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, }) cleanupRedis(redisName) gomega.Eventually(func() bool { - err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1alpha1.VirtualMCPServer{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1beta1.VirtualMCPServer{}) return apierrors.IsNotFound(err) }, timeout, pollInterval).Should(gomega.BeTrue()) }) @@ -353,10 +353,10 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() "E2E Redis pod restart group", timeout, pollInterval) ginkgo.By("Creating backend MCPServer") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -366,11 +366,11 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() ginkgo.By("Waiting for backend MCPServer to be ready") gomega.Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: backendName, Namespace: defaultNamespace}, server); err != nil { return err } - if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("MCPServer not ready, phase: %s", server.Status.Phase) } return nil @@ -380,16 +380,16 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() redisAddr := fmt.Sprintf("%s.%s.svc.cluster.local:6379", redisName, defaultNamespace) ginkgo.By("Creating VirtualMCPServer with replicas=1, Redis, and NodePort") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, Replicas: &replicas, ServiceType: "NodePort", SessionAffinity: "None", - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: redisAddr, KeyPrefix: "thv:vmcp:e2e:", }, @@ -401,18 +401,18 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() }) ginkgo.AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, }) cleanupRedis(redisName) gomega.Eventually(func() bool { - err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1alpha1.VirtualMCPServer{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1beta1.VirtualMCPServer{}) return apierrors.IsNotFound(err) }, timeout, pollInterval).Should(gomega.BeTrue()) }) @@ -544,10 +544,10 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() "E2E Redis terminated session group", timeout, pollInterval) ginkgo.By("Creating backend MCPServer") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -557,11 +557,11 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() ginkgo.By("Waiting for backend MCPServer to be ready") gomega.Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: backendName, Namespace: defaultNamespace}, server); err != nil { return err } - if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("MCPServer not ready, phase: %s", server.Status.Phase) } return nil @@ -571,15 +571,15 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() redisAddr := fmt.Sprintf("%s.%s.svc.cluster.local:6379", redisName, defaultNamespace) ginkgo.By("Creating VirtualMCPServer with replicas=2, Redis, and SessionAffinity=None") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, Replicas: &replicas, SessionAffinity: "None", - SessionStorage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: redisAddr, KeyPrefix: "thv:vmcp:e2e:", }, @@ -596,18 +596,18 @@ var _ = ginkgo.Describe("VirtualMCPServer Redis-Backed Session Sharing", func() }) ginkgo.AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, }) cleanupRedis(redisName) gomega.Eventually(func() bool { - err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1alpha1.VirtualMCPServer{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1beta1.VirtualMCPServer{}) return apierrors.IsNotFound(err) }, timeout, pollInterval).Should(gomega.BeTrue()) }) diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go index 96f5d6667c..2932dd242d 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go @@ -24,7 +24,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -58,16 +58,16 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { expectedSecretName = virtualMCPName + "-hmac-secret" ginkgo.By("Creating MCPGroup") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPGroup{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPGroupSpec{Description: "Session management e2e group"}, + Spec: mcpv1beta1.MCPGroupSpec{Description: "Session management e2e group"}, })).To(gomega.Succeed()) ginkgo.By("Creating yardstick backend MCPServer") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -76,14 +76,14 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { })).To(gomega.Succeed()) ginkgo.By("Creating VirtualMCPServer") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: virtualMCPName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, ServiceType: "NodePort", }, })).To(gomega.Succeed()) @@ -96,17 +96,17 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { }) ginkgo.AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: virtualMCPName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, }) gomega.Eventually(func() bool { - err := k8sClient.Get(ctx, types.NamespacedName{Name: virtualMCPName, Namespace: defaultNamespace}, &mcpv1alpha1.VirtualMCPServer{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: virtualMCPName, Namespace: defaultNamespace}, &mcpv1beta1.VirtualMCPServer{}) return apierrors.IsNotFound(err) }, timeout, pollInterval).Should(gomega.BeTrue()) @@ -300,26 +300,26 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { expectedSecretName = virtualMCPName + "-hmac-secret" ginkgo.By("Creating MCPGroup") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPGroup{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPGroupSpec{Description: "Default session management group"}, + Spec: mcpv1beta1.MCPGroupSpec{Description: "Default session management group"}, })).To(gomega.Succeed()) ginkgo.By("Creating VirtualMCPServer with default configuration") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: virtualMCPName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, }, })).To(gomega.Succeed()) }) ginkgo.AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: virtualMCPName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, }) }) @@ -433,16 +433,16 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { // ---- Deploy yardstick backend ---- ginkgo.By("Creating MCPGroup") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPGroup{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPGroupSpec{Description: "Session hijacking test group"}, + Spec: mcpv1beta1.MCPGroupSpec{Description: "Session hijacking test group"}, })).To(gomega.Succeed()) ginkgo.By("Creating yardstick backend MCPServer") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -452,11 +452,11 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { // ---- Create MCPOIDCConfig for OIDC auth ---- ginkgo.By("Creating MCPOIDCConfig for OIDC incoming auth") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPOIDCConfig{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: "session-oidc-config", Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: oidcIssuer, InsecureAllowHTTP: true, JWKSAllowPrivateIP: true, @@ -467,16 +467,16 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { // ---- Deploy VirtualMCPServer with OIDC incoming auth ---- ginkgo.By("Creating VirtualMCPServer with OIDC incoming auth") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: "session-oidc-config", Audience: "vmcp-audience", }, @@ -493,20 +493,20 @@ var _ = ginkgo.Describe("VirtualMCPServer Session Management", func() { }) ginkgo.AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, }) oidcCleanup() // Wait for the vMCP to be fully gone before the next test context starts. gomega.Eventually(func() bool { - err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1alpha1.VirtualMCPServer{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1beta1.VirtualMCPServer{}) return apierrors.IsNotFound(err) }, timeout, pollInterval).Should(gomega.BeTrue()) }) diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go index 21edae2c9f..c5024b8957 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go @@ -14,7 +14,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/yaml" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -35,18 +35,18 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { "Test MCP Group for telemetry config", timeout, pollingInterval) By("Creating yardstick backend MCPServer") - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "TRANSPORT", Value: "streamable-http"}, }, }, @@ -55,45 +55,45 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { By("Waiting for backend MCPServer to be running") Eventually(func() error { - server := &mcpv1alpha1.MCPServer{} + server := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backendName, Namespace: testNamespace, }, server); err != nil { return fmt.Errorf("failed to get server: %w", err) } - if server.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("backend not ready yet, phase: %s", server.Status.Phase) } return nil }, timeout, pollingInterval).Should(Succeed()) By("Creating MCPTelemetryConfig for shared telemetry") - telCfg := &mcpv1alpha1.MCPTelemetryConfig{ + telCfg := &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "e2e-telemetry-config", Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPTelemetryConfigSpec{ - OpenTelemetry: &mcpv1alpha1.MCPTelemetryOTelConfig{ + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ Enabled: true, Endpoint: "localhost:4317", - Tracing: &mcpv1alpha1.OpenTelemetryTracingConfig{Enabled: true}, - Metrics: &mcpv1alpha1.OpenTelemetryMetricsConfig{Enabled: true}, + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, + Metrics: &mcpv1beta1.OpenTelemetryMetricsConfig{Enabled: true}, ResourceAttributes: map[string]string{ "environment": "e2e-test", "test_id": "telemetry_config_test", "cluster_name": "kind-test-cluster", }, }, - Prometheus: &mcpv1alpha1.PrometheusConfig{Enabled: true}, + Prometheus: &mcpv1beta1.PrometheusConfig{Enabled: true}, }, } Expect(k8sClient.Create(ctx, telCfg)).To(Succeed()) // Wait for MCPTelemetryConfig to be reconciled (hash set) Eventually(func() bool { - fetched := &mcpv1alpha1.MCPTelemetryConfig{} + fetched := &mcpv1beta1.MCPTelemetryConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: telCfg.Name, Namespace: telCfg.Namespace, @@ -102,18 +102,18 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { }, timeout, pollingInterval).Should(BeTrue()) By("Creating VirtualMCPServer with telemetryConfigRef") - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, ServiceType: "NodePort", - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - TelemetryConfigRef: &mcpv1alpha1.MCPTelemetryConfigReference{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{ Name: "e2e-telemetry-config", ServiceName: "custom-service-name", }, @@ -126,14 +126,14 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { By("Waiting for VirtualMCPServer to be ready") Eventually(func() error { - server := &mcpv1alpha1.VirtualMCPServer{} + server := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, }, server); err != nil { return fmt.Errorf("failed to get VirtualMCPServer: %w", err) } - if server.Status.Phase != mcpv1alpha1.VirtualMCPServerPhaseReady { + if server.Status.Phase != mcpv1beta1.VirtualMCPServerPhaseReady { return fmt.Errorf("VirtualMCPServer not ready yet, phase: %s", server.Status.Phase) } return nil @@ -142,7 +142,7 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -151,7 +151,7 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { Expect(k8sClient.Delete(ctx, vmcp)).To(Succeed()) By("Cleaning up backend MCPServer") - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, @@ -160,7 +160,7 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { Expect(k8sClient.Delete(ctx, backend)).To(Succeed()) By("Cleaning up MCPTelemetryConfig") - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPTelemetryConfig{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPTelemetryConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "e2e-telemetry-config", Namespace: testNamespace, @@ -168,7 +168,7 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { }) By("Cleaning up MCPGroup") - group := &mcpv1alpha1.MCPGroup{ + group := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_toolconfig_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_toolconfig_test.go index c1745b294b..08ed6cd766 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_toolconfig_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_toolconfig_test.go @@ -14,7 +14,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -44,17 +44,17 @@ var _ = Describe("VirtualMCPServer Tool Filtering via MCPToolConfig", Ordered, f }, timeout, pollingInterval) By("Creating MCPToolConfig for filtering and overriding tools") - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: toolConfigName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ // Filter on the overridden name (renamed_fetch), not the backend name (fetch). // This is because filtering happens AFTER override is applied. ToolsFilter: []string{"renamed_fetch"}, // Override the fetch tool name and description - ToolsOverride: map[string]mcpv1alpha1.ToolOverride{ + ToolsOverride: map[string]mcpv1beta1.ToolOverride{ "fetch": { Name: "renamed_fetch", Description: "This fetch tool has been renamed via MCPToolConfig", @@ -65,13 +65,13 @@ var _ = Describe("VirtualMCPServer Tool Filtering via MCPToolConfig", Ordered, f Expect(k8sClient.Create(ctx, toolConfig)).To(Succeed()) By("Creating VirtualMCPServer with MCPToolConfig reference for backend1") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -92,7 +92,7 @@ var _ = Describe("VirtualMCPServer Tool Filtering via MCPToolConfig", Ordered, f }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -111,7 +111,7 @@ var _ = Describe("VirtualMCPServer Tool Filtering via MCPToolConfig", Ordered, f AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -120,7 +120,7 @@ var _ = Describe("VirtualMCPServer Tool Filtering via MCPToolConfig", Ordered, f _ = k8sClient.Delete(ctx, vmcpServer) By("Cleaning up MCPToolConfig") - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: toolConfigName, Namespace: testNamespace, @@ -130,7 +130,7 @@ var _ = Describe("VirtualMCPServer Tool Filtering via MCPToolConfig", Ordered, f By("Cleaning up backend MCPServers") for _, backendName := range []string{backend1Name, backend2Name} { - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, @@ -140,7 +140,7 @@ var _ = Describe("VirtualMCPServer Tool Filtering via MCPToolConfig", Ordered, f } By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, @@ -276,7 +276,7 @@ var _ = Describe("VirtualMCPServer Tool Filtering via MCPToolConfig", Ordered, f Context("when verifying MCPToolConfig configuration", func() { It("should have correct ToolConfigRef in VirtualMCPServer spec", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -301,7 +301,7 @@ var _ = Describe("VirtualMCPServer Tool Filtering via MCPToolConfig", Ordered, f }) It("should have MCPToolConfig with correct filter and overrides", func() { - toolConfig := &mcpv1alpha1.MCPToolConfig{} + toolConfig := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: toolConfigName, Namespace: testNamespace, @@ -343,12 +343,12 @@ var _ = Describe("VirtualMCPServer MCPToolConfig Dynamic Updates", Ordered, func images.GofetchServerImage, timeout, pollingInterval) By("Creating MCPToolConfig with initial filter") - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: toolConfigName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPToolConfigSpec{ + Spec: mcpv1beta1.MCPToolConfigSpec{ // Initially only allow 'fetch' tool ToolsFilter: []string{"fetch"}, }, @@ -356,13 +356,13 @@ var _ = Describe("VirtualMCPServer MCPToolConfig Dynamic Updates", Ordered, func Expect(k8sClient.Create(ctx, toolConfig)).To(Succeed()) By("Creating VirtualMCPServer with MCPToolConfig reference") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -377,7 +377,7 @@ var _ = Describe("VirtualMCPServer MCPToolConfig Dynamic Updates", Ordered, func }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -396,7 +396,7 @@ var _ = Describe("VirtualMCPServer MCPToolConfig Dynamic Updates", Ordered, func AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -405,7 +405,7 @@ var _ = Describe("VirtualMCPServer MCPToolConfig Dynamic Updates", Ordered, func _ = k8sClient.Delete(ctx, vmcpServer) By("Cleaning up MCPToolConfig") - toolConfig := &mcpv1alpha1.MCPToolConfig{ + toolConfig := &mcpv1beta1.MCPToolConfig{ ObjectMeta: metav1.ObjectMeta{ Name: toolConfigName, Namespace: testNamespace, @@ -414,7 +414,7 @@ var _ = Describe("VirtualMCPServer MCPToolConfig Dynamic Updates", Ordered, func _ = k8sClient.Delete(ctx, toolConfig) By("Cleaning up backend MCPServer") - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, @@ -423,7 +423,7 @@ var _ = Describe("VirtualMCPServer MCPToolConfig Dynamic Updates", Ordered, func _ = k8sClient.Delete(ctx, backend) By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, @@ -459,7 +459,7 @@ var _ = Describe("VirtualMCPServer MCPToolConfig Dynamic Updates", Ordered, func It("should reflect updated overrides when MCPToolConfig is modified", func() { By("Updating MCPToolConfig to change the tool override name") - toolConfig := &mcpv1alpha1.MCPToolConfig{} + toolConfig := &mcpv1beta1.MCPToolConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: toolConfigName, Namespace: testNamespace, @@ -469,7 +469,7 @@ var _ = Describe("VirtualMCPServer MCPToolConfig Dynamic Updates", Ordered, func // Update the override to rename fetch to "updated_fetch" instead of "renamed_fetch" // Also update the filter to match the new overridden name toolConfig.Spec.ToolsFilter = []string{"updated_fetch"} - toolConfig.Spec.ToolsOverride = map[string]mcpv1alpha1.ToolOverride{ + toolConfig.Spec.ToolsOverride = map[string]mcpv1beta1.ToolOverride{ "fetch": { Name: "updated_fetch", Description: "This fetch tool name was updated via MCPToolConfig", diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go index ea8ca25263..e9b9ec33a4 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_yardstick_base_test.go @@ -16,7 +16,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -40,36 +40,36 @@ var _ = Describe("VirtualMCPServer Yardstick Base", Ordered, func() { By("Creating yardstick backend MCPServers in parallel") // Create both MCPServer resources without waiting - backend1 := &mcpv1alpha1.MCPServer{ + backend1 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backend1Name, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "TRANSPORT", Value: "streamable-http"}, }, }, } Expect(k8sClient.Create(ctx, backend1)).To(Succeed()) - backend2 := &mcpv1alpha1.MCPServer{ + backend2 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backend2Name, Namespace: testNamespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, MCPPort: 8080, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ {Name: "TRANSPORT", Value: "streamable-http"}, }, }, @@ -79,25 +79,25 @@ var _ = Describe("VirtualMCPServer Yardstick Base", Ordered, func() { // Wait for both backends to be running in parallel By("Waiting for both backend MCPServers to be running") Eventually(func() error { - server1 := &mcpv1alpha1.MCPServer{} + server1 := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backend1Name, Namespace: testNamespace, }, server1); err != nil { return fmt.Errorf("backend1: failed to get server: %w", err) } - if server1.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server1.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("backend1 not ready yet, phase: %s", server1.Status.Phase) } - server2 := &mcpv1alpha1.MCPServer{} + server2 := &mcpv1beta1.MCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{ Name: backend2Name, Namespace: testNamespace, }, server2); err != nil { return fmt.Errorf("backend2: failed to get server: %w", err) } - if server2.Status.Phase != mcpv1alpha1.MCPServerPhaseReady { + if server2.Status.Phase != mcpv1beta1.MCPServerPhaseReady { return fmt.Errorf("backend2 not ready yet, phase: %s", server2.Status.Phase) } @@ -105,20 +105,20 @@ var _ = Describe("VirtualMCPServer Yardstick Base", Ordered, func() { }, timeout, pollingInterval).Should(Succeed(), "Both MCPServers should be running") By("Creating VirtualMCPServer with prefix conflict resolution") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ ConflictResolution: "prefix", }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -137,7 +137,7 @@ var _ = Describe("VirtualMCPServer Yardstick Base", Ordered, func() { AfterAll(func() { By("Cleaning up VirtualMCPServer") - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, @@ -147,7 +147,7 @@ var _ = Describe("VirtualMCPServer Yardstick Base", Ordered, func() { By("Cleaning up backend MCPServers") for _, backendName := range []string{backend1Name, backend2Name} { - backend := &mcpv1alpha1.MCPServer{ + backend := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backendName, Namespace: testNamespace, @@ -157,7 +157,7 @@ var _ = Describe("VirtualMCPServer Yardstick Base", Ordered, func() { } By("Cleaning up MCPGroup") - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, @@ -326,7 +326,7 @@ var _ = Describe("VirtualMCPServer Yardstick Base", Ordered, func() { Context("when verifying VirtualMCPServer status", func() { It("should have correct aggregation configuration", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, @@ -350,13 +350,13 @@ var _ = Describe("VirtualMCPServer Yardstick Base", Ordered, func() { }) It("should have VirtualMCPServer in Ready phase", func() { - vmcpServer := &mcpv1alpha1.VirtualMCPServer{} + vmcpServer := &mcpv1beta1.VirtualMCPServer{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: vmcpServerName, Namespace: testNamespace, }, vmcpServer) Expect(err).ToNot(HaveOccurred()) - Expect(vmcpServer.Status.Phase).To(Equal(mcpv1alpha1.VirtualMCPServerPhaseReady)) + Expect(vmcpServer.Status.Phase).To(Equal(mcpv1beta1.VirtualMCPServerPhaseReady)) }) }) @@ -423,7 +423,7 @@ var _ = Describe("VirtualMCPServer Yardstick Base", Ordered, func() { AfterAll(func() { By("Cleaning up additional backends from membership test") - backend3 := &mcpv1alpha1.MCPServer{ + backend3 := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: backend3Name, Namespace: testNamespace, diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go index a34a3eec32..3549b1e3b8 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go @@ -16,7 +16,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -64,16 +64,16 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { vmcpName = fmt.Sprintf("e2e-scale-vmcp-%d", ts) ginkgo.By("Creating MCPGroup") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPGroup{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPGroupSpec{Description: "E2E scaling group"}, + Spec: mcpv1beta1.MCPGroupSpec{Description: "E2E scaling group"}, })).To(gomega.Succeed()) ginkgo.By("Creating backend MCPServer") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -83,28 +83,28 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { replicas := int32(2) ginkgo.By("Creating VirtualMCPServer with replicas=2") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, Replicas: &replicas, }, })).To(gomega.Succeed()) }) ginkgo.AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, }) gomega.Eventually(func() bool { - err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1alpha1.VirtualMCPServer{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1beta1.VirtualMCPServer{}) return apierrors.IsNotFound(err) }, timeout, pollInterval).Should(gomega.BeTrue()) }) @@ -128,21 +128,21 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { ginkgo.It("Should set SessionStorageWarning condition when Redis is not configured", func() { WaitForCondition(ctx, k8sClient, vmcpName, defaultNamespace, - mcpv1alpha1.ConditionSessionStorageWarning, "True", + mcpv1beta1.ConditionSessionStorageWarning, "True", timeout, pollInterval) - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} gomega.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, vmcp)).To(gomega.Succeed()) var warningCond *metav1.Condition for i, cond := range vmcp.Status.Conditions { - if cond.Type == mcpv1alpha1.ConditionSessionStorageWarning { + if cond.Type == mcpv1beta1.ConditionSessionStorageWarning { warningCond = &vmcp.Status.Conditions[i] break } } gomega.Expect(warningCond).NotTo(gomega.BeNil()) - gomega.Expect(warningCond.Reason).To(gomega.Equal(mcpv1alpha1.ConditionReasonSessionStorageMissing)) + gomega.Expect(warningCond.Reason).To(gomega.Equal(mcpv1beta1.ConditionReasonSessionStorageMissing)) }) }) @@ -164,16 +164,16 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { vmcpName = fmt.Sprintf("e2e-scale-lc-vmcp-%d", ts) ginkgo.By("Creating MCPGroup") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPGroup{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPGroupSpec{Description: "E2E scaling lifecycle group"}, + Spec: mcpv1beta1.MCPGroupSpec{Description: "E2E scaling lifecycle group"}, })).To(gomega.Succeed()) ginkgo.By("Creating backend MCPServer") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.MCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -183,11 +183,11 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { replicas := int32(1) ginkgo.By("Creating VirtualMCPServer with replicas=1") - gomega.Expect(k8sClient.Create(ctx, &mcpv1alpha1.VirtualMCPServer{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - GroupRef: &mcpv1alpha1.MCPGroupRef{Name: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, Replicas: &replicas, ServiceType: "NodePort", }, @@ -198,17 +198,17 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { }) ginkgo.AfterAll(func() { - _ = k8sClient.Delete(ctx, &mcpv1alpha1.VirtualMCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPServer{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, }) - _ = k8sClient.Delete(ctx, &mcpv1alpha1.MCPGroup{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{Name: mcpGroupName, Namespace: defaultNamespace}, }) gomega.Eventually(func() bool { - err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1alpha1.VirtualMCPServer{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, &mcpv1beta1.VirtualMCPServer{}) return apierrors.IsNotFound(err) }, timeout, pollInterval).Should(gomega.BeTrue()) }) @@ -219,14 +219,14 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { }, timeout, pollInterval).Should(gomega.Equal(1)) WaitForCondition(ctx, k8sClient, vmcpName, defaultNamespace, - mcpv1alpha1.ConditionSessionStorageWarning, "False", + mcpv1beta1.ConditionSessionStorageWarning, "False", timeout, pollInterval) }) ginkgo.It("Should update Deployment replicas and set SessionStorageWarning after scaling to 2", func() { ginkgo.By("Scaling VirtualMCPServer to 2 replicas") gomega.Eventually(func() error { - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, vmcp); err != nil { return err } @@ -254,14 +254,14 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { ginkgo.By("Verifying SessionStorageWarning is now set") WaitForCondition(ctx, k8sClient, vmcpName, defaultNamespace, - mcpv1alpha1.ConditionSessionStorageWarning, "True", + mcpv1beta1.ConditionSessionStorageWarning, "True", timeout, pollInterval) }) ginkgo.It("Should clear SessionStorageWarning when scaled back to 1", func() { ginkgo.By("Scaling VirtualMCPServer back to 1 replica") gomega.Eventually(func() error { - vmcp := &mcpv1alpha1.VirtualMCPServer{} + vmcp := &mcpv1beta1.VirtualMCPServer{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: vmcpName, Namespace: defaultNamespace}, vmcp); err != nil { return err } @@ -272,7 +272,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Horizontal Scaling", func() { ginkgo.By("Verifying SessionStorageWarning is cleared") WaitForCondition(ctx, k8sClient, vmcpName, defaultNamespace, - mcpv1alpha1.ConditionSessionStorageWarning, "False", + mcpv1beta1.ConditionSessionStorageWarning, "False", timeout, pollInterval) }) }) From 81e23e278e0b9c9b20105412785964be3ca3bdb9 Mon Sep 17 00:00:00 2001 From: Stacklok Bot <140063061+stacklokbot@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:21:13 -0400 Subject: [PATCH 113/117] Release v0.23.0 (#4973) * Update VERSION for release Release-Triggered-By: ChrisJBurns * Update Chart.yaml for release Release-Triggered-By: ChrisJBurns * Update Chart.yaml for release Release-Triggered-By: ChrisJBurns * Update Chart.yaml for release Release-Triggered-By: ChrisJBurns * Update Chart.yaml for release Release-Triggered-By: ChrisJBurns * Update values.yaml for release Release-Triggered-By: ChrisJBurns * Update values.yaml for release Release-Triggered-By: ChrisJBurns * Update values.yaml for release Release-Triggered-By: ChrisJBurns * Update VERSION for release Release-Triggered-By: ChrisJBurns * Update Chart.yaml for release Release-Triggered-By: ChrisJBurns * Update README.md for release Release-Triggered-By: ChrisJBurns * Update Chart.yaml for release Release-Triggered-By: ChrisJBurns * Update README.md for release Release-Triggered-By: ChrisJBurns * Update values.yaml for release Release-Triggered-By: ChrisJBurns --- VERSION | 2 +- deploy/charts/operator-crds/Chart.yaml | 4 ++-- deploy/charts/operator-crds/README.md | 2 +- deploy/charts/operator/Chart.yaml | 4 ++-- deploy/charts/operator/README.md | 10 +++++----- deploy/charts/operator/values.yaml | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index 2157409059..ca222b7cf3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.22.0 +0.23.0 diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index 5cf2fd9043..93af24c610 100644 --- a/deploy/charts/operator-crds/Chart.yaml +++ b/deploy/charts/operator-crds/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator-crds description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. type: application -version: 0.22.0 -appVersion: "v0.22.0" +version: 0.23.0 +appVersion: "v0.23.0" diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index 91c7267233..31e5530afe 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -1,6 +1,6 @@ # ToolHive Operator CRDs Helm Chart -![Version: 0.22.0](https://img.shields.io/badge/Version-0.22.0-informational?style=flat-square) +![Version: 0.23.0](https://img.shields.io/badge/Version-0.23.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. diff --git a/deploy/charts/operator/Chart.yaml b/deploy/charts/operator/Chart.yaml index 0002b4dd6f..ba7b85a5ca 100644 --- a/deploy/charts/operator/Chart.yaml +++ b/deploy/charts/operator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator description: A Helm chart for deploying the ToolHive Operator into Kubernetes. type: application -version: 0.22.0 -appVersion: "v0.22.0" +version: 0.23.0 +appVersion: "v0.23.0" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index d4ed74f634..ef8516ed0f 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -1,6 +1,6 @@ # ToolHive Operator Helm Chart -![Version: 0.22.0](https://img.shields.io/badge/Version-0.22.0-informational?style=flat-square) +![Version: 0.23.0](https://img.shields.io/badge/Version-0.23.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for deploying the ToolHive Operator into Kubernetes. @@ -46,7 +46,7 @@ The command removes all the Kubernetes components associated with the chart and |-----|------|---------|-------------| | fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources | | nameOverride | string | `""` | Override the name of the chart | -| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"env":[],"features":{"experimental":false,"registry":true,"server":true,"virtualMCP":true},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.22.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.22.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.22.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | +| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"env":[],"features":{"experimental":false,"registry":true,"server":true,"virtualMCP":true},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.23.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.23.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.23.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | | operator.affinity | object | `{}` | Affinity settings for the operator pod | | operator.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | Configuration for horizontal pod autoscaling | | operator.autoscaling.enabled | bool | `false` | Enable autoscaling for the operator | @@ -62,7 +62,7 @@ The command removes all the Kubernetes components associated with the chart and | operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container | | operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container | | operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container | -| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.22.0"` | Container image for the operator | +| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.23.0"` | Container image for the operator | | operator.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy for the operator container | | operator.imagePullSecrets | list | `[]` | List of image pull secrets to use | | operator.leaderElectionRole | object | `{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]}` | Leader election role configuration | @@ -89,8 +89,8 @@ The command removes all the Kubernetes components associated with the chart and | operator.serviceAccount.labels | object | `{}` | Labels to add to the service account | | operator.serviceAccount.name | string | `"toolhive-operator"` | The name of the service account to use. If not set and create is true, a name is generated. | | operator.tolerations | list | `[]` | Tolerations for the operator pod | -| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.22.0"` | Image to use for Toolhive runners | -| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.22.0"` | Image to use for Virtual MCP Server (vMCP) deployments | +| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.23.0"` | Image to use for Toolhive runners | +| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.23.0"` | Image to use for Virtual MCP Server (vMCP) deployments | | operator.volumeMounts | list | `[]` | Additional volume mounts on the operator container | | operator.volumes | list | `[]` | Additional volumes to mount on the operator pod | | registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.1.1"}` | All values for the registry API deployment and associated resources | diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index cea477a4ef..092b4ad7f2 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -26,15 +26,15 @@ operator: # -- List of image pull secrets to use imagePullSecrets: [] # -- Container image for the operator - image: ghcr.io/stacklok/toolhive/operator:v0.22.0 + image: ghcr.io/stacklok/toolhive/operator:v0.23.0 # -- Image pull policy for the operator container imagePullPolicy: IfNotPresent # -- Image to use for Toolhive runners - toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.22.0 + toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.23.0 # -- Image to use for Virtual MCP Server (vMCP) deployments - vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.22.0 + vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.23.0 # -- Host for the proxy deployed by the operator proxyHost: 0.0.0.0 From 63ee62aacfac84c90a005da3e6708a3b86ed499b Mon Sep 17 00:00:00 2001 From: reyortiz3 <reynier@stacklok.com> Date: Tue, 21 Apr 2026 12:36:20 -0400 Subject: [PATCH 114/117] Propagate auth errors through stale cache fallback (#4981) refreshCache() was returning stale cached data for any API error, including auth failures (401/403 from registry and OAuth flow timeouts). Users saw previously-cached registry content with no error after their credentials expired or the OAuth browser flow timed out. Fix in two parts: - auth.Transport.RoundTrip now wraps all Token() errors with ErrRegistryAuthRequired, classifying token-acquisition failures as auth errors regardless of the underlying cause. - refreshCache() checks for ErrRegistryAuthRequired/ErrRegistryUnauthorized before the stale-cache fallback; transient errors (network, 5xx) still degrade gracefully. Fixes #4950 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --- pkg/registry/auth/transport.go | 5 +- pkg/registry/provider_cached.go | 11 +- pkg/registry/provider_cached_authbug_test.go | 172 +++++++++++++++++++ 3 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 pkg/registry/provider_cached_authbug_test.go diff --git a/pkg/registry/auth/transport.go b/pkg/registry/auth/transport.go index 44ac0d614a..183d66a98f 100644 --- a/pkg/registry/auth/transport.go +++ b/pkg/registry/auth/transport.go @@ -23,7 +23,10 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { // Get token from source token, err := t.Source.Token(req.Context()) if err != nil { - return nil, fmt.Errorf("failed to get auth token: %w", err) + // Any token acquisition failure is an auth error regardless of cause. + // Wrapping with ErrRegistryAuthRequired ensures refreshCache() and other + // callers can distinguish auth failures from transient network errors. + return nil, fmt.Errorf("%w: failed to get auth token: %w", ErrRegistryAuthRequired, err) } // If token is empty, pass through without auth diff --git a/pkg/registry/provider_cached.go b/pkg/registry/provider_cached.go index 85bdced249..e22001be49 100644 --- a/pkg/registry/provider_cached.go +++ b/pkg/registry/provider_cached.go @@ -6,6 +6,7 @@ package registry import ( "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -112,7 +113,9 @@ func (p *CachedAPIRegistryProvider) GetRegistry() (*types.Registry, error) { } // refreshCache fetches fresh data from the API and updates the cache. -// If the API fetch fails, returns stale cache if available. +// Auth errors (ErrRegistryAuthRequired, ErrRegistryUnauthorized) are always +// propagated — stale cache must never mask a changed authentication state. +// For transient failures (network blip, 5xx) stale cache is returned if available. func (p *CachedAPIRegistryProvider) refreshCache() (*types.Registry, error) { p.cacheMu.Lock() defer p.cacheMu.Unlock() @@ -120,7 +123,11 @@ func (p *CachedAPIRegistryProvider) refreshCache() (*types.Registry, error) { // Fetch from API registry, err := p.APIRegistryProvider.GetRegistry() if err != nil { - // If fetch fails and we have stale cache, return it + // Auth errors must propagate — stale cache must not mask a changed auth state. + if errors.Is(err, auth.ErrRegistryAuthRequired) || errors.Is(err, api.ErrRegistryUnauthorized) { + return nil, err + } + // Transient failures (network blip, 5xx): degrade gracefully to stale cache. if p.cachedData != nil { return p.cachedData, nil } diff --git a/pkg/registry/provider_cached_authbug_test.go b/pkg/registry/provider_cached_authbug_test.go new file mode 100644 index 0000000000..4815940b4f --- /dev/null +++ b/pkg/registry/provider_cached_authbug_test.go @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/registry/api" + "github.com/stacklok/toolhive/pkg/registry/auth" +) + +// TestCachedProvider_AuthErrorNotMaskedByStaleCache reproduces a bug in +// refreshCache() at provider_cached.go:116. +// +// Scenario: a user has an existing cache populated from a previous successful +// fetch. Later, their registry returns 401 (token revoked, credentials +// rejected server-side). The CLI should surface the auth error, because the +// user's authorization state may have changed since the cache was populated. +// +// Current behavior: refreshCache() treats ANY error from the upstream fetch +// as a signal to fall back to stale cached data, including authentication +// errors. The CLI silently prints stale registry contents with no error. +// +// This test covers the 401/403 branch (server-side rejection). +func TestCachedProvider_AuthErrorNotMaskedByStaleCache(t *testing.T) { + t.Parallel() + + var returnUnauthorized atomic.Bool + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if returnUnauthorized.Load() { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"servers":[],"metadata":{"next_cursor":""}}`)) + })) + t.Cleanup(srv.Close) + + // allowPrivateIp=true because httptest binds to 127.0.0.1 + // usePersistent=false exercises only the in-memory cache path + // tokenSource=nil so the constructor's validation probe runs against the healthy server + provider, err := NewCachedAPIRegistryProvider(srv.URL, true, false, nil) + require.NoError(t, err, "constructor should succeed while server is healthy") + + // Populate the in-memory cache with a successful fetch. + _, err = provider.ListServers() + require.NoError(t, err, "first fetch should succeed and populate cache") + + // Flip the server to 401 — equivalent to the registry now rejecting + // the user's credentials server-side. + returnUnauthorized.Store(true) + + // Force a refresh — mirrors what happens when the in-memory cache TTL + // expires (default 1h) and the next `thv registry list` triggers a + // fresh fetch. The upstream API call will fail with 401. + err = provider.ForceRefresh() + + require.Error(t, err, + "expected auth error to propagate on refresh; got nil — "+ + "stale cache is masking the auth failure (bug)") + require.True(t, + errors.Is(err, api.ErrRegistryUnauthorized) || + errors.Is(err, auth.ErrRegistryAuthRequired), + "expected ErrRegistryUnauthorized or ErrRegistryAuthRequired; got: %v", err) +} + +// failingTokenSource is a test double for auth.TokenSource. +// It returns an empty token (no Authorization header added) when fail is +// false, and returns an error wrapped like oauthTokenSource.Token() would +// when its browser OAuth flow fails, when fail is true. +type failingTokenSource struct { + fail atomic.Bool +} + +// Token mirrors the exact error-wrapping oauthTokenSource.Token() applies +// when its browser flow times out / is cancelled. See: +// - pkg/registry/auth/oauth_token_source.go:61-67 (outer wrap) +// - pkg/registry/auth/oauth_token_source.go:110-113 (inner wrap) +// +// The point of matching the wrapping is to verify that the eventual +// refreshCache() fallback behavior does not depend on the specific error +// type — any error causes stale cache to be served. +func (s *failingTokenSource) Token(_ context.Context) (string, error) { + if s.fail.Load() { + inner := fmt.Errorf("oauth flow start failed: %w", + errors.New("authorization timed out waiting for browser callback")) + return "", fmt.Errorf("oauth flow failed: %w", inner) + } + // No auth header needed — the httptest server does not validate credentials. + return "", nil +} + +// TestCachedProvider_OAuthFlowFailureNotMaskedByStaleCache reproduces the +// same masking bug via the OAuth-browser-flow-failure code path — the one +// actually described in the bug report: +// +// "thv registry list sent me a URL to OAuth. I did nothing. After a minute +// it exited the OAuth flow and went to another OAuth flow. I also did +// nothing. In another minute it exited that OAuth flow and then it +// showed me everything in the registry." +// +// This path differs from the 401/403 path because the error returned by +// oauthTokenSource.Token() when the browser flow times out is a generic +// wrapped error — it does NOT match errors.Is(err, auth.ErrRegistryAuthRequired) +// or errors.Is(err, api.ErrRegistryUnauthorized). It is propagated through: +// +// oauthTokenSource.Token ("oauth flow failed: ...") +// -> auth.Transport.RoundTrip ("failed to get auth token: ...") +// -> api.Client.fetchServersPage ("failed to fetch servers: ...") +// -> APIRegistryProvider.GetRegistry (wrapped into *UnavailableError) +// -> refreshCache (swallowed; stale cache returned with nil err) +// +// A sentinel-check fix like +// +// if errors.Is(err, auth.ErrRegistryAuthRequired) || +// errors.Is(err, api.ErrRegistryUnauthorized) { return nil, err } +// +// will NOT catch this path — the OAuth browser-flow error carries neither +// sentinel. A correct fix has to classify token-acquisition failures as +// auth errors before they are flattened into UnavailableError. +func TestCachedProvider_OAuthFlowFailureNotMaskedByStaleCache(t *testing.T) { + t.Parallel() + + // Server always serves a valid empty list. All failures in this test + // come from the token source, not the server — simulating an OAuth + // flow that never completes while the registry itself is reachable. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"servers":[],"metadata":{"next_cursor":""}}`)) + })) + t.Cleanup(srv.Close) + + ts := &failingTokenSource{} + + // Non-nil tokenSource causes NewAPIRegistryProvider to skip its + // construction-time validation probe (provider_api.go:57) — matching + // the real OAuth-configured code path where the probe is skipped + // because a browser flow cannot complete within 10 seconds. + provider, err := NewCachedAPIRegistryProvider(srv.URL, true, false, ts) + require.NoError(t, err, "constructor should succeed (probe skipped when tokenSource != nil)") + + // Populate the in-memory cache with a successful fetch. + // ts.fail == false, so Token() returns ("", nil) and the request passes + // through the auth transport without an Authorization header. + _, err = provider.ListServers() + require.NoError(t, err, "first fetch should succeed and populate cache") + + // Simulate the OAuth browser flow failing / timing out on the next + // token acquisition. + ts.fail.Store(true) + + err = provider.ForceRefresh() + + // DESIRED: an OAuth-flow failure must not be hidden by stale cache. + // CURRENT BUG: refreshCache returns cached data with nil error. + require.Error(t, err, + "expected OAuth flow failure to propagate on refresh; got nil — "+ + "stale cache is masking the OAuth failure (bug, "+ + "matches user-reported scenario)") +} From 7ee0772a6bd6fe4180af941ade1b50adc7b8ad8a Mon Sep 17 00:00:00 2001 From: Radoslav Dimitrov <radoslav@stacklok.com> Date: Tue, 21 Apr 2026 14:02:48 -0400 Subject: [PATCH 115/117] Unify docs-website release pipeline: new assets + retire dispatch chain (#4982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bundle CRD manifests as a release asset Adds a `thv-crds.tar.gz` tarball to each release containing the CRD YAML manifests from `deploy/charts/operator-crds/files/crds/`. Motivation: downstream consumers of the CRDs (notably stacklok/docs-website, which generates per-CRD reference pages) currently have to clone the entire toolhive repo at each release tag just to read these 13 manifests. Shipping them as a ~94KB tarball asset lets those consumers skip the clone and just `gh release download` like they already do for `thv-cli-docs.tar.gz` and `swagger.yaml`. Purely additive — no changes to existing release assets or workflows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Re-export toolhive-core schemas as release assets Reads the toolhive-core version from go.mod at release time, downloads the four JSON schema files from that version of stacklok/toolhive-core, and ships them alongside toolhive's own release assets. Motivation: downstream consumers (notably stacklok/docs-website) currently have to replicate this logic: read go.mod, derive the core version, then fetch from a different repo's release. Re-exporting the schemas here makes toolhive's release self-contained — one `gh release download` call gets everything. Paired with https://github.com/stacklok/toolhive/pull/4982 (CRD manifests as release asset); together these eliminate the need for docs-website to clone toolhive or hit a second repo during its release-doc regeneration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Retire the docs-website repository_dispatch chain docs-website no longer consumes `repository_dispatch: published-release` events — its reference-doc regeneration now runs via a Renovate-driven pipeline that reads the new `thv-crds.tar.gz` and re-exported core schemas introduced earlier in this PR. See https://github.com/stacklok/docs-website/pull/748 for context. Changes: - Delete `.github/workflows/update-docs-website.yml` (the dispatch sender, called via workflow_call from `releaser.yml`). - Remove the `update-docs-website` job from `releaser.yml` along with its `extract-release-actor` dependency, which only existed to feed the dispatch's `assign_to` input. - Update `notify-release-failure`'s `needs:` list to drop the removed jobs. Follow-up for maintainers: the `DOCS_REPO_DISPATCH_TOKEN` repo secret can be retired — nothing references it after this change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .github/workflows/releaser.yml | 69 ++++++++++------------- .github/workflows/update-docs-website.yml | 51 ----------------- .goreleaser.yaml | 5 ++ 3 files changed, 34 insertions(+), 91 deletions(-) delete mode 100644 .github/workflows/update-docs-website.yml diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml index d0e7015560..efe5fa0fb1 100644 --- a/.github/workflows/releaser.yml +++ b/.github/workflows/releaser.yml @@ -145,6 +145,35 @@ jobs: mkdir -p build tar -czf build/thv-cli-docs.tar.gz -C docs/cli . + - name: Bundle CRD manifests + run: | + mkdir -p build + tar -czf build/thv-crds.tar.gz -C deploy/charts/operator-crds/files/crds . + + - name: Download toolhive-core schemas at pinned version + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Resolve the toolhive-core version this release was built against + # (from go.mod, since we ship binaries compiled against that version). + # Re-exporting the schemas here lets downstream consumers (notably + # docs-website) skip the two-repo dance of deriving the version and + # fetching from a separate release. + mkdir -p build + CORE_VERSION=$(grep 'github.com/stacklok/toolhive-core' go.mod | awk '{print $2}' | head -1) + if [ -z "$CORE_VERSION" ]; then + echo "::error::Could not determine toolhive-core version from go.mod" + exit 1 + fi + echo "Using toolhive-core version: $CORE_VERSION" + gh release download "$CORE_VERSION" \ + --repo stacklok/toolhive-core \ + --pattern "toolhive-legacy-registry.schema.json" \ + --pattern "upstream-registry.schema.json" \ + --pattern "publisher-provided.schema.json" \ + --pattern "skill.schema.json" \ + --dir build/ + - name: Remove existing release assets (allows re-runs) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -213,44 +242,6 @@ jobs: id-token: write uses: ./.github/workflows/helm-publish.yml - extract-release-actor: - name: Extract Release Actor - runs-on: ubuntu-slim - outputs: - triggered_by: ${{ steps.extract.outputs.triggered_by }} - permissions: - contents: read - steps: - - name: Extract actor from release body - id: extract - env: - GH_TOKEN: ${{ github.token }} - run: | - # Fetch the release body and extract the Release-Triggered-By metadata - RELEASE_BODY=$(gh release view "${{ github.ref_name }}" --repo "${{ github.repository }}" --json body --jq '.body') - - # Extract username from HTML comment: <!-- Release-Triggered-By: username --> - TRIGGERED_BY=$(echo "$RELEASE_BODY" | grep -oP '(?<=<!-- Release-Triggered-By: )[^[:space:]]+(?= -->)' || true) - - if [ -n "$TRIGGERED_BY" ]; then - echo "Found release triggering actor: $TRIGGERED_BY" - echo "triggered_by=$TRIGGERED_BY" >> $GITHUB_OUTPUT - else - echo "No Release-Triggered-By metadata found, falling back to github.actor" - echo "triggered_by=${{ github.actor }}" >> $GITHUB_OUTPUT - fi - - update-docs-website: - name: Trigger Docs Update - needs: [ publish-helm, extract-release-actor ] - permissions: {} - uses: ./.github/workflows/update-docs-website.yml - with: - version: ${{ github.ref_name }} - assign_to: ${{ needs.extract-release-actor.outputs.triggered_by }} - secrets: - DOCS_REPO_DISPATCH_TOKEN: ${{ secrets.DOCS_REPO_DISPATCH_TOKEN }} - # provenance: # name: Generate provenance (SLSA3) # needs: @@ -311,9 +302,7 @@ jobs: - release-binaries - image-build-and-push - skills-build-and-push - - update-docs-website - publish-helm - - extract-release-actor if: ${{ failure() }} runs-on: ubuntu-slim permissions: {} diff --git a/.github/workflows/update-docs-website.yml b/.github/workflows/update-docs-website.yml deleted file mode 100644 index c96210c009..0000000000 --- a/.github/workflows/update-docs-website.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Trigger Docs Update - -permissions: {} - -on: - workflow_call: - inputs: - version: - description: 'Version tag for the release' - required: true - type: string - assign_to: - description: 'GitHub username to assign the PR to' - required: false - type: string - secrets: - DOCS_REPO_DISPATCH_TOKEN: - required: true - -jobs: - trigger-docs-update: - name: Trigger Documentation Update - runs-on: ubuntu-slim - - steps: - - name: Trigger docs update workflow - run: | - repo="stacklok/docs-website" - event_type="published-release" - version="${{ inputs.version }}" - assign_to="${{ inputs.assign_to }}" - - echo "Triggering docs update for $repo with version $version" - if [ -n "$assign_to" ]; then - echo "Will assign PR to: $assign_to" - fi - - # Build client_payload JSON - payload="{\"version\": \"$version\"" - if [ -n "$assign_to" ]; then - payload="$payload, \"assign_to\": \"$assign_to\"" - fi - payload="$payload}" - - curl --fail -L \ - -X POST \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ secrets.DOCS_REPO_DISPATCH_TOKEN }}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - https://api.github.com/repos/$repo/dispatches \ - -d "{\"event_type\": \"$event_type\", \"client_payload\": $payload}" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6229397734..bbfbea5055 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -101,6 +101,11 @@ release: name: toolhive extra_files: - glob: build/thv-cli-docs.tar.gz + - glob: build/thv-crds.tar.gz + - glob: build/toolhive-legacy-registry.schema.json + - glob: build/upstream-registry.schema.json + - glob: build/publisher-provided.schema.json + - glob: build/skill.schema.json - glob: docs/server/swagger.yaml - glob: docs/server/swagger.json - glob: docs/operator/crd-api.md From 69fb2b7beca713d462ff38578f6c950859048d2b Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:14:27 +0100 Subject: [PATCH 116/117] Add advisory CRD schema compatibility CI check (#4980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add advisory CRD schema compatibility CI check The v1beta1 graduation in #4849 made a stability commitment that is enforced only by convention today. Adds a new api-compat workflow that runs crd-schema-checker on every PR touching operator API surface and reports compatible/incompatible/unknown status in the job step summary. Phase 1 is advisory: the job runs with continue-on-error so findings are surfaced without blocking merges. Phase 2 will flip enforcement and add the api-break-allowed escape hatch label. Part of #4972. Baseline is the last published CRD chart from OCI, falling back to origin/main when the chart for the latest tag is not yet published (the release-to-publish lag window). NoBools and NoMaps style validators are disabled — they are OpenShift API-style rules, not compat-breaking checks, and fire on fields we legitimately use. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address review feedback on API compat workflow Apply findings from the multi-agent consensus review. Summary: - SHA-pin crd-schema-checker to 3fee146022bf — the tool has no release tags, and @latest left CI non-deterministic and exposed the runner to supply-chain risk (continue-on-error does not sandbox the step). - Add timeout-minutes: 10 — expected runtime is ~3 min; the bound protects against a hung helm pull or go install without the 6h default burning CI budget on a genuine hang. - Drop 2>/dev/null from helm pull — its stderr is one useful line when the fallback path activates; redirecting hid transient ghcr.io outages behind the expected "tag not yet published" case. - Narrow path filter to files/crds/** plus the workflow self-watch. templates/** is caught by operator-ci's generate-crds drift check; values.yaml and crd-helm-wrapper only change Helm conditionals and annotations the checker ignores, and the workflow's explicit --set flags force every CRD to render regardless. - Route step outputs through env: blocks — defense-in-depth against future edits that might route PR-controlled data through them. Count-floor and origin/main fallback rework deferred: the floor would generate false positives on legitimate CRD add/remove, and the origin/main rework needs design discussion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fail-closed when baseline chart is unavailable Remove the origin/main fallback in baseline resolution. Falling back silently compares subsequent PRs against an already-broken baseline once a breaking change lands on main, narrowing the Phase 2 enforcement guarantee. Better to fail the job and wait for the release chart to publish than to emit a false green. Phase 1's continue-on-error means a failure here does not block PRs during the release-to-OCI publish window. Phase 2 will cover that window via the api-break-allowed label if needed. Addresses review finding F5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Drop design doc reference from workflow comment The design doc is a planning artifact and is not committed to the repo, so the reference is a dead link for anyone reading the workflow file. The workflow's own inline comments already explain the rollout phases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .github/workflows/api-compat.yml | 169 +++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 .github/workflows/api-compat.yml diff --git a/.github/workflows/api-compat.yml b/.github/workflows/api-compat.yml new file mode 100644 index 0000000000..a663256f96 --- /dev/null +++ b/.github/workflows/api-compat.yml @@ -0,0 +1,169 @@ +name: API Compatibility + +# This workflow guards the stability of the v1beta1 operator API surface. +# +# Phase 1 (current): advisory only. `crd-schema-check` runs with +# continue-on-error: true so breaking-change findings are surfaced via the +# job's step summary but do not block merges. Remove continue-on-error in +# Phase 2 to start enforcing. + +on: + pull_request: + paths: + - 'cmd/thv-operator/api/**' + # files/crds is the source of truth — controller-gen emits here, and + # crd-helm-wrapper copies from here into templates/. Any drift in + # templates/ is caught by operator-ci.yml's generate-crds job, so + # watching templates/ would be redundant. values.yaml and the + # crd-helm-wrapper only affect Helm conditionals and annotations the + # checker ignores; the workflow's explicit --set flags force every + # CRD to render regardless of those files. + - 'deploy/charts/operator-crds/files/crds/**' + # Self-exercise the workflow when the workflow itself changes. + - '.github/workflows/api-compat.yml' + +permissions: + contents: read + +jobs: + crd-schema-check: + name: CRD Schema Compatibility + runs-on: ubuntu-latest + # Phase 1: advisory only. Remove in Phase 2 to enforce. + continue-on-error: true + # Expected runtime is ~3 minutes (checkout + go/helm setup + helm pull + + # go install + two renders + checker). 10 minutes is a cheap upper bound + # that protects against a hung helm pull or go install without being + # disruptive to legitimate slow runs. + timeout-minutes: 10 + steps: + - name: Checkout PR HEAD + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version: 'stable' + cache: true + + - name: Set up Helm + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 + + - name: Resolve baseline source + id: baseline + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + # Baseline is always the most recent published release chart from + # OCI. If the pull fails (no releases, OCI outage, or the chart + # for the most recent tag is not yet published), the job fails — + # an API-touching PR must be checked against a real released + # baseline or not at all. Falling back to origin/main was + # rejected in review: it can silently compare against an + # already-broken baseline once a break lands on main. + LATEST_TAG="$(gh release list --repo "$GITHUB_REPOSITORY" --limit 1 --json tagName --jq '.[0].tagName')" + if [ -z "$LATEST_TAG" ]; then + echo "::error::No releases found for $GITHUB_REPOSITORY; cannot establish an API compatibility baseline." + exit 1 + fi + LATEST_VERSION="${LATEST_TAG#v}" + + echo "Pulling published chart for $LATEST_TAG..." + mkdir -p /tmp/baseline-release + helm pull "oci://ghcr.io/stacklok/toolhive/toolhive-operator-crds" \ + --version "$LATEST_VERSION" \ + --untar --untardir /tmp/baseline-release + + echo "source=release $LATEST_TAG" >> "$GITHUB_OUTPUT" + echo "chart-dir=/tmp/baseline-release/toolhive-operator-crds" >> "$GITHUB_OUTPUT" + + - name: Render baseline CRDs + env: + # Route step outputs through env vars so bash quotes them instead + # of the runner substituting them directly into the script body. + # Current values are hardcoded literals, so not injectable today — + # this is defense-in-depth against a future edit that routes a + # PR-controlled string through these outputs. + CHART_DIR: ${{ steps.baseline.outputs.chart-dir }} + run: | + set -euo pipefail + mkdir -p /tmp/baseline-crds + # All three feature flags must be set so the Helm wrapper emits every + # CRD (see deploy/charts/operator-crds/crd-helm-wrapper). Missing a + # flag silently drops CRDs from the comparison. + # The yq filter is defensive — the chart currently only templates + # CRDs, but this keeps the invariant explicit if non-CRD resources + # are ever added. + helm template toolhive-operator-crds "$CHART_DIR" \ + --set crds.install.server=true \ + --set crds.install.registry=true \ + --set crds.install.virtualMcp=true \ + | yq 'select(.kind == "CustomResourceDefinition")' \ + > /tmp/baseline-crds/all.yaml + echo "Rendered $(grep -c '^kind: CustomResourceDefinition' /tmp/baseline-crds/all.yaml || echo 0) baseline CRDs" + + - name: Render HEAD CRDs + run: | + set -euo pipefail + mkdir -p /tmp/head-crds + helm template toolhive-operator-crds deploy/charts/operator-crds \ + --set crds.install.server=true \ + --set crds.install.registry=true \ + --set crds.install.virtualMcp=true \ + | yq 'select(.kind == "CustomResourceDefinition")' \ + > /tmp/head-crds/all.yaml + echo "Rendered $(grep -c '^kind: CustomResourceDefinition' /tmp/head-crds/all.yaml || echo 0) HEAD CRDs" + + - name: Install crd-schema-checker + # SHA-pinned: openshift/crd-schema-checker has no release tags at the + # time of writing, so @latest is the only other option. Pinning makes + # CI deterministic and mitigates supply-chain risk (upstream compromise + # would otherwise execute attacker code on the runner with GITHUB_TOKEN + # in env). Bump via a deliberate PR after verifying the new output + # locally. SHA pinned on 2026-04-21. + run: go install github.com/openshift/crd-schema-checker/cmd/crd-schema-checker@3fee146022bfe6f4adf84998de35d7267b864bef + + - name: Check CRD schema compatibility + id: checker + env: + # See Render baseline CRDs step for rationale on env-based interpolation. + BASELINE_SOURCE: ${{ steps.baseline.outputs.source }} + run: | + # NoBools and NoMaps are OpenShift API-style conventions, not + # compat-breaking rules. They fire on fields we legitimately use + # (e.g. embeddingservers.spec.modelCache.enabled) and drown out + # real findings. Re-enable only if upstream clarifies breaking- + # change semantics for them. + set +e + crd-schema-checker check-manifests \ + --existing-crd-filename /tmp/baseline-crds/all.yaml \ + --new-crd-filename /tmp/head-crds/all.yaml \ + --disabled-validators=NoBools,NoMaps \ + 2>&1 | tee /tmp/checker-output.txt + CHECK_EXIT=${PIPESTATUS[0]} + set -e + + if [ "$CHECK_EXIT" -eq 0 ]; then + STATUS="Compatible" + else + STATUS="Incompatible or Unknown" + fi + + { + echo "## API Compatibility — CRD Schema Check (Phase 1: advisory)" + echo "" + echo "**Baseline source**: $BASELINE_SOURCE" + echo "**Status**: $STATUS" + echo "" + echo "<details><summary>crd-schema-checker output</summary>" + echo "" + echo '```' + cat /tmp/checker-output.txt + echo '```' + echo "" + echo "</details>" + } >> "$GITHUB_STEP_SUMMARY" + + exit "$CHECK_EXIT" From 7fb840aab57ac54f370db6f47867eefd3e9f3fe3 Mon Sep 17 00:00:00 2001 From: Stacklok Bot <140063061+stacklokbot@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:20:52 -0400 Subject: [PATCH 117/117] Release v0.23.1 (#4985) * Update VERSION for release Release-Triggered-By: rdimitrov * Update Chart.yaml for release Release-Triggered-By: rdimitrov * Update Chart.yaml for release Release-Triggered-By: rdimitrov * Update Chart.yaml for release Release-Triggered-By: rdimitrov * Update Chart.yaml for release Release-Triggered-By: rdimitrov * Update values.yaml for release Release-Triggered-By: rdimitrov * Update values.yaml for release Release-Triggered-By: rdimitrov * Update values.yaml for release Release-Triggered-By: rdimitrov * Update VERSION for release Release-Triggered-By: rdimitrov * Update Chart.yaml for release Release-Triggered-By: rdimitrov * Update README.md for release Release-Triggered-By: rdimitrov * Update Chart.yaml for release Release-Triggered-By: rdimitrov * Update README.md for release Release-Triggered-By: rdimitrov * Update values.yaml for release Release-Triggered-By: rdimitrov --- VERSION | 2 +- deploy/charts/operator-crds/Chart.yaml | 4 ++-- deploy/charts/operator-crds/README.md | 2 +- deploy/charts/operator/Chart.yaml | 4 ++-- deploy/charts/operator/README.md | 10 +++++----- deploy/charts/operator/values.yaml | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index ca222b7cf3..610e28725b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.0 +0.23.1 diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index 93af24c610..5a859297d6 100644 --- a/deploy/charts/operator-crds/Chart.yaml +++ b/deploy/charts/operator-crds/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator-crds description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. type: application -version: 0.23.0 -appVersion: "v0.23.0" +version: 0.23.1 +appVersion: "v0.23.1" diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index 31e5530afe..1e64428fd3 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -1,6 +1,6 @@ # ToolHive Operator CRDs Helm Chart -![Version: 0.23.0](https://img.shields.io/badge/Version-0.23.0-informational?style=flat-square) +![Version: 0.23.1](https://img.shields.io/badge/Version-0.23.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. diff --git a/deploy/charts/operator/Chart.yaml b/deploy/charts/operator/Chart.yaml index ba7b85a5ca..83ae521e26 100644 --- a/deploy/charts/operator/Chart.yaml +++ b/deploy/charts/operator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator description: A Helm chart for deploying the ToolHive Operator into Kubernetes. type: application -version: 0.23.0 -appVersion: "v0.23.0" +version: 0.23.1 +appVersion: "v0.23.1" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index ef8516ed0f..04fa310fe5 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -1,6 +1,6 @@ # ToolHive Operator Helm Chart -![Version: 0.23.0](https://img.shields.io/badge/Version-0.23.0-informational?style=flat-square) +![Version: 0.23.1](https://img.shields.io/badge/Version-0.23.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for deploying the ToolHive Operator into Kubernetes. @@ -46,7 +46,7 @@ The command removes all the Kubernetes components associated with the chart and |-----|------|---------|-------------| | fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources | | nameOverride | string | `""` | Override the name of the chart | -| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"env":[],"features":{"experimental":false,"registry":true,"server":true,"virtualMCP":true},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.23.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.23.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.23.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | +| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"env":[],"features":{"experimental":false,"registry":true,"server":true,"virtualMCP":true},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.23.1","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.23.1","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.23.1","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | | operator.affinity | object | `{}` | Affinity settings for the operator pod | | operator.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | Configuration for horizontal pod autoscaling | | operator.autoscaling.enabled | bool | `false` | Enable autoscaling for the operator | @@ -62,7 +62,7 @@ The command removes all the Kubernetes components associated with the chart and | operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container | | operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container | | operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container | -| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.23.0"` | Container image for the operator | +| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.23.1"` | Container image for the operator | | operator.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy for the operator container | | operator.imagePullSecrets | list | `[]` | List of image pull secrets to use | | operator.leaderElectionRole | object | `{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":[""],"resources":["events"],"verbs":["create","patch"]}]}` | Leader election role configuration | @@ -89,8 +89,8 @@ The command removes all the Kubernetes components associated with the chart and | operator.serviceAccount.labels | object | `{}` | Labels to add to the service account | | operator.serviceAccount.name | string | `"toolhive-operator"` | The name of the service account to use. If not set and create is true, a name is generated. | | operator.tolerations | list | `[]` | Tolerations for the operator pod | -| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.23.0"` | Image to use for Toolhive runners | -| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.23.0"` | Image to use for Virtual MCP Server (vMCP) deployments | +| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.23.1"` | Image to use for Toolhive runners | +| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.23.1"` | Image to use for Virtual MCP Server (vMCP) deployments | | operator.volumeMounts | list | `[]` | Additional volume mounts on the operator container | | operator.volumes | list | `[]` | Additional volumes to mount on the operator pod | | registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.1.1"}` | All values for the registry API deployment and associated resources | diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index 092b4ad7f2..d8eaa5a720 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -26,15 +26,15 @@ operator: # -- List of image pull secrets to use imagePullSecrets: [] # -- Container image for the operator - image: ghcr.io/stacklok/toolhive/operator:v0.23.0 + image: ghcr.io/stacklok/toolhive/operator:v0.23.1 # -- Image pull policy for the operator container imagePullPolicy: IfNotPresent # -- Image to use for Toolhive runners - toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.23.0 + toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.23.1 # -- Image to use for Virtual MCP Server (vMCP) deployments - vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.23.0 + vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.23.1 # -- Host for the proxy deployed by the operator proxyHost: 0.0.0.0