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 --- 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. 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/.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. 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" 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. 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/.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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 05165e34ab..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') }} @@ -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: 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/security-scan.yml b/.github/workflows/security-scan.yml index e89bccad05..dcbf430fef 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 }} @@ -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/.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/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/.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 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 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/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/) diff --git a/VERSION b/VERSION index 1cf0537c34..610e28725b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.19.0 +0.23.1 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 f7508f7599..5ff43c9f52 100644 --- a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -21,283 +21,188 @@ 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) { +func (in *EmbeddingServer) DeepCopyInto(out *EmbeddingServer) { *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 - } + 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 AWSStsConfig. -func (in *AWSStsConfig) DeepCopy() *AWSStsConfig { +// 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(AWSStsConfig) + 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 *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 +// 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(AuditConfig) - 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 *AuthServerRef) DeepCopyInto(out *AuthServerRef) { +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 AuthServerRef. -func (in *AuthServerRef) DeepCopy() *AuthServerRef { +// 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(AuthServerRef) + 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 *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 +// 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(AuthServerStorageConfig) - 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 *AuthzConfigRef) DeepCopyInto(out *AuthzConfigRef) { +func (in *MCPExternalAuthConfig) DeepCopyInto(out *MCPExternalAuthConfig) { *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) - } + 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 AuthzConfigRef. -func (in *AuthzConfigRef) DeepCopy() *AuthzConfigRef { +// 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(AuthzConfigRef) + 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 *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 +// 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(BackendAuthConfig) - 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 *BearerTokenConfig) DeepCopyInto(out *BearerTokenConfig) { +func (in *MCPExternalAuthConfigList) DeepCopyInto(out *MCPExternalAuthConfigList) { *out = *in - if in.TokenSecretRef != nil { - in, out := &in.TokenSecretRef, &out.TokenSecretRef - *out = new(SecretKeyRef) - **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 BearerTokenConfig. -func (in *BearerTokenConfig) DeepCopy() *BearerTokenConfig { +// 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(BearerTokenConfig) + 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 *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 +// 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(CABundleSource) - 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 *ConfigMapAuthzRef) DeepCopyInto(out *ConfigMapAuthzRef) { +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 ConfigMapAuthzRef. -func (in *ConfigMapAuthzRef) DeepCopy() *ConfigMapAuthzRef { +// 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(ConfigMapAuthzRef) + out := new(MCPGroup) in.DeepCopyInto(out) 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 +// 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 } - out := new(ConfigMapOIDCRef) - 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 *MCPGroupList) DeepCopyInto(out *MCPGroupList) { *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([]MCPGroup, 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 MCPGroupList. +func (in *MCPGroupList) DeepCopy() *MCPGroupList { if in == nil { return nil } - out := new(EmbeddedAuthServerConfig) + out := new(MCPGroupList) 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 *MCPGroupList) 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 *MCPOIDCConfig) DeepCopyInto(out *MCPOIDCConfig) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) @@ -305,18 +210,18 @@ func (in *EmbeddingServer) DeepCopyInto(out *EmbeddingServer) { 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 MCPOIDCConfig. +func (in *MCPOIDCConfig) DeepCopy() *MCPOIDCConfig { if in == nil { return nil } - out := new(EmbeddingServer) + out := new(MCPOIDCConfig) 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 *MCPOIDCConfig) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -324,31 +229,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 *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([]EmbeddingServer, len(*in)) + *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 EmbeddingServerList. -func (in *EmbeddingServerList) DeepCopy() *EmbeddingServerList { +// 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(EmbeddingServerList) + out := new(MCPOIDCConfigList) 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 *MCPOIDCConfigList) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -356,2586 +261,243 @@ 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 *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 EmbeddingServerRef. -func (in *EmbeddingServerRef) DeepCopy() *EmbeddingServerRef { +// 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(EmbeddingServerRef) + 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 *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 *MCPRegistry) 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 *MCPRegistryList) DeepCopyInto(out *MCPRegistryList) { *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([]MCPRegistry, 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 { +// 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(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.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) - (*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 *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 - if in.AllowedClientIDs != nil { - in, out := &in.AllowedClientIDs, &out.AllowedClientIDs - *out = make([]string, len(*in)) - copy(*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 *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 - 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) + out := new(MCPRegistryList) 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 { +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 *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.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) - (*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 - } - 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 - *out = new(string) - **out = **in - } - if in.Replicas != nil { - in, out := &in.Replicas, &out.Replicas - *out = new(int32) - **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) - } -} - -// 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.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.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) - (*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.Telemetry != nil { - in, out := &in.Telemetry, &out.Telemetry - *out = new(TelemetryConfig) - (*in).DeepCopyInto(*out) - } - 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 - } -} - -// 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 *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 - 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) - } -} - -// 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 *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 -} - -// 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 - } - if in.UsernameRef != nil { - in, out := &in.UsernameRef, &out.UsernameRef - *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 *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 - 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 + } + return nil } // 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) @@ -2943,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 } @@ -2962,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 } @@ -2994,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) @@ -3050,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 } @@ -3069,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 } @@ -3101,116 +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) - } - in.Config.DeepCopyInto(&out.Config) - 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 94% rename from cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go rename to cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index c2fc8dac5f..cca6d75b91 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -1,12 +1,14 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 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 @@ -745,6 +772,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` @@ -897,6 +925,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/v1beta1/mcpexternalauthconfig_types_test.go similarity index 89% rename from cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types_test.go rename to cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go index b2909506b5..4cc555fda8 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" @@ -609,6 +609,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/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 93% rename from cmd/thv-operator/api/v1alpha1/mcpoidcconfig_types.go rename to cmd/thv-operator/api/v1beta1/mcpoidcconfig_types.go index 63e05ad324..e1d3a5e53f 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" @@ -132,12 +132,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"` @@ -195,6 +199,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` @@ -239,16 +244,18 @@ type MCPOIDCConfigReference struct { // +optional Audience string `json:"audience,omitempty"` - // 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"` - // 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"` + + // 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/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 92% rename from cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go rename to cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go index 6367e1164a..f73f6b69e0 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" @@ -37,11 +37,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 { // RemoteURL is the URL of the remote MCP server to proxy // +kubebuilder:validation:Required @@ -59,12 +54,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 @@ -107,17 +96,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"` @@ -155,10 +136,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. @@ -377,6 +358,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" @@ -418,11 +400,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/v1beta1/mcpserver_types.go similarity index 77% rename from cmd/thv-operator/api/v1alpha1/mcpserver_types.go rename to cmd/thv-operator/api/v1beta1/mcpserver_types.go index 6050a76bfd..20f5d686f8 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpserver_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpserver_types.go @@ -1,9 +1,11 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package v1alpha1 +package v1beta1 import ( + "encoding/json" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -175,11 +177,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.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)" +// +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 { @@ -266,16 +266,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"` @@ -307,17 +301,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 @@ -331,10 +317,17 @@ 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. + // Accepts {name: <group>} or, for objects created before v0.20, a plain + // group name string. Both forms decode to the same MCPGroupRef. The schema + // is untyped to admit both forms, so CEL cannot validate it; an invalid + // value decodes to an empty name and is reported by the GroupRefValidated + // condition. // +optional - GroupRef string `json:"groupRef,omitempty"` + // +kubebuilder:validation:Schemaless + // +kubebuilder:pruning:PreserveUnknownFields + 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. @@ -599,18 +592,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 @@ -686,98 +667,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. @@ -786,68 +675,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 - // +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 - // +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" @@ -914,6 +741,44 @@ 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"` +} + +// UnmarshalJSON accepts both the object form {"name": "<group>"} and the +// legacy plain string form "<group>" used by MCPServer before v0.20. +// Any other shape decodes to an empty name instead of failing, so a single +// malformed object cannot break list/watch decoding in the operator cache. +func (r *MCPGroupRef) UnmarshalJSON(data []byte) error { + var name string + if err := json.Unmarshal(data, &name); err == nil { + r.Name = name + return nil + } + var obj struct { + Name any `json:"name"` + } + if err := json.Unmarshal(data, &obj); err != nil { + r.Name = "" + return nil + } + r.Name, _ = obj.Name.(string) + return nil +} + +// 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 @@ -945,60 +810,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 @@ -1101,6 +912,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" @@ -1137,11 +949,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/mcpserver_types_test.go b/cmd/thv-operator/api/v1beta1/mcpserver_types_test.go similarity index 79% rename from cmd/thv-operator/api/v1alpha1/mcpserver_types_test.go rename to cmd/thv-operator/api/v1beta1/mcpserver_types_test.go index b21ae1af95..e44b613f6d 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" @@ -180,3 +180,38 @@ func TestMCPServerSpecScalingFieldsJSONRoundtrip(t *testing.T) { }) } } + +func TestMCPGroupRefUnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want *MCPGroupRef + }{ + {name: "object form", input: `{"groupRef":{"name":"platform"}}`, want: &MCPGroupRef{Name: "platform"}}, + {name: "legacy string form", input: `{"groupRef":"platform"}`, want: &MCPGroupRef{Name: "platform"}}, + {name: "absent", input: `{}`, want: nil}, + {name: "number decodes to empty name", input: `{"groupRef":42}`, want: &MCPGroupRef{}}, + {name: "array decodes to empty name", input: `{"groupRef":["platform"]}`, want: &MCPGroupRef{}}, + {name: "non-string name decodes to empty name", input: `{"groupRef":{"name":7}}`, want: &MCPGroupRef{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var spec MCPServerSpec + require.NoError(t, json.Unmarshal([]byte(tt.input), &spec)) + assert.Equal(t, tt.want, spec.GroupRef) + }) + } +} + +func TestMCPGroupRefMarshalsObjectForm(t *testing.T) { + t.Parallel() + + data, err := json.Marshal(MCPServerSpec{Image: "img", GroupRef: &MCPGroupRef{Name: "platform"}}) + require.NoError(t, err) + assert.Contains(t, string(data), `"groupRef":{"name":"platform"}`) +} diff --git a/cmd/thv-operator/api/v1alpha1/mcpserverentry_types.go b/cmd/thv-operator/api/v1beta1/mcpserverentry_types.go similarity index 97% rename from cmd/thv-operator/api/v1alpha1/mcpserverentry_types.go rename to cmd/thv-operator/api/v1beta1/mcpserverentry_types.go index ff665dbaed..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" @@ -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 @@ -150,12 +149,13 @@ 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" //+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/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 88% rename from cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go rename to cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go index 27db742973..654f156c15 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" @@ -14,6 +14,8 @@ import ( ) // VirtualMCPServerSpec defines the desired state of VirtualMCPServer +// +//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. @@ -59,14 +61,24 @@ 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. - // The telemetry and audit config from here are also supported, but not required. + // Optional in the schema so objects created before groupRef was introduced + // still validate; the controller rejects a VirtualMCPServer without it. + // +optional + GroupRef *MCPGroupRef `json:"groupRef,omitempty"` + + // Config is the Virtual MCP server configuration. + // The audit config from here is also supported, but not required. // +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. + // +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. @@ -77,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 @@ -109,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 { @@ -120,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"` @@ -162,12 +167,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"` } @@ -178,10 +183,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 @@ -219,8 +225,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"` @@ -228,6 +234,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 +281,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 +347,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 @@ -342,10 +368,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 @@ -370,6 +392,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" @@ -397,30 +420,24 @@ 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 } +// ResolveGroupName returns the group name from spec.groupRef. +func (r *VirtualMCPServer) ResolveGroupName() string { + 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 (required field) + // Validate Group is set — spec.groupRef.name is required // 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() == "" { + return fmt.Errorf("spec.groupRef.name is required") } // Note: IncomingAuth validation is handled by kubebuilder markers and CEL rules @@ -499,7 +516,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/v1beta1/virtualmcpserver_types_test.go similarity index 81% rename from cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go rename to cmd/thv-operator/api/v1beta1/virtualmcpserver_types_test.go index b6d28bb7be..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" @@ -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, @@ -535,3 +535,101 @@ 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_Validate_RequiresGroupRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + groupRef *MCPGroupRef + expectErr bool + errMsg string + }{ + { + name: "valid with groupRef set", + groupRef: &MCPGroupRef{Name: "my-group"}, + expectErr: false, + }, + { + 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: "returns spec.groupRef name", + groupRef: &MCPGroupRef{Name: "from-spec"}, + want: "from-spec", + }, + { + name: "returns empty when spec.groupRef is nil", + groupRef: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + vmcp := &VirtualMCPServer{ + Spec: VirtualMCPServerSpec{ + GroupRef: tt.groupRef, + }, + } + assert.Equal(t, tt.want, vmcp.ResolveGroupName()) + }) + } +} 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..ca6af43f2c --- /dev/null +++ b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go @@ -0,0 +1,3090 @@ +//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.AllowedClientIDs != nil { + in, out := &in.AllowedClientIDs, &out.AllowedClientIDs + *out = make([]string, len(*in)) + copy(*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.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **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.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 + } + if in.UsernameRef != nil { + in, out := &in.UsernameRef, &out.UsernameRef + *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 b2298dfdc5..da63544b22 100644 --- a/cmd/thv-operator/controllers/embeddingserver_controller.go +++ b/cmd/thv-operator/controllers/embeddingserver_controller.go @@ -29,7 +29,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" - 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" ) @@ -69,7 +69,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) { @@ -129,7 +129,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) @@ -151,7 +151,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 @@ -174,7 +174,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 @@ -191,7 +191,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) @@ -252,7 +252,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) @@ -300,7 +300,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() @@ -345,15 +345,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, }) @@ -364,12 +364,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, }) @@ -386,9 +386,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, }) @@ -399,7 +399,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) @@ -448,7 +448,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 != "" { @@ -496,7 +496,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, @@ -546,7 +546,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", @@ -579,7 +579,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{ @@ -595,7 +595,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{ @@ -611,7 +611,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 @@ -638,7 +638,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 { @@ -662,7 +662,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 { @@ -721,7 +721,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) @@ -760,7 +760,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) @@ -805,7 +805,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, @@ -818,7 +818,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) @@ -924,7 +924,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) @@ -938,7 +938,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 @@ -949,26 +949,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", } }() @@ -987,12 +987,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") } @@ -1006,7 +1006,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{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + For(&mcpv1beta1.EmbeddingServer{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). 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 f350d3c0ff..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,12 +426,13 @@ 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{} } - 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) - 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) + groupName) + 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{} } return []ctrl.Request{ @@ -464,12 +465,13 @@ 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{} } - 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) - group := &mcpv1alpha1.MCPGroup{} - groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: mcpRemoteProxy.Spec.GroupRef} + groupName) + 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", - "namespace", obj.GetNamespace(), "name", mcpRemoteProxy.Spec.GroupRef) + "namespace", obj.GetNamespace(), "name", groupName) return []ctrl.Request{} } return []ctrl.Request{ @@ -503,12 +505,13 @@ 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{} } - 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) - group := &mcpv1alpha1.MCPGroup{} - groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: mcpServerEntry.Spec.GroupRef} + "groupRef", groupName) + 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", - "namespace", obj.GetNamespace(), "name", mcpServerEntry.Spec.GroupRef) + "namespace", obj.GetNamespace(), "name", groupName) return []ctrl.Request{} } return []ctrl.Request{ @@ -537,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 e0618f99d6..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: 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: 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: 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: 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: 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: 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,27 +190,27 @@ 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) - if mcpServer.Spec.GroupRef == "" { + 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} + return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + 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} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -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: testGroupName}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server2", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: "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: 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: 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: 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: 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,27 +341,27 @@ 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) - if mcpServer.Spec.GroupRef == "" { + 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} + return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + 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} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -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: 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: "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: "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,26 +529,26 @@ 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) - if mcpServer.Spec.GroupRef == "" { + 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} + return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + 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} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -573,31 +573,31 @@ 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) - if mcpServer.Spec.GroupRef == "" { + 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} + return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + 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} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -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: 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,27 +686,27 @@ 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) - if mcpServer.Spec.GroupRef == "" { + 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} + return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + 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} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -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,27 +774,27 @@ 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) - if mcpServer.Spec.GroupRef == "" { + 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} + return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + 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} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -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: 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: 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: "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,27 +909,27 @@ 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) - if mcpServer.Spec.GroupRef == "" { + 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} + return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + 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} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -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 { @@ -960,8 +960,8 @@ 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 { - var updatedServer mcpv1alpha1.MCPServer + if server.Spec.GroupRef.GetName() == testGroupName { + 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: testGroupName}, + Spec: mcpv1beta1.MCPServerSpec{Image: "test", GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "server2", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{Image: "test", GroupRef: "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: 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: "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: 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: 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,26 +1077,26 @@ 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) - if mcpServer.Spec.GroupRef == "" { + 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} + return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + 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} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -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: testGroupName}, + Spec: mcpv1beta1.MCPRemoteProxySpec{GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "proxy2", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: "other-group"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "proxy3", Namespace: "default"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: 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: "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: testGroupName}, + Spec: mcpv1beta1.MCPRemoteProxySpec{GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}}, }, { ObjectMeta: metav1.ObjectMeta{Name: "proxy2", Namespace: "namespace-b"}, - Spec: mcpv1alpha1.MCPRemoteProxySpec{GroupRef: 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,26 +1216,26 @@ 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) - if mcpServer.Spec.GroupRef == "" { + 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} + return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + 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} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -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: 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: "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: "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,26 +1387,26 @@ 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) - if mcpServer.Spec.GroupRef == "" { + 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} + return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + 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} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -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: 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: 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,27 +1485,27 @@ 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) - if mcpServer.Spec.GroupRef == "" { + 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} + return []string{mcpServer.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + 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} + return []string{mcpRemoteProxy.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - mcpServerEntry := obj.(*mcpv1alpha1.MCPServerEntry) - if mcpServerEntry.Spec.GroupRef == "" { + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + if mcpServerEntry.Spec.GroupRef.GetName() == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{mcpServerEntry.Spec.GroupRef.GetName()} }). Build() @@ -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 40c848c282..450a1761cd 100644 --- a/cmd/thv-operator/controllers/mcpregistry_controller.go +++ b/cmd/thv-operator/controllers/mcpregistry_controller.go @@ -23,7 +23,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" - 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" ) @@ -87,7 +87,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) { @@ -111,7 +111,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 { @@ -125,7 +125,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()) @@ -217,7 +217,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{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + For(&mcpv1beta1.MCPRegistry{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). Owns(&appsv1.Deployment{}). Owns(&corev1.Service{}). Owns(&corev1.ConfigMap{}). @@ -231,10 +231,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) } @@ -242,7 +242,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 @@ -252,7 +252,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 @@ -262,16 +262,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") } } @@ -288,9 +288,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, @@ -299,14 +299,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 @@ -321,7 +321,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 @@ -360,7 +360,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, } @@ -402,7 +402,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 @@ -442,23 +442,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 e471462a59..17f95b0e5d 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" @@ -30,7 +29,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "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" @@ -67,7 +66,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) { @@ -98,18 +97,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 { @@ -124,7 +123,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") } @@ -134,7 +133,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") } @@ -144,7 +143,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") } @@ -154,7 +153,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") } @@ -164,7 +163,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") } @@ -175,7 +174,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 @@ -215,7 +214,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( @@ -226,7 +225,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") @@ -248,7 +247,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) @@ -313,7 +312,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) @@ -360,7 +359,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())) @@ -370,19 +369,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), ) } @@ -390,26 +389,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) - } - - // 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) + 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) @@ -419,9 +404,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, }) @@ -431,23 +416,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, @@ -455,53 +440,12 @@ 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, + 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 } @@ -510,11 +454,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 @@ -529,12 +473,12 @@ func (r *MCPRemoteProxyReconciler) validateK8sRefs( ) r.recordValidationEvent( proxy, - mcpv1alpha1.ConditionReasonAuthzConfigMapNotFound, + mcpv1beta1.ConditionReasonAuthzConfigMapNotFound, msg, ) setConfigurationInvalidCondition( proxy, - mcpv1alpha1.ConditionReasonAuthzConfigMapNotFound, + mcpv1beta1.ConditionReasonAuthzConfigMapNotFound, msg, ) return stderrors.New(msg) @@ -542,8 +486,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) } } @@ -567,12 +511,12 @@ func (r *MCPRemoteProxyReconciler) validateK8sRefs( ) r.recordValidationEvent( proxy, - mcpv1alpha1.ConditionReasonHeaderSecretNotFound, + mcpv1beta1.ConditionReasonHeaderSecretNotFound, msg, ) setConfigurationInvalidCondition( proxy, - mcpv1alpha1.ConditionReasonHeaderSecretNotFound, + mcpv1beta1.ConditionReasonHeaderSecretNotFound, msg, ) return stderrors.New(msg) @@ -580,8 +524,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) } } @@ -591,11 +535,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 { @@ -609,9 +553,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, @@ -620,9 +564,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, }) @@ -631,9 +575,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, }) @@ -656,12 +600,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 != "" { @@ -678,9 +622,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, }) @@ -690,9 +634,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, }) @@ -702,9 +646,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, }) @@ -714,14 +658,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, }) @@ -746,11 +690,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 { @@ -764,9 +708,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, @@ -775,9 +719,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, }) @@ -788,9 +732,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", @@ -803,9 +747,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, }) @@ -829,10 +773,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 { @@ -845,9 +789,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, @@ -861,9 +805,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, @@ -872,9 +816,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, }) @@ -882,11 +826,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, @@ -898,9 +842,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)), @@ -913,9 +857,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, }) @@ -940,12 +884,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 { @@ -970,13 +914,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, }) @@ -1003,16 +947,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, }) @@ -1024,9 +968,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, }) @@ -1036,16 +980,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, }) @@ -1062,11 +1006,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, } @@ -1089,41 +1033,42 @@ 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) { - if proxy.Spec.GroupRef == "" { +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 } 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 { + 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, - Message: fmt.Sprintf("MCPGroup '%s' not found in namespace '%s'", proxy.Spec.GroupRef, proxy.Namespace), + 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, - Message: fmt.Sprintf("MCPGroup '%s' is not ready (current phase: %s)", proxy.Spec.GroupRef, group.Status.Phase), + 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, - Message: fmt.Sprintf("MCPGroup '%s' is valid and ready", proxy.Spec.GroupRef), + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyGroupRefValidated, + Message: fmt.Sprintf("MCPGroup '%s' is valid and ready", groupName), ObservedGeneration: proxy.Generation, }) } @@ -1132,7 +1077,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 @@ -1153,7 +1098,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{ @@ -1183,42 +1128,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, }) @@ -1249,7 +1194,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 } @@ -1278,7 +1223,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 { @@ -1311,7 +1256,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 @@ -1331,6 +1276,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 } @@ -1357,7 +1314,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 @@ -1396,7 +1353,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 { @@ -1419,7 +1376,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 @@ -1465,13 +1422,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 @@ -1498,12 +1455,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 @@ -1530,13 +1487,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 @@ -1565,13 +1522,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 @@ -1596,17 +1553,17 @@ func (r *MCPRemoteProxyReconciler) SetupWithManager(mgr ctrl.Manager) error { ) return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1alpha1.MCPRemoteProxy{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + For(&mcpv1beta1.MCPRemoteProxy{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). 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 b833347395..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,47 +43,33 @@ 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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://login.salesforce.com", - Audience: "mcp.salesforce.com", - }, - }, }, }, expectError: false, }, { 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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, }, }, expectError: true, @@ -93,22 +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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.company.com", - Audience: "mcp-proxy", - }, - }, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "exchange-config", }, }, @@ -151,21 +130,14 @@ 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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://login.salesforce.com", - Audience: "mcp.salesforce.com", - }, - }, }, } @@ -281,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 @@ -292,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", }, }, @@ -306,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", }, }, @@ -388,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", }, }, @@ -404,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...) @@ -432,7 +404,7 @@ func TestHandleToolConfig(t *testing.T) { errContains: "failed to fetch MCPToolConfig", expectCondition: true, expectedCondStatus: metav1.ConditionFalse, - expectedCondReason: mcpv1alpha1.ConditionReasonMCPRemoteProxyToolConfigFetchError, + expectedCondReason: mcpv1beta1.ConditionReasonMCPRemoteProxyToolConfigFetchError, }, } @@ -449,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) } @@ -471,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, @@ -484,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, @@ -504,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, @@ -514,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") } } @@ -528,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 @@ -539,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", }, }, @@ -553,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", }, }, @@ -653,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", }, }, @@ -669,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...) @@ -697,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, }, } @@ -751,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) } @@ -773,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, @@ -786,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, @@ -806,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, @@ -816,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") } } @@ -878,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, }, @@ -937,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, }, @@ -1020,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, }, @@ -1082,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, @@ -1142,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", }, }, @@ -1169,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", }, }, @@ -1194,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", }, }, @@ -1219,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, }, } @@ -1262,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, @@ -1277,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", }, }, @@ -1315,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 9083a0460e..813d0636c4 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) @@ -128,7 +128,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{} @@ -166,7 +166,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, ) { @@ -187,15 +187,9 @@ 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 := []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...) - } + env := r.buildOIDCClientSecretEnvVars(ctx, proxy) // Add token exchange environment variables // Note: Embedded auth server env vars are added separately in deploymentForMCPRemoteProxy @@ -231,9 +225,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. @@ -261,32 +252,41 @@ 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, + ctx context.Context, proxy *mcpv1beta1.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 } - oidcClientSecretEnvVar, err := ctrlutil.GenerateOIDCClientSecretEnvVar( - ctx, r.Client, proxy.Namespace, proxy.Spec.OIDCConfig.Inline.ClientSecretRef, + 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 + } + if oidcCfg == nil || + oidcCfg.Spec.Type != mcpv1beta1.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. // 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 { @@ -315,7 +315,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) @@ -343,7 +343,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) @@ -370,7 +370,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() @@ -387,7 +387,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", @@ -397,7 +397,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) @@ -441,7 +441,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 f4105195c6..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, }, @@ -700,25 +700,205 @@ func TestEnsureService(t *testing.T) { } } +func TestMCPRemoteProxyDeploymentNeedsUpdate_EmbeddedAuthLegacyEnvStable(t *testing.T) { + t.Parallel() + + scheme := createRunConfigTestScheme() + + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "embedded-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "google", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ + IssuerURL: "https://accounts.google.com", + ClientID: "client-id", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "upstream-secret", + Key: "client-secret", + }, + }, + }, + }, + }, + }, + } + + proxy := &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-proxy", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + ProxyPort: 8080, + ExternalAuthConfigRef: &mcpv1beta1.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 := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "embedded-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "google", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ + IssuerURL: "https://accounts.google.com", + ClientID: "client-id", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "upstream-secret", + Key: "client-secret", + }, + }, + }, + }, + }, + }, + } + + proxy := &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-proxy", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + ProxyPort: 8080, + AuthServerRef: &mcpv1beta1.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 := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "exchange-config", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "client-id", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "token-secret", + Key: "client-secret", + }, + Audience: "api", + }, + }, + } + + proxy := &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-proxy", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + ProxyPort: 8080, + ExternalAuthConfigRef: &mcpv1beta1.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() 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", }, }, @@ -736,61 +916,31 @@ 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{ + 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", }, @@ -825,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", }, @@ -885,34 +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", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, - 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", }, @@ -981,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, }, @@ -1010,7 +1153,7 @@ func TestMCPRemoteProxyServiceNeedsUpdate(t *testing.T) { tests := []struct { name string service *corev1.Service - proxy *mcpv1alpha1.MCPRemoteProxy + proxy *mcpv1beta1.MCPRemoteProxy needsUpdate bool }{ { @@ -1032,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 @@ -1046,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 2244704240..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,33 +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", - 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) { + validateResult: func(t *testing.T, proxy *mcpv1beta1.MCPRemoteProxy, c client.Client) { t.Helper() // Verify ServiceAccount created @@ -125,77 +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", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.company.com", - Audience: "mcp-proxy", - }, - }, - 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, }, - Telemetry: &mcpv1alpha1.TelemetryConfig{ - OpenTelemetry: &mcpv1alpha1.OpenTelemetryConfig{ - Enabled: true, - ServiceName: "full-proxy", - }, - }, }, }, - 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", }, }, @@ -208,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 @@ -228,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, @@ -240,37 +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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, - 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") }, }, @@ -298,7 +271,7 @@ func TestMCPRemoteProxyFullReconciliation(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(objects...). - WithStatusSubresource(&mcpv1alpha1.MCPRemoteProxy{}). + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}). Build() reconciler := &MCPRemoteProxyReconciler{ @@ -342,35 +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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "dynamic-tools", }, }, @@ -383,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{ @@ -406,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, @@ -436,21 +402,14 @@ 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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, }, } @@ -461,7 +420,7 @@ func TestMCPRemoteProxyStatusProgression(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(proxy). - WithStatusSubresource(&mcpv1alpha1.MCPRemoteProxy{}). + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}). Build() reconciler := &MCPRemoteProxyReconciler{ @@ -483,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 @@ -515,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 @@ -531,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, }, } @@ -553,29 +512,12 @@ 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() - authzConfig := &mcpv1alpha1.AuthzConfigRef{ - Type: mcpv1alpha1.AuthzConfigTypeConfigMap, - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + authzConfig := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "authz-cm", Key: "policies.json", }, @@ -592,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);"}, }, } @@ -610,26 +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", - 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{ + authzConfig := &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{ `permit(principal, action == Action::"tools/list", resource);`, }, @@ -673,21 +608,14 @@ 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", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, }, } @@ -749,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", }, @@ -774,7 +702,7 @@ func TestGenerateTokenExchangeEnvVarsShared(t *testing.T) { WithRuntimeObjects(externalAuth). Build() - ref := &mcpv1alpha1.ExternalAuthConfigRef{ + ref := &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-exchange", } @@ -800,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 @@ -808,80 +736,26 @@ 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{ + name: "valid proxy with no OIDC config", + proxy: &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{Name: "no-oidc-proxy", Namespace: "default"}, + Spec: mcpv1beta1.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", - proxy: &mcpv1alpha1.MCPRemoteProxy{ - ObjectMeta: metav1.ObjectMeta{Name: "https-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, - 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", - 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{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{"not valid cedar"}, }, }, @@ -889,25 +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", - 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{ + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeConfigMap, + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "does-not-exist", }, }, @@ -915,27 +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", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test", - }, - }, - 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", }, @@ -946,48 +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", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test", - }, - }, }, }, expectError: true, errContains: "scheme", - 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, + expectCondition: mcpv1beta1.ConditionReasonRemoteURLInvalid, conditionStatus: metav1.ConditionFalse, }, } @@ -1002,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) @@ -1024,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) @@ -1048,43 +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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, - 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", }, }, @@ -1092,28 +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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, - ToolConfigRef: &mcpv1alpha1.ToolConfigRef{ + ToolConfigRef: &mcpv1beta1.ToolConfigRef{ Name: "non-existent", }, }, }, expectError: true, - expectPhase: mcpv1alpha1.MCPRemoteProxyPhaseFailed, + expectPhase: mcpv1beta1.MCPRemoteProxyPhaseFailed, }, } @@ -1133,7 +951,7 @@ func TestValidateAndHandleConfigs(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithRuntimeObjects(objects...). - WithStatusSubresource(&mcpv1alpha1.MCPRemoteProxy{}). + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}). Build() reconciler := &MCPRemoteProxyReconciler{ @@ -1148,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 34cd5f2fb5..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 != "" { @@ -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 } @@ -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, + proxy *mcpv1beta1.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 } @@ -277,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 } @@ -308,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 @@ -337,10 +330,9 @@ 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, + proxy *mcpv1beta1.MCPRemoteProxy, options *[]runner.RunConfigBuilderOption, ) error { if proxy.Spec.TelemetryConfigRef != nil { @@ -353,8 +345,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/mcpremoteproxy_runconfig_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go index 24d4f8f249..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,28 +40,21 @@ 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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://login.salesforce.com", - Audience: "mcp.salesforce.com", - }, - }, }, }, expectError: false, @@ -72,41 +65,32 @@ 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") }, }, { 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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, - 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", @@ -127,24 +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, - 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{ + 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" };`, @@ -169,21 +146,14 @@ 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{ - RemoteURL: "https://mcp.example.com", - ProxyPort: 8080, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + ProxyPort: 8080, TrustProxyHeaders: true, }, }, @@ -196,22 +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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, - HeaderForward: &mcpv1alpha1.HeaderForwardConfig{ + HeaderForward: &mcpv1beta1.HeaderForwardConfig{ AddPlaintextHeaders: map[string]string{ "X-Tenant-ID": "tenant-123", "X-Correlation": "corr-abc", @@ -231,33 +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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, - 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", }, @@ -280,29 +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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, - 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", }, @@ -366,45 +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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.company.com", - Audience: "mcp-proxy", - }, - }, - 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", }, @@ -450,22 +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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "non-existent", }, }, @@ -518,43 +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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, - 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", }, @@ -583,34 +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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, - 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 }, }, @@ -619,35 +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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, - 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", }, @@ -658,35 +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, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: mcpv1alpha1.OIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "mcp-proxy", - }, - }, - 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", }, @@ -863,28 +770,21 @@ 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, - 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/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 c1b49c1be7..05f2af0c96 100644 --- a/cmd/thv-operator/controllers/mcpserver_controller.go +++ b/cmd/thv-operator/controllers/mcpserver_controller.go @@ -35,7 +35,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "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" @@ -53,9 +53,9 @@ type MCPServerReconciler struct { PlatformDetector *ctrlutil.SharedPlatformDetector } -func (*MCPServerReconciler) buildSessionRedisCredentialEnvVars(mcpServer *mcpv1alpha1.MCPServer) []corev1.EnvVar { +func (*MCPServerReconciler) buildSessionRedisCredentialEnvVars(mcpServer *mcpv1beta1.MCPServer) []corev1.EnvVar { if mcpServer.Spec.SessionStorage == nil || - mcpServer.Spec.SessionStorage.Provider != mcpv1alpha1.SessionStorageProviderRedis { + mcpServer.Spec.SessionStorage.Provider != mcpv1beta1.SessionStorageProviderRedis { return nil } @@ -200,7 +200,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) { @@ -272,8 +272,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") } @@ -283,8 +283,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") } @@ -295,8 +295,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") } @@ -306,8 +306,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") } @@ -317,8 +317,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") } @@ -334,9 +334,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") } @@ -346,9 +346,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") } @@ -361,9 +361,9 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( runConfigChecksum, err := r.ensureRunConfigConfigMap(ctx, mcpServer) if 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") } @@ -388,9 +388,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") } @@ -400,9 +400,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") } @@ -516,39 +516,40 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, nil } -func (r *MCPServerReconciler) validateGroupRef(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) { - if mcpServer.Spec.GroupRef == "" { +func (r *MCPServerReconciler) validateGroupRef(ctx context.Context, mcpServer *mcpv1beta1.MCPServer) { + 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 { + 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, - Message: fmt.Sprintf("MCPGroup '%s' not found in namespace '%s'", mcpServer.Spec.GroupRef, mcpServer.Namespace), + 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, - Message: fmt.Sprintf("MCPGroup '%s' is not ready (current phase: %s)", mcpServer.Spec.GroupRef, group.Status.Phase), + 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, - Message: fmt.Sprintf("MCPGroup '%s' is valid and ready", mcpServer.Spec.GroupRef), + Reason: mcpv1beta1.ConditionReasonGroupRefValidated, + Message: fmt.Sprintf("MCPGroup '%s' is valid and ready", groupName), ObservedGeneration: mcpServer.Generation, }) } @@ -559,28 +560,10 @@ 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) { +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, @@ -589,15 +572,15 @@ 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. -func (r *MCPServerReconciler) validateCABundleRef(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) { - caBundleRef := getCABundleRef(mcpServer.Spec.OIDCConfig) +// Checks the MCPOIDCConfig path for CA bundle references. +func (r *MCPServerReconciler) validateCABundleRef(ctx context.Context, mcpServer *mcpv1beta1.MCPServer) { + var caBundleRef *mcpv1beta1.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 && + oidcCfg.Spec.Type == mcpv1beta1.MCPOIDCConfigTypeInline && oidcCfg.Spec.Inline != nil { caBundleRef = oidcCfg.Spec.Inline.CABundleRef } @@ -612,7 +595,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 } @@ -622,7 +605,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 @@ -635,20 +618,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") @@ -656,9 +639,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, @@ -668,7 +651,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 @@ -686,19 +669,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 @@ -713,10 +696,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", }) @@ -730,7 +713,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 @@ -800,7 +783,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) @@ -816,7 +799,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") @@ -827,7 +810,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) @@ -855,7 +838,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 @@ -884,7 +867,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 @@ -927,7 +910,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) @@ -971,7 +954,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) @@ -1063,9 +1046,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 @@ -1081,23 +1061,12 @@ 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 && - 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, @@ -1141,7 +1110,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", @@ -1167,12 +1136,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) @@ -1367,7 +1332,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 @@ -1422,7 +1387,7 @@ func (r *MCPServerReconciler) serviceForMCPServer(ctx context.Context, m *mcpv1a return svc } -func mcpServerStatusURL(mcpServer *mcpv1alpha1.MCPServer, serviceName string) string { +func mcpServerStatusURL(mcpServer *mcpv1beta1.MCPServer, serviceName string) string { host := fmt.Sprintf("%s.%s.svc.cluster.local", serviceName, mcpServer.Namespace) return transport.GenerateMCPServerURL( mcpServer.Spec.Transport, @@ -1501,7 +1466,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 @@ -1509,10 +1474,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) } } @@ -1532,11 +1497,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 @@ -1561,28 +1526,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 @@ -1609,7 +1574,7 @@ func (r *MCPServerReconciler) deleteIfExists(ctx context.Context, obj client.Obj func (r *MCPServerReconciler) cleanupLegacyMCPServerResources( ctx context.Context, - m *mcpv1alpha1.MCPServer, + m *mcpv1beta1.MCPServer, ) (bool, error) { deleted := false @@ -1634,7 +1599,7 @@ func (r *MCPServerReconciler) cleanupLegacyMCPServerResources( func (r *MCPServerReconciler) deleteLegacyMCPServerResourceIfOwned( ctx context.Context, - m *mcpv1alpha1.MCPServer, + m *mcpv1beta1.MCPServer, obj client.Object, name string, kind string, @@ -1664,7 +1629,7 @@ func (r *MCPServerReconciler) deleteLegacyMCPServerResourceIfOwned( return true, nil } -func isLegacyMCPServerResourceFor(obj client.Object, m *mcpv1alpha1.MCPServer) bool { +func isLegacyMCPServerResourceFor(obj client.Object, m *mcpv1beta1.MCPServer) bool { if obj == nil || m == nil { return false } @@ -1686,11 +1651,11 @@ func isLegacyMCPServerResourceFor(obj client.Object, m *mcpv1alpha1.MCPServer) b } // 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 } @@ -1718,7 +1683,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 { @@ -1768,9 +1733,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 @@ -1786,25 +1748,14 @@ 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 } 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, @@ -1828,6 +1779,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) { @@ -1952,7 +1917,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 { if len(service.Spec.Ports) == 0 { return true } @@ -2005,7 +1970,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{} @@ -2066,7 +2031,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 @@ -2093,9 +2058,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", @@ -2132,10 +2097,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 { @@ -2148,9 +2113,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, @@ -2163,9 +2128,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, @@ -2174,9 +2139,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, }) @@ -2184,11 +2149,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, @@ -2200,9 +2165,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)), @@ -2215,9 +2180,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, }) @@ -2242,7 +2207,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 { @@ -2271,11 +2236,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 { @@ -2300,14 +2265,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") @@ -2317,7 +2282,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") @@ -2325,14 +2290,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") } @@ -2343,9 +2308,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, @@ -2357,11 +2322,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, } @@ -2382,7 +2347,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), ) @@ -2412,9 +2377,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, @@ -2425,14 +2390,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 { @@ -2441,9 +2406,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, @@ -2454,20 +2419,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 { @@ -2476,9 +2441,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, @@ -2490,11 +2455,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") @@ -2502,8 +2467,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 @@ -2518,11 +2482,11 @@ 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)") + 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 { @@ -2535,13 +2499,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 @@ -2570,12 +2534,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 @@ -2601,11 +2565,11 @@ func (r *MCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { telemetryConfigHandler := handler.EnqueueRequestsFromMapFunc(r.mapTelemetryConfigToServers) return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1alpha1.MCPServer{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + For(&mcpv1beta1.MCPServer{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). 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 537dc4bcab..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", @@ -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"}, }, @@ -261,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 }, }, @@ -292,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"}, }, }, @@ -328,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"}, }, }, @@ -367,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, }, }, @@ -394,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", }, @@ -429,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", }, @@ -473,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", }, @@ -521,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"}, @@ -556,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", @@ -638,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", }, @@ -722,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", }, }, @@ -740,55 +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", }, - // 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: &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", @@ -842,6 +838,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, &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: tt.mcpServer.Spec.OIDCConfigRef.Name, + Namespace: tt.mcpServer.Namespace, + }, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ + Issuer: "https://kubernetes.default.svc", + }, + }, + }) + } fakeClient := fake.NewClientBuilder(). WithScheme(scheme). @@ -871,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", }, }, @@ -896,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", }, @@ -939,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", }, }, @@ -968,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, }, }, @@ -998,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", }, }, @@ -1015,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"}, @@ -1051,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 988d3b346c..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: "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: "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: "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: "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: "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: "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: "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_legacy_cleanup_test.go b/cmd/thv-operator/controllers/mcpserver_legacy_cleanup_test.go index 7cda5d01ee..60b64bd4b9 100644 --- a/cmd/thv-operator/controllers/mcpserver_legacy_cleanup_test.go +++ b/cmd/thv-operator/controllers/mcpserver_legacy_cleanup_test.go @@ -16,7 +16,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/container/kubernetes" ) @@ -29,7 +29,7 @@ func TestMCPServerReconciler_cleanupLegacyMCPServerResources(t *testing.T) { ) scheme := createTestScheme() - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, @@ -88,7 +88,7 @@ func TestMCPServerReconciler_cleanupLegacyMCPServerResources_SkipsForeignObjects ) scheme := createTestScheme() - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, 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_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_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 c8496beaf6..db4e1beb82 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/cmd/thv-operator/pkg/runconfig/configmap/checksum" "github.com/stacklok/toolhive/pkg/container/kubernetes" ) @@ -70,19 +70,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", }, @@ -95,8 +95,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) @@ -181,18 +181,18 @@ func TestDeploymentForMCPServerWithPodTemplateSpec(t *testing.T) { func TestDeploymentForMCPServerPreservesRunConfigChecksumWithPodTemplateAnnotations(t *testing.T) { t.Parallel() - 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: "streamable-http", ProxyPort: 8080, - ResourceOverrides: &mcpv1alpha1.ResourceOverrides{ - ProxyDeployment: &mcpv1alpha1.ProxyDeploymentOverrides{ - PodTemplateMetadataOverrides: &mcpv1alpha1.ResourceMetadataOverrides{ + ResourceOverrides: &mcpv1beta1.ResourceOverrides{ + ProxyDeployment: &mcpv1beta1.ProxyDeploymentOverrides{ + PodTemplateMetadataOverrides: &mcpv1beta1.ResourceMetadataOverrides{ Annotations: map[string]string{ "toolhive.stacklok.dev/forced-reconcile-at": "2026-04-21T18:45:00Z", }, @@ -204,8 +204,8 @@ func TestDeploymentForMCPServerPreservesRunConfigChecksumWithPodTemplateAnnotati 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{}) r := newTestMCPServerReconciler(nil, s, kubernetes.PlatformKubernetes) deployment := r.deploymentForMCPServer(context.Background(), mcpServer, "test-checksum") @@ -219,12 +219,12 @@ func TestDeploymentForMCPServerPreservesRunConfigChecksumWithPodTemplateAnnotati 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, @@ -234,8 +234,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) @@ -250,17 +250,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", @@ -278,8 +278,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) @@ -360,12 +360,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, @@ -375,8 +375,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) @@ -407,12 +407,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, @@ -422,8 +422,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 08d9326e98..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", }, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{Type: "kubernetes"}, - 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, @@ -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(&mcpv1beta1.MCPServer{}) + + // Add referenced MCPOIDCConfig to fake client if spec references one + if mcpServer.Spec.OIDCConfigRef != nil { + oidcCfg := &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: mcpServer.Spec.OIDCConfigRef.Name, + Namespace: namespace, + }, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ + Issuer: "https://auth.example.com", + }, + }, + } + oidcCfg.Status.Conditions = []metav1.Condition{ + { + Type: mcpv1beta1.ConditionTypeValid, + Status: metav1.ConditionTrue, + Reason: "Valid", + LastTransitionTime: metav1.Now(), + }, + } + clientBuilder = clientBuilder. + WithObjects(oidcCfg). + WithStatusSubresource(&mcpv1beta1.MCPOIDCConfig{}) + } + + fakeClient := clientBuilder.Build() reconciler := newTestMCPServerReconciler(fakeClient, testScheme, kubernetes.PlatformKubernetes) @@ -1116,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 f65eb25323..dafebcef0d 100644 --- a/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go +++ b/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go @@ -29,20 +29,203 @@ 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" "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, mcpv1beta1.AddToScheme(scheme)) + + externalAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "embedded-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "google", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ + IssuerURL: "https://accounts.google.com", + ClientID: "client-id", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "upstream-secret", + Key: "client-secret", + }, + }, + }, + }, + }, + }, + } + + mcpServer := &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPServerSpec{ + Image: "test-image", + ProxyPort: 8080, + ExternalAuthConfigRef: &mcpv1beta1.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, mcpv1beta1.AddToScheme(scheme)) + + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "embedded-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "google", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ + IssuerURL: "https://accounts.google.com", + ClientID: "client-id", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "upstream-secret", + Key: "client-secret", + }, + }, + }, + }, + }, + }, + } + + mcpServer := &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPServerSpec{ + Image: "test-image", + ProxyPort: 8080, + AuthServerRef: &mcpv1beta1.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, mcpv1beta1.AddToScheme(scheme)) + + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "exchange-config", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "client-id", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "token-secret", + Key: "client-secret", + }, + Audience: "api", + }, + }, + } + + mcpServer := &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPServerSpec{ + Image: "test-image", + ProxyPort: 8080, + ExternalAuthConfigRef: &mcpv1beta1.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() 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 @@ -50,12 +233,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, }, @@ -79,17 +262,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", @@ -101,7 +284,7 @@ func TestResourceOverrides(t *testing.T) { }, }, }, - ProxyService: &mcpv1alpha1.ResourceMetadataOverrides{ + ProxyService: &mcpv1beta1.ResourceMetadataOverrides{ Labels: map[string]string{ "custom-label": "service-value", "environment": "test", @@ -144,22 +327,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", @@ -197,17 +380,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"}, }, }, @@ -233,17 +416,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", @@ -253,7 +436,7 @@ func TestResourceOverrides(t *testing.T) { "version": "v1.2.3", }, }, - Env: []mcpv1alpha1.EnvVar{ + Env: []mcpv1beta1.EnvVar{ { Name: "LOG_LEVEL", Value: "debug", @@ -264,7 +447,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", }, @@ -298,17 +481,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", @@ -345,7 +528,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) @@ -353,7 +536,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) @@ -464,30 +647,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"}, }, @@ -503,17 +686,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"}, }, @@ -529,17 +712,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"}, @@ -556,17 +739,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"}, }, }, @@ -581,12 +764,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, }, @@ -596,12 +779,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, }, @@ -619,7 +802,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) @@ -631,7 +814,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") @@ -650,14 +833,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), @@ -667,7 +850,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) } @@ -675,12 +858,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, }, @@ -705,7 +888,7 @@ func TestMCPServerServiceNeedsUpdate(t *testing.T) { tests := []struct { name string service *corev1.Service - mcpServer *mcpv1alpha1.MCPServer + mcpServer *mcpv1beta1.MCPServer needsUpdate bool }{ { @@ -721,7 +904,7 @@ func TestMCPServerServiceNeedsUpdate(t *testing.T) { s.Spec.Ports[0].Port = 8080 return s }(), - mcpServer: func() *mcpv1alpha1.MCPServer { + mcpServer: func() *mcpv1beta1.MCPServer { m := baseMCPServer.DeepCopy() m.Spec.ProxyPort = 8000 return m @@ -736,7 +919,7 @@ func TestMCPServerServiceNeedsUpdate(t *testing.T) { s.Spec.Ports[0].TargetPort = intstr.FromInt(8080) return s }(), - mcpServer: func() *mcpv1alpha1.MCPServer { + mcpServer: func() *mcpv1beta1.MCPServer { m := baseMCPServer.DeepCopy() m.Spec.ProxyPort = 8000 return m @@ -766,7 +949,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 @@ -780,7 +963,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 @@ -801,12 +984,12 @@ func TestMCPServerServiceNeedsUpdate(t *testing.T) { func TestMCPServerStatusURLTracksProxyPort(t *testing.T) { t.Parallel() - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "aws-mcp", Namespace: "toolhive-operator", }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Transport: "streamable-http", ProxyPort: 8000, }, @@ -827,12 +1010,12 @@ func TestReconcileUpdatesProxyServiceBeforeDeployment(t *testing.T) { namespace = "toolhive-operator" ) - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, - Spec: mcpv1alpha1.MCPServerSpec{ + Spec: mcpv1beta1.MCPServerSpec{ Image: "public.ecr.aws/awslabs-mcp/awslabs/aws-api-mcp-server:1.3.31", Transport: "streamable-http", ProxyPort: 8000, @@ -885,7 +1068,7 @@ func TestReconcileUpdatesProxyServiceBeforeDeployment(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) 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 1098e4596f..779307cab6 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" @@ -39,7 +39,7 @@ const defaultAPITimeout = 15 * time.Second // immediately after an update. func (r *MCPServerReconciler) ensureRunConfigConfigMap( ctx context.Context, - m *mcpv1alpha1.MCPServer, + m *mcpv1beta1.MCPServer, ) (string, error) { runConfig, err := r.createRunConfigFromMCPServer(m) if err != nil { @@ -88,7 +88,7 @@ func (r *MCPServerReconciler) ensureRunConfigConfigMap( // 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) @@ -172,13 +172,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( @@ -192,7 +192,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 { @@ -202,8 +202,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 @@ -244,19 +242,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) @@ -312,9 +297,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 @@ -573,7 +558,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 } @@ -585,7 +570,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 8bd690034b..86f2e8acaf 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, }, @@ -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 { @@ -546,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() @@ -600,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 }, @@ -735,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 @@ -830,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");`, @@ -882,68 +811,18 @@ 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{ + 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, }, }, @@ -1023,18 +902,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", }, @@ -1325,7 +1204,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"}, }) @@ -1356,7 +1235,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"}, } @@ -1405,16 +1284,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 @@ -1427,12 +1306,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{}{ @@ -1441,15 +1320,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"}, } @@ -1460,15 +1339,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"}, } @@ -1481,12 +1360,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{}{ @@ -1568,17 +1447,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, @@ -1600,18 +1479,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", @@ -1625,12 +1504,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, @@ -1683,12 +1562,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, @@ -1700,7 +1579,7 @@ func TestPopulateScalingConfig(t *testing.T) { }, { name: "backendReplicas set — written to ScalingConfig", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, @@ -1715,7 +1594,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, @@ -1730,7 +1609,7 @@ func TestPopulateScalingConfig(t *testing.T) { }, { name: "sessionStorage nil — SessionRedis stays nil", - spec: mcpv1alpha1.MCPServerSpec{ + spec: mcpv1beta1.MCPServerSpec{ Image: testImage, Transport: stdioTransport, ProxyPort: 8080, @@ -1744,11 +1623,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", }, }, @@ -1759,11 +1638,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, @@ -1781,14 +1660,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", }, @@ -1815,7 +1694,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, } @@ -1839,27 +1718,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 }, @@ -1881,7 +1760,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_sessionstorage_env_test.go b/cmd/thv-operator/controllers/mcpserver_sessionstorage_env_test.go index 2f4b7c3d8e..79aaaffb3b 100644 --- a/cmd/thv-operator/controllers/mcpserver_sessionstorage_env_test.go +++ b/cmd/thv-operator/controllers/mcpserver_sessionstorage_env_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" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -20,20 +20,20 @@ import ( func TestDeploymentForMCPServer_WithRedisCredentials(t *testing.T) { t.Parallel() - usernameRef := &mcpv1alpha1.SecretKeyRef{Name: "redis-session-user", Key: "username"} - passwordRef := &mcpv1alpha1.SecretKeyRef{Name: "redis-secret", Key: "password"} + usernameRef := &mcpv1beta1.SecretKeyRef{Name: "redis-session-user", Key: "username"} + passwordRef := &mcpv1beta1.SecretKeyRef{Name: "redis-secret", Key: "password"} - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-mcp-redis", Namespace: "default", }, - 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", UsernameRef: usernameRef, PasswordRef: passwordRef, @@ -42,12 +42,12 @@ func TestDeploymentForMCPServer_WithRedisCredentials(t *testing.T) { } scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(mcpServer). - WithStatusSubresource(&mcpv1alpha1.MCPServer{}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). Build() r := &MCPServerReconciler{ @@ -89,12 +89,12 @@ func TestBuildSessionRedisCredentialEnvVars(t *testing.T) { t.Parallel() r := &MCPServerReconciler{} - usernameRef := &mcpv1alpha1.SecretKeyRef{Name: "redis-session-user", Key: "username"} - passwordRef := &mcpv1alpha1.SecretKeyRef{Name: "redis-secret", Key: "password"} + usernameRef := &mcpv1beta1.SecretKeyRef{Name: "redis-session-user", Key: "username"} + passwordRef := &mcpv1beta1.SecretKeyRef{Name: "redis-secret", Key: "password"} tests := []struct { name string - storage *mcpv1alpha1.SessionStorageConfig + storage *mcpv1beta1.SessionStorageConfig expectEnvVarNum int }{ { @@ -104,23 +104,23 @@ func TestBuildSessionRedisCredentialEnvVars(t *testing.T) { }, { name: "memory provider produces no env var", - storage: &mcpv1alpha1.SessionStorageConfig{Provider: "memory"}, + storage: &mcpv1beta1.SessionStorageConfig{Provider: "memory"}, expectEnvVarNum: 0, }, { name: "redis without credentials produces no env var", - storage: &mcpv1alpha1.SessionStorageConfig{Provider: mcpv1alpha1.SessionStorageProviderRedis, Address: "redis:6379"}, + storage: &mcpv1beta1.SessionStorageConfig{Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379"}, expectEnvVarNum: 0, }, { name: "redis with passwordRef produces redis password env var", - storage: &mcpv1alpha1.SessionStorageConfig{Provider: mcpv1alpha1.SessionStorageProviderRedis, Address: "redis:6379", PasswordRef: passwordRef}, + storage: &mcpv1beta1.SessionStorageConfig{Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379", PasswordRef: passwordRef}, expectEnvVarNum: 1, }, { name: "redis with usernameRef and passwordRef produces both redis credential env vars", - storage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + storage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379", UsernameRef: usernameRef, PasswordRef: passwordRef, @@ -133,9 +133,9 @@ func TestBuildSessionRedisCredentialEnvVars(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-mcp", Namespace: "default"}, - Spec: mcpv1alpha1.MCPServerSpec{SessionStorage: tc.storage}, + Spec: mcpv1beta1.MCPServerSpec{SessionStorage: tc.storage}, } env := r.buildSessionRedisCredentialEnvVars(mcpServer) 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 f274262eed..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,20 @@ 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) - group := &mcpv1alpha1.MCPGroup{} - groupKey := types.NamespacedName{Namespace: entry.Namespace, Name: entry.Spec.GroupRef} + groupName := entry.Spec.GroupRef.GetName() + 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, - Message: fmt.Sprintf("MCPGroup '%s' not found in namespace '%s'", entry.Spec.GroupRef, entry.Namespace), + Reason: mcpv1beta1.ConditionReasonMCPServerEntryGroupRefNotFound, + Message: fmt.Sprintf("MCPGroup '%s' not found in namespace '%s'", groupName, entry.Namespace), ObservedGeneration: entry.Generation, }) return false, nil @@ -180,21 +181,21 @@ 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, - Message: fmt.Sprintf("MCPGroup '%s' is not ready (current phase: %s)", entry.Spec.GroupRef, group.Status.Phase), + Reason: mcpv1beta1.ConditionReasonMCPServerEntryGroupRefNotReady, + Message: fmt.Sprintf("MCPGroup '%s' is not ready (current phase: %s)", groupName, group.Status.Phase), ObservedGeneration: entry.Generation, }) return false, nil } 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, }) @@ -205,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, @@ -228,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, }) @@ -241,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, }) @@ -254,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, }) @@ -277,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, }) @@ -290,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, }) @@ -302,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, }) @@ -316,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, }) @@ -327,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, }) @@ -359,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}, @@ -393,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}, @@ -434,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 c138fde906..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: 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,45 +400,45 @@ 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: 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: 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) - if s.Spec.GroupRef == "" { + 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} + return []string{s.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - p := obj.(*mcpv1alpha1.MCPRemoteProxy) - if p.Spec.GroupRef == "" { + 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} + return []string{p.Spec.GroupRef.GetName()} }). - WithIndex(&mcpv1alpha1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { - e := obj.(*mcpv1alpha1.MCPServerEntry) - if e.Spec.GroupRef == "" { + WithIndex(&mcpv1beta1.MCPServerEntry{}, "spec.groupRef", func(obj client.Object) []string { + e := obj.(*mcpv1beta1.MCPServerEntry) + if e.Spec.GroupRef.GetName() == "" { return nil } - return []string{e.Spec.GroupRef} + return []string{e.Spec.GroupRef.GetName()} }). Build() @@ -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: 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: 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 4b061e7a98..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" ) @@ -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. @@ -52,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) { @@ -82,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(), @@ -96,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", @@ -148,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 } @@ -168,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 @@ -179,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 } @@ -191,12 +192,16 @@ 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( + &mcpv1beta1.VirtualMCPServer{}, + handler.EnqueueRequestsFromMapFunc(r.mapVirtualMCPServerToTelemetryConfig), + ). Complete(r) } @@ -206,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 } @@ -225,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 @@ -236,7 +241,53 @@ 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 + } + } + } + + 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.(*mcpv1beta1.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 := &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 + } + 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 == mcpv1beta1.WorkloadKindVirtualMCPServer && ref.Name == vmcp.Name { requests = append(requests, reconcile.Request{NamespacedName: nn}) break } @@ -247,7 +298,7 @@ func (r *MCPTelemetryConfigReconciler) mapMCPRemoteProxyToTelemetryConfig( } // 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) } @@ -255,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) @@ -278,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, @@ -304,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 } @@ -318,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 } @@ -328,10 +379,21 @@ func (r *MCPTelemetryConfigReconciler) findReferencingWorkloads( return nil, err } - refs := make([]mcpv1alpha1.WorkloadReference, 0, len(serverRefs)+len(proxies)) + // Check VirtualMCPServers + 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([]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, mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindVirtualMCPServer, Name: vmcp.Name}) + } } ctrlutil.SortWorkloadRefs(refs) return refs, nil 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 181d124fc8..929b5454e9 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -36,7 +36,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "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" @@ -130,6 +130,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. @@ -137,7 +138,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) { @@ -161,17 +162,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") @@ -205,7 +209,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 +243,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 +251,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 +260,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 +269,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 +310,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 +366,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 +379,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 +412,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 +432,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, ) @@ -439,9 +443,27 @@ 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 := mcpv1beta1.ValidateAdditionalAuthorizationParams(prefix, params); err != nil { + message := err.Error() + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) + statusManager.SetMessage(message) + statusManager.SetAuthServerConfigValidatedCondition( + mcpv1beta1.ConditionReasonAuthServerConfigInvalid, + message, + metav1.ConditionFalse, + ) + statusManager.SetObservedGeneration(vmcp.Generation) + return fmt.Errorf("%s", message) + } + } + // AuthServerConfig is valid statusManager.SetAuthServerConfigValidatedCondition( - mcpv1alpha1.ConditionReasonAuthServerConfigValid, + mcpv1beta1.ConditionReasonAuthServerConfigValid, "AuthServerConfig is valid", metav1.ConditionTrue, ) @@ -455,7 +477,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 { @@ -474,24 +496,24 @@ 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.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) - statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed) + message := fmt.Sprintf("Referenced MCPGroup %s not found", vmcp.ResolveGroupName()) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) statusManager.SetMessage(message) statusManager.SetGroupRefValidatedCondition( - mcpv1alpha1.ConditionReasonVirtualMCPServerGroupRefNotFound, + mcpv1beta1.ConditionReasonVirtualMCPServerGroupRefNotFound, message, metav1.ConditionFalse, ) @@ -503,25 +525,25 @@ 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.Spec.Config.Group, mcpGroup.Status.Phase) - statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhasePending) + vmcp.ResolveGroupName(), mcpGroup.Status.Phase) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhasePending) statusManager.SetMessage(message) statusManager.SetGroupRefValidatedCondition( - mcpv1alpha1.ConditionReasonVirtualMCPServerGroupRefNotReady, + mcpv1beta1.ConditionReasonVirtualMCPServerGroupRefNotReady, message, metav1.ConditionFalse, ) 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), + mcpv1beta1.ConditionReasonVirtualMCPServerGroupRefValid, + fmt.Sprintf("MCPGroup %s is valid and ready", vmcp.ResolveGroupName()), metav1.ConditionTrue, ) statusManager.SetObservedGeneration(vmcp.Generation) @@ -532,7 +554,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) @@ -542,7 +564,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, ) @@ -552,7 +574,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, @@ -561,10 +583,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, ) @@ -575,16 +597,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, ) @@ -593,7 +615,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) } @@ -602,7 +624,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, ) @@ -615,7 +637,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) @@ -635,11 +657,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, ) @@ -651,8 +673,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, ) @@ -662,11 +684,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, + vmcp *mcpv1beta1.VirtualMCPServer, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, statusManager virtualmcpserverstatus.StatusManager, ) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) @@ -686,10 +711,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, ) @@ -699,7 +724,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, ) @@ -709,7 +734,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) @@ -730,7 +755,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 @@ -741,7 +767,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 @@ -762,14 +788,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, ) @@ -782,7 +808,7 @@ func (r *VirtualMCPServerReconciler) ensureAuthSecretsValid( } statusManager.SetAuthConfiguredCondition( - mcpv1alpha1.ConditionReasonAuthValid, + mcpv1beta1.ConditionReasonAuthValid, "Authentication configuration is valid", metav1.ConditionTrue, ) @@ -801,7 +827,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 { @@ -841,7 +867,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) @@ -926,7 +952,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) @@ -992,7 +1018,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") @@ -1031,7 +1057,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, + vmcp *mcpv1beta1.VirtualMCPServer, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) (ctrl.Result, error) { ctxLogger := log.FromContext(ctx) @@ -1052,7 +1079,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") } @@ -1086,8 +1113,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") } @@ -1143,7 +1170,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) @@ -1224,7 +1251,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 == "" { @@ -1237,8 +1264,9 @@ func (*VirtualMCPServerReconciler) ensureServiceURL( func (r *VirtualMCPServerReconciler) deploymentNeedsUpdate( ctx context.Context, deployment *appsv1.Deployment, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, vmcpConfigChecksum string, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) bool { if deployment == nil || vmcp == nil { @@ -1249,7 +1277,7 @@ func (r *VirtualMCPServerReconciler) deploymentNeedsUpdate( return true } - if r.containerNeedsUpdate(ctx, deployment, vmcp, typedWorkloads) { + if r.containerNeedsUpdate(ctx, deployment, vmcp, telemetryCfg, typedWorkloads) { return true } @@ -1280,7 +1308,8 @@ func (r *VirtualMCPServerReconciler) deploymentNeedsUpdate( func (r *VirtualMCPServerReconciler) containerNeedsUpdate( ctx context.Context, deployment *appsv1.Deployment, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) bool { if deployment == nil || vmcp == nil || len(deployment.Spec.Template.Spec.Containers) == 0 { @@ -1307,7 +1336,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 } @@ -1324,7 +1353,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 @@ -1357,7 +1386,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 { @@ -1392,7 +1421,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 { @@ -1444,7 +1473,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 @@ -1531,24 +1560,27 @@ 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 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 []mcpv1beta1.DiscoveredBackend) (routable, unhealthy int) { ctxLogger := log.FromContext(ctx) for _, backend := range backends { switch backend.Status { - case mcpv1alpha1.BackendStatusReady: - ready++ - case mcpv1alpha1.BackendStatusUnavailable, - mcpv1alpha1.BackendStatusDegraded, - mcpv1alpha1.BackendStatusUnknown: + case mcpv1beta1.BackendStatusReady, mcpv1beta1.BackendStatusUnauthenticated: + routable++ + case mcpv1beta1.BackendStatusUnavailable, + mcpv1beta1.BackendStatusDegraded, + mcpv1beta1.BackendStatusUnknown: unhealthy++ default: ctxLogger.V(1).Info("Unexpected backend status, treating as unhealthy", @@ -1556,23 +1588,23 @@ func countBackendHealth(ctx context.Context, backends []mcpv1alpha1.DiscoveredBa unhealthy++ } } - return ready, unhealthy + return routable, unhealthy } // determineStatusFromBackends evaluates backend health to determine status func (*VirtualMCPServerReconciler) determineStatusFromBackends( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) 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, + 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", @@ -1583,18 +1615,18 @@ func (*VirtualMCPServerReconciler) determineStatusFromBackends( // Some backends unhealthy if unhealthy > 0 { return statusDecision{ - phase: mcpv1alpha1.VirtualMCPServerPhaseDegraded, - message: fmt.Sprintf("Virtual MCP server is running with %d/%d backends available", ready, total), + 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", conditionState: metav1.ConditionFalse, } } - // All backends ready - if ready > 0 { + // 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", @@ -1606,7 +1638,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", @@ -1620,14 +1652,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", @@ -1640,7 +1672,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", @@ -1652,7 +1684,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", @@ -1666,7 +1698,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 @@ -1741,7 +1773,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 } @@ -1751,7 +1783,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 @@ -1797,7 +1829,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() @@ -1832,10 +1864,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 @@ -1864,12 +1896,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] } @@ -1880,12 +1912,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] } @@ -1896,12 +1928,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] } @@ -1913,7 +1945,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) { @@ -1995,9 +2027,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: @@ -2039,7 +2071,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 @@ -2105,7 +2137,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 || @@ -2177,7 +2209,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) @@ -2187,7 +2219,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, @@ -2195,10 +2227,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, ) @@ -2224,12 +2256,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 @@ -2255,43 +2287,49 @@ func (r *VirtualMCPServerReconciler) mapEmbeddingServerToVirtualMCPServer( func (r *VirtualMCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). WithOptions(controller.Options{MaxConcurrentReconciles: 3}). - For(&mcpv1alpha1.VirtualMCPServer{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + For(&mcpv1beta1.VirtualMCPServer{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). 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( + &mcpv1beta1.MCPTelemetryConfig{}, + handler.EnqueueRequestsFromMapFunc(r.mapTelemetryConfigToVirtualMCPServer), + ). Complete(r) } // 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 @@ -2299,7 +2337,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, @@ -2323,7 +2361,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 } @@ -2332,7 +2370,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 @@ -2361,7 +2399,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 @@ -2370,7 +2408,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, @@ -2379,7 +2417,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) } } @@ -2404,7 +2442,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 } @@ -2413,7 +2451,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 @@ -2442,7 +2480,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 @@ -2451,7 +2489,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, @@ -2460,7 +2498,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) } } @@ -2485,7 +2523,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 } @@ -2493,7 +2531,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 @@ -2519,7 +2557,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 @@ -2527,7 +2565,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, @@ -2536,7 +2574,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) } } @@ -2554,12 +2592,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 @@ -2584,12 +2622,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 @@ -2611,7 +2649,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 } @@ -2630,7 +2668,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 @@ -2671,22 +2709,22 @@ 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.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 } @@ -2697,7 +2735,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", @@ -2714,7 +2752,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", @@ -2739,12 +2777,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 @@ -2767,7 +2805,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 { @@ -2930,12 +2968,27 @@ 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 *mcpv1beta1.VirtualMCPServer, + statusManager virtualmcpserverstatus.StatusManager, +) (*mcpv1beta1.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. func (r *VirtualMCPServerReconciler) handleOIDCConfig( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, ) error { ctxLogger := log.FromContext(ctx) @@ -2954,8 +3007,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, ) @@ -2964,8 +3017,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, ) @@ -2973,15 +3026,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, ) @@ -2996,8 +3049,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, ) @@ -3020,10 +3073,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 { @@ -3044,12 +3097,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 7b0e9e6fa7..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{ - Config: vmcpconfig.Config{Group: 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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: 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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: 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) @@ -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) @@ -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{ - Config: vmcpconfig.Config{Group: 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{ - Config: vmcpconfig.Config{Group: 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{ - Config: vmcpconfig.Config{Group: 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{ - Config: vmcpconfig.Config{Group: testGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -1166,66 +1166,73 @@ 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", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + name: "OIDC with missing client secret via MCPOIDCConfig", + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - 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", - }, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "oidc", + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, + }, + }, + }, + secrets: []client.Object{ + &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ + Issuer: "https://issuer.example.com", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "missing-secret", + Key: "client-secret", }, }, }, }, }, - secrets: []client.Object{}, expectAuthCondition: true, expectedAuthStatus: metav1.ConditionFalse, - expectedAuthReason: mcpv1alpha1.ConditionReasonAuthInvalid, + expectedAuthReason: mcpv1beta1.ConditionReasonAuthInvalid, expectError: true, }, { - name: "OIDC with valid client secret", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + name: "OIDC with valid client secret via MCPOIDCConfig", + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - 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", - }, - }, - }, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "oidc", + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, }, }, }, secrets: []client.Object{ + &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ + Issuer: "https://issuer.example.com", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "oidc-secret", + Key: "client-secret", + }, + }, + }, + }, &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "oidc-secret", @@ -1238,35 +1245,38 @@ 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", - vmcp: &mcpv1alpha1.VirtualMCPServer{ + name: "OIDC secret exists but missing required key via MCPOIDCConfig", + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - 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", - }, - }, - }, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "oidc", + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "test-oidc", Audience: "test-audience"}, }, }, }, secrets: []client.Object{ + &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ + Issuer: "https://issuer.example.com", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "oidc-secret", + Key: "client-secret", + }, + }, + }, + }, &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "oidc-secret", @@ -1279,7 +1289,7 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { }, expectAuthCondition: true, expectedAuthStatus: metav1.ConditionFalse, - expectedAuthReason: mcpv1alpha1.ConditionReasonAuthInvalid, + expectedAuthReason: mcpv1beta1.ConditionReasonAuthInvalid, expectError: true, }, } @@ -1293,7 +1303,7 @@ func TestVirtualMCPServerAuthConfiguredCondition(t *testing.T) { fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). - WithStatusSubresource(&mcpv1alpha1.VirtualMCPServer{}). + WithStatusSubresource(&mcpv1beta1.VirtualMCPServer{}). Build() r := &VirtualMCPServerReconciler{ @@ -1303,7 +1313,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) @@ -1318,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 } @@ -1337,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) @@ -1369,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{ - Config: vmcpconfig.Config{Group: 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 }, @@ -1399,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{ - Config: vmcpconfig.Config{Group: 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, @@ -1419,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{ - Config: vmcpconfig.Config{Group: 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) @@ -1452,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) @@ -1479,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, @@ -1488,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) } } }) @@ -1499,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{ - Config: vmcpconfig.Config{Group: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -1526,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) @@ -1539,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{ - Config: vmcpconfig.Config{Group: 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(). @@ -1597,7 +1607,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) @@ -1612,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) @@ -1620,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{ - Config: vmcpconfig.Config{Group: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } tests := []struct { name string deployment *appsv1.Deployment - vmcp *mcpv1alpha1.VirtualMCPServer + vmcp *mcpv1beta1.VirtualMCPServer expectedUpdate bool }{ { @@ -1796,12 +1806,13 @@ func TestVirtualMCPServerContainerNeedsUpdate(t *testing.T) { }, }, }, - vmcp: &mcpv1alpha1.VirtualMCPServer{ + vmcp: &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, Config: vmcpconfig.Config{ Group: testGroupName, Operational: &vmcpconfig.OperationalConfig{ @@ -1868,7 +1879,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) }) } @@ -1879,7 +1890,7 @@ func TestVirtualMCPServerDeploymentMetadataNeedsUpdate(t *testing.T) { reconciler := &VirtualMCPServerReconciler{} - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testVmcpName, Namespace: "default", @@ -1889,7 +1900,7 @@ func TestVirtualMCPServerDeploymentMetadataNeedsUpdate(t *testing.T) { tests := []struct { name string deployment *appsv1.Deployment - vmcp *mcpv1alpha1.VirtualMCPServer + vmcp *mcpv1beta1.VirtualMCPServer expectedUpdate bool }{ { @@ -1961,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", @@ -1982,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 }{ @@ -2095,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) @@ -2103,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{ - Config: vmcpconfig.Config{Group: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -2259,7 +2270,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) }) } @@ -2269,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{ - Config: vmcpconfig.Config{Group: 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, }, } @@ -2387,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, @@ -2402,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{ - Config: vmcpconfig.Config{Group: "nonexistent-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "nonexistent-group"}, }, } @@ -2443,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) } @@ -2458,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{ - Config: vmcpconfig.Config{Group: 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 }, } @@ -2509,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 @@ -2554,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{ - Config: vmcpconfig.Config{Group: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -2579,7 +2590,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) @@ -2590,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{ - Config: vmcpconfig.Config{Group: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -2628,7 +2639,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) @@ -2647,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{ - Config: vmcpconfig.Config{Group: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -2711,7 +2722,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) @@ -2730,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{ - Config: vmcpconfig.Config{Group: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -2807,7 +2818,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) @@ -2817,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{ - Config: vmcpconfig.Config{Group: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -2859,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{ - Config: vmcpconfig.Config{Group: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, ServiceType: "LoadBalancer", }, } @@ -2921,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{ - Config: vmcpconfig.Config{Group: testGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName}, }, } @@ -2978,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{ - Config: vmcpconfig.Config{Group: 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{ - Config: vmcpconfig.Config{Group: 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, }, }, @@ -3025,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{ - Config: vmcpconfig.Config{Group: 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{ - Config: vmcpconfig.Config{Group: 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, }, }, @@ -3069,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{ - Config: vmcpconfig.Config{Group: 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, }, }, @@ -3101,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) @@ -3115,8 +3126,8 @@ func TestVirtualMCPServerValidateEmbeddingServerRef(t *testing.T) { WithScheme(scheme). WithObjects(objs...). WithStatusSubresource( - &mcpv1alpha1.VirtualMCPServer{}, - &mcpv1alpha1.EmbeddingServer{}, + &mcpv1beta1.VirtualMCPServer{}, + &mcpv1beta1.EmbeddingServer{}, ). Build() @@ -3137,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) } @@ -3155,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{ - Config: vmcpconfig.Config{Group: 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{ @@ -3201,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) @@ -3216,7 +3227,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) @@ -3234,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{ - Config: vmcpconfig.Config{Group: 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{ @@ -3280,7 +3291,7 @@ func TestVirtualMCPServerEnsureDeployment_ReplicaSync_NilPassthrough(t *testing. } scheme := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(scheme) + _ = mcpv1beta1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) _ = appsv1.AddToScheme(scheme) @@ -3295,7 +3306,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) @@ -3311,8 +3322,8 @@ 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{}) +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()) } @@ -3323,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 607b97d9b9..7fa82c9a71 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" @@ -121,11 +121,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, + vmcp *mcpv1beta1.VirtualMCPServer, vmcpConfigChecksum string, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) *appsv1.Deployment { ls := labelsForVirtualMCPServer(vmcp.Name) @@ -137,7 +140,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 @@ -152,6 +155,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) @@ -198,7 +208,7 @@ func (r *VirtualMCPServerReconciler) deploymentForVirtualMCPServer( vmcpLivenessInitialDelay, vmcpLivenessPeriod, vmcpLivenessTimeout, vmcpLivenessFailures, ), ReadinessProbe: ctrlutil.BuildHealthProbe( - "/health", "http", + "/readyz", "http", vmcpReadinessInitialDelay, vmcpReadinessPeriod, vmcpReadinessTimeout, vmcpReadinessFailures, ), SecurityContext: containerSecurityContext, @@ -240,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", @@ -262,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{} @@ -287,23 +297,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...) - } } } @@ -312,10 +316,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, + vmcp *mcpv1beta1.VirtualMCPServer, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) ([]corev1.EnvVar, error) { env := []corev1.EnvVar{} @@ -357,12 +363,19 @@ func (r *VirtualMCPServerReconciler) buildEnvVarsForVmcp( // Mount Redis password secret when session storage provider is Redis. env = append(env, r.buildRedisCredentialEnvVars(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 } // 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 @@ -370,25 +383,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) @@ -397,7 +392,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{ @@ -420,7 +415,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{ @@ -438,9 +433,9 @@ func (*VirtualMCPServerReconciler) buildHMACSecretEnvVar(vmcp *mcpv1alpha1.Virtu // buildRedisCredentialEnvVars returns the session Redis credential env vars when // sessionStorage.provider == "redis" and usernameRef/passwordRef are set. -func (*VirtualMCPServerReconciler) buildRedisCredentialEnvVars(vmcp *mcpv1alpha1.VirtualMCPServer) []corev1.EnvVar { +func (*VirtualMCPServerReconciler) buildRedisCredentialEnvVars(vmcp *mcpv1beta1.VirtualMCPServer) []corev1.EnvVar { if vmcp.Spec.SessionStorage == nil || - vmcp.Spec.SessionStorage.Provider != mcpv1alpha1.SessionStorageProviderRedis { + vmcp.Spec.SessionStorage.Provider != mcpv1beta1.SessionStorageProviderRedis { return nil } @@ -477,7 +472,7 @@ func (*VirtualMCPServerReconciler) buildRedisCredentialEnvVars(vmcp *mcpv1alpha1 // 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 @@ -521,7 +516,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) @@ -582,6 +577,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 } @@ -589,7 +593,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 @@ -628,6 +632,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 } @@ -648,10 +659,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 } @@ -661,7 +672,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 } @@ -671,26 +682,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 @@ -721,7 +732,7 @@ func sortEnvVarsByName(envVars []corev1.EnvVar) { // 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) @@ -744,7 +755,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 @@ -759,7 +770,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() @@ -777,7 +788,7 @@ func (r *VirtualMCPServerReconciler) buildSecurityContextsForVmcp( // buildContainerPortsForVmcp builds container port configuration func (*VirtualMCPServerReconciler) buildContainerPortsForVmcp( - _ *mcpv1alpha1.VirtualMCPServer, + _ *mcpv1beta1.VirtualMCPServer, ) []corev1.ContainerPort { return []corev1.ContainerPort{{ ContainerPort: vmcpDefaultPort, @@ -789,7 +800,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) @@ -841,7 +852,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) @@ -866,7 +877,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 @@ -875,20 +886,8 @@ 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 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( @@ -898,7 +897,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, @@ -932,7 +931,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 @@ -944,7 +943,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 { @@ -980,7 +979,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) @@ -1039,7 +1038,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 81d8a8b585..e4bbfb118d 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,25 +40,25 @@ 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{ - Config: vmcpconfig.Config{Group: "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, 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) @@ -96,18 +96,18 @@ func TestDeploymentForVirtualMCPServer(t *testing.T) { func TestDeploymentForVirtualMCPServer_WithRedisCredentials(t *testing.T) { t.Parallel() - usernameRef := &mcpv1alpha1.SecretKeyRef{Name: "session-redis-credentials", Key: "username"} - passwordRef := &mcpv1alpha1.SecretKeyRef{Name: "redis-secret", Key: "password"} + usernameRef := &mcpv1beta1.SecretKeyRef{Name: "session-redis-credentials", Key: "username"} + 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{ - Config: vmcpconfig.Config{Group: "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", UsernameRef: usernameRef, PasswordRef: passwordRef, @@ -116,14 +116,14 @@ func TestDeploymentForVirtualMCPServer_WithRedisCredentials(t *testing.T) { } scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) r := &VirtualMCPServerReconciler{ Scheme: scheme, 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) @@ -158,32 +158,32 @@ 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{ - Config: vmcpconfig.Config{Group: "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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", Operational: &vmcpconfig.OperationalConfig{ LogLevel: "debug", }, @@ -210,13 +210,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{ - Config: vmcpconfig.Config{Group: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } @@ -240,18 +240,18 @@ 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{ - Config: vmcpconfig.Config{Group: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, } 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, VMCP_NAMESPACE, and POD_IP @@ -288,12 +288,12 @@ func TestBuildRedisCredentialEnvVars(t *testing.T) { r := &VirtualMCPServerReconciler{} - usernameRef := &mcpv1alpha1.SecretKeyRef{Name: "session-redis-credentials", Key: "username"} - passwordRef := &mcpv1alpha1.SecretKeyRef{Name: "redis-secret", Key: "password"} + usernameRef := &mcpv1beta1.SecretKeyRef{Name: "session-redis-credentials", Key: "username"} + passwordRef := &mcpv1beta1.SecretKeyRef{Name: "redis-secret", Key: "password"} tests := []struct { name string - storage *mcpv1alpha1.SessionStorageConfig + storage *mcpv1beta1.SessionStorageConfig expectedNames []string }{ { @@ -303,18 +303,18 @@ func TestBuildRedisCredentialEnvVars(t *testing.T) { }, { name: "memory provider produces no env var", - storage: &mcpv1alpha1.SessionStorageConfig{Provider: "memory"}, + storage: &mcpv1beta1.SessionStorageConfig{Provider: "memory"}, expectedNames: nil, }, { name: "redis without credential refs produces no env var", - storage: &mcpv1alpha1.SessionStorageConfig{Provider: mcpv1alpha1.SessionStorageProviderRedis, Address: "redis:6379"}, + storage: &mcpv1beta1.SessionStorageConfig{Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379"}, expectedNames: nil, }, { name: "redis with passwordRef produces THV_SESSION_REDIS_PASSWORD", - storage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + storage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379", PasswordRef: passwordRef, }, @@ -322,8 +322,8 @@ func TestBuildRedisCredentialEnvVars(t *testing.T) { }, { name: "redis with usernameRef and passwordRef produces both env vars", - storage: &mcpv1alpha1.SessionStorageConfig{ - Provider: mcpv1alpha1.SessionStorageProviderRedis, + storage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.SessionStorageProviderRedis, Address: "redis:6379", UsernameRef: usernameRef, PasswordRef: passwordRef, @@ -335,9 +335,9 @@ func TestBuildRedisCredentialEnvVars(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.buildRedisCredentialEnvVars(vmcp) require.Len(t, env, len(tc.expectedNames)) @@ -366,18 +366,18 @@ func TestBuildOutgoingAuthEnvVars_InlineBackendsDeterministicOrder(t *testing.T) t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - authConfigA := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfigA := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-a", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "Authorization", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "auth-a-secret", Key: "authorization", }, @@ -385,16 +385,16 @@ func TestBuildOutgoingAuthEnvVars_InlineBackendsDeterministicOrder(t *testing.T) }, } - authConfigB := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfigB := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-b", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "Authorization", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "auth-b-secret", Key: "authorization", }, @@ -407,24 +407,24 @@ func TestBuildOutgoingAuthEnvVars_InlineBackendsDeterministicOrder(t *testing.T) WithRuntimeObjects(authConfigB, authConfigA). Build() - 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", - Backends: map[string]mcpv1alpha1.BackendAuthConfig{ + Backends: map[string]mcpv1beta1.BackendAuthConfig{ "backend-z": { - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-b", }, }, "backend-a": { - Type: mcpv1alpha1.BackendAuthTypeExternalAuthConfigRef, - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "auth-a", }, }, @@ -445,18 +445,18 @@ func TestBuildOutgoingAuthEnvVars_DiscoveredWorkloadsDeterministicOrder(t *testi t.Parallel() scheme := runtime.NewScheme() - require.NoError(t, mcpv1alpha1.AddToScheme(scheme)) + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) - authConfigA := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfigA := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-a", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "Authorization", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "auth-a-secret", Key: "authorization", }, @@ -464,16 +464,16 @@ func TestBuildOutgoingAuthEnvVars_DiscoveredWorkloadsDeterministicOrder(t *testi }, } - authConfigB := &mcpv1alpha1.MCPExternalAuthConfig{ + authConfigB := &mcpv1beta1.MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "auth-b", Namespace: "default", }, - Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ - Type: mcpv1alpha1.ExternalAuthTypeHeaderInjection, - HeaderInjection: &mcpv1alpha1.HeaderInjectionConfig{ + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeHeaderInjection, + HeaderInjection: &mcpv1beta1.HeaderInjectionConfig{ HeaderName: "Authorization", - ValueSecretRef: &mcpv1alpha1.SecretKeyRef{ + ValueSecretRef: &mcpv1beta1.SecretKeyRef{ Name: "auth-b-secret", Key: "authorization", }, @@ -481,23 +481,23 @@ func TestBuildOutgoingAuthEnvVars_DiscoveredWorkloadsDeterministicOrder(t *testi }, } - mcpServerA := &mcpv1alpha1.MCPServer{ + mcpServerA := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "backend-a", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "auth-a"}, + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "auth-a"}, }, } - mcpServerB := &mcpv1alpha1.MCPServer{ + mcpServerB := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "backend-b", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{Name: "auth-b"}, + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "auth-b"}, }, } @@ -506,13 +506,13 @@ func TestBuildOutgoingAuthEnvVars_DiscoveredWorkloadsDeterministicOrder(t *testi WithRuntimeObjects(authConfigB, authConfigA, mcpServerB, mcpServerA). Build() - 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", }, }, @@ -536,7 +536,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", @@ -555,7 +555,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", @@ -574,7 +574,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", @@ -595,7 +595,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", @@ -615,18 +615,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{ - Config: vmcpconfig.Config{Group: "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{ @@ -655,19 +655,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{ - Config: vmcpconfig.Config{Group: "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{ @@ -685,7 +685,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", @@ -748,9 +748,9 @@ 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{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", @@ -758,7 +758,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 @@ -770,7 +770,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", @@ -796,13 +796,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"}, }, @@ -812,7 +812,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", @@ -823,7 +823,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", @@ -911,7 +911,7 @@ func TestBuildCABundleVolumesForEntries(t *testing.T) { tests := []struct { name string - entries []mcpv1alpha1.MCPServerEntry + entries []mcpv1beta1.MCPServerEntry workloads []workloads.TypedWorkload expectedVolumes int expectedMounts int @@ -928,13 +928,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: "test-group", + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -946,14 +946,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: "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", @@ -982,14 +982,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: "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", @@ -1012,14 +1012,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: "test-group", - CABundleRef: &mcpv1alpha1.CABundleSource{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "ca-cm"}, }, @@ -1028,10 +1028,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: "test-group", + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -1050,7 +1050,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 eac97e6e23..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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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, }, }, }, @@ -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, mcpv1beta1.AddToScheme(scheme)) + + vmcp := &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vmcp", + Namespace: "default", + }, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ + Source: "discovered", + }, + }, + } + + // Four MCPServers each referencing a distinct MCPExternalAuthConfig. + // The MCPServer names match the workload Names in tt.workloadOrder. + mcpServers := []client.Object{ + &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "server-alpha", Namespace: "default"}, + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "alpha-auth"}, + }, + }, + &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "server-beta", Namespace: "default"}, + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "beta-auth"}, + }, + }, + &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "server-mu", Namespace: "default"}, + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "mu-auth"}, + }, + }, + &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "server-zeta", Namespace: "default"}, + Spec: mcpv1beta1.MCPServerSpec{ + ExternalAuthConfigRef: &mcpv1beta1.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{ + &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "alpha-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://alpha.example.com/token", + Audience: "alpha-service", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "alpha-secret", Key: "client-secret"}, + }, + }, + }, + &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "beta-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://beta.example.com/token", + Audience: "beta-service", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "beta-secret", Key: "client-secret"}, + }, + }, + }, + &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "mu-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://mu.example.com/token", + Audience: "mu-service", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "mu-secret", Key: "client-secret"}, + }, + }, + }, + &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "zeta-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://zeta.example.com/token", + Audience: "zeta-service", + ClientSecretRef: &mcpv1beta1.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, mcpv1beta1.AddToScheme(scheme)) + + vmcp := &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vmcp", + Namespace: "default", + }, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ + Source: "inline", + // Map with four backends — Go map iteration order is non-deterministic. + Backends: map[string]mcpv1beta1.BackendAuthConfig{ + "backend-zeta": { + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ + Name: "inline-zeta-auth", + }, + }, + "backend-mu": { + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ + Name: "inline-mu-auth", + }, + }, + "backend-beta": { + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ + Name: "inline-beta-auth", + }, + }, + "backend-alpha": { + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ + Name: "inline-alpha-auth", + }, + }, + }, + }, + }, + } + + authConfigs := []client.Object{ + &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "inline-alpha-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://alpha.example.com/token", + Audience: "alpha-service", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "inline-alpha-secret", Key: "client-secret"}, + }, + }, + }, + &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "inline-beta-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://beta.example.com/token", + Audience: "beta-service", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "inline-beta-secret", Key: "client-secret"}, + }, + }, + }, + &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "inline-mu-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://mu.example.com/token", + Audience: "mu-service", + ClientSecretRef: &mcpv1beta1.SecretKeyRef{Name: "inline-mu-secret", Key: "client-secret"}, + }, + }, + }, + &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "inline-zeta-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://zeta.example.com/token", + Audience: "zeta-service", + ClientSecretRef: &mcpv1beta1.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. @@ -1203,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{ - Config: vmcpconfig.Config{Group: "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 d71059736f..293506ecd8 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go @@ -17,9 +17,8 @@ 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" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/pkg/vmcp/workloads" ) @@ -34,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) @@ -42,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, }, } @@ -58,13 +57,13 @@ func TestVirtualMCPServerPodTemplateSpecDeterministic(t *testing.T) { }, } - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpName, Namespace: namespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: groupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, PodTemplateSpec: podTemplateSpecToRawExtension(t, podTemplate), }, } @@ -87,8 +86,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") @@ -112,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) @@ -120,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{ - Config: vmcpconfig.Config{Group: groupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, PodTemplateSpec: &runtime.RawExtension{ Raw: []byte(`{"spec":{"nodeSelector":{"disktype":"ssd"}}}`), }, @@ -162,7 +161,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") @@ -284,13 +283,13 @@ func TestVirtualMCPServerPodTemplateSpecNeedsUpdate(t *testing.T) { }, } - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: testPodTemplateVmcpName, Namespace: testPodTemplateNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: testPodTemplateGroupName}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testPodTemplateGroupName}, PodTemplateSpec: tt.newPodTemplateSpec, }, } @@ -308,7 +307,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) @@ -316,24 +315,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{ - Config: vmcpconfig.Config{Group: 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"}}}]}}`), }, @@ -357,7 +356,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..5224f242cf --- /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" + + 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" +) + +// 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 *mcpv1beta1.VirtualMCPServer, + statusManager virtualmcpserverstatus.StatusManager, +) (*mcpv1beta1.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( + mcpv1beta1.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( + mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefFetchError, + err.Error(), + metav1.ConditionFalse, + ) + return nil, err + } + + if telemetryConfig == nil { + // Resource genuinely does not exist + statusManager.SetTelemetryConfigRefValidatedCondition( + mcpv1beta1.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( + mcpv1beta1.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( + mcpv1beta1.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.(*mcpv1beta1.MCPTelemetryConfig) + if !ok { + return nil + } + + 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 + } + + 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..6011c73b69 --- /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" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/virtualmcpserverstatus" +) + +func TestHandleTelemetryConfig_VirtualMCPServer(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) + + tests := []struct { + name string + vmcp *mcpv1beta1.VirtualMCPServer + telemetryConfig *mcpv1beta1.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: &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{TelemetryConfigRef: nil}, + Status: mcpv1beta1.VirtualMCPServerStatus{ + TelemetryConfigHash: "old-hash", + Conditions: []metav1.Condition{ + { + Type: mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + Status: metav1.ConditionTrue, + Reason: mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid, + }, + }, + }, + }, + expectError: false, + expectTelCfgNil: true, + expectHashCleared: true, + expectCondRemoved: true, + }, + { + name: "valid ref sets condition true and updates hash", + vmcp: &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "my-telemetry"}, + }, + }, + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "my-telemetry", Namespace: "default"}, + Spec: newTelemetrySpec("https://otel-collector:4317", true, false), + Status: mcpv1beta1.MCPTelemetryConfigStatus{ + ConfigHash: "abc123", + }, + }, + expectError: false, + expectedHash: "abc123", + expectedCondType: mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + expectedCondStatus: metav1.ConditionTrue, + expectedCondReason: mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid, + }, + { + name: "not found sets condition false", + vmcp: &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "missing"}, + }, + }, + expectError: true, + expectedCondType: mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + expectedCondStatus: metav1.ConditionFalse, + expectedCondReason: mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefNotFound, + }, + { + name: "invalid config sets condition false", + vmcp: &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "invalid-telemetry"}, + }, + }, + // Spec with endpoint but no tracing/metrics enabled -> Validate() fails + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "invalid-telemetry", Namespace: "default"}, + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ + Enabled: true, + Endpoint: "https://otel-collector:4317", + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: false}, + Metrics: &mcpv1beta1.OpenTelemetryMetricsConfig{Enabled: false}, + }, + }, + }, + expectError: true, + expectedCondType: mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + expectedCondStatus: metav1.ConditionFalse, + expectedCondReason: mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefInvalid, + }, + { + name: "hash change triggers update", + vmcp: &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "my-telemetry"}, + }, + Status: mcpv1beta1.VirtualMCPServerStatus{ + TelemetryConfigHash: "old-hash", + }, + }, + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "my-telemetry", Namespace: "default"}, + Spec: newTelemetrySpec("https://otel-collector:4317", true, false), + Status: mcpv1beta1.MCPTelemetryConfigStatus{ + ConfigHash: "new-hash", + }, + }, + expectError: false, + expectedHash: "new-hash", + expectedCondType: mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated, + expectedCondStatus: metav1.ConditionTrue, + expectedCondReason: mcpv1beta1.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, + mcpv1beta1.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, mcpv1beta1.AddToScheme(scheme)) + + vmcp1 := &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "vmcp1", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "shared-telemetry"}, + }, + } + vmcp2 := &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "vmcp2", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "other-telemetry"}, + }, + } + vmcp3 := &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "vmcp3", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{}, // no ref + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(vmcp1, vmcp2, vmcp3). + Build() + + reconciler := &VirtualMCPServerReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + ctx := t.Context() + + telemetryConfig := &mcpv1beta1.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 1fd73473eb..939cd19d1b 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go @@ -11,7 +11,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/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" @@ -27,11 +27,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, + vmcp *mcpv1beta1.VirtualMCPServer, typedWorkloads []workloads.TypedWorkload, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, statusManager virtualmcpserverstatus.StatusManager, ) error { // Create OIDC resolver and converter for CRD-to-config transformation @@ -40,7 +43,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) } @@ -76,10 +79,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, ) @@ -149,7 +152,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) @@ -157,7 +160,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) } @@ -169,7 +172,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) } @@ -276,7 +279,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 } @@ -309,7 +312,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 582c024a05..52457317e4 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" @@ -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 } @@ -46,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) @@ -59,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{ - Config: vmcpconfig.Config{Group: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, expectedName: "test-vmcp", @@ -85,7 +84,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) @@ -101,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", @@ -129,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, }, }, }, @@ -148,15 +147,15 @@ func TestConvertOutgoingAuth(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, + vmcpServer := &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, OutgoingAuth: tt.outgoingAuth, }, } 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) @@ -177,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", }, }, @@ -206,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{ - Config: vmcpconfig.Config{Group: "test-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Default: tt.authConfig, }, }, @@ -221,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). @@ -247,7 +246,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) @@ -331,17 +330,17 @@ func TestConvertAggregation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", Aggregation: tt.aggregation, }, }, } 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) @@ -383,7 +382,7 @@ func TestConvertCompositeTools(t *testing.T) { Steps: []vmcpconfig.WorkflowStepConfig{ { ID: "deploy", - Type: mcpv1alpha1.WorkflowStepTypeToolCall, + Type: mcpv1beta1.WorkflowStepTypeToolCall, Tool: "kubectl.apply", }, }, @@ -400,7 +399,7 @@ func TestConvertCompositeTools(t *testing.T) { Steps: []vmcpconfig.WorkflowStepConfig{ { ID: "step1", - Type: mcpv1alpha1.WorkflowStepTypeToolCall, + Type: mcpv1beta1.WorkflowStepTypeToolCall, }, }, }, @@ -410,7 +409,7 @@ func TestConvertCompositeTools(t *testing.T) { Steps: []vmcpconfig.WorkflowStepConfig{ { ID: "step1", - Type: mcpv1alpha1.WorkflowStepTypeElicitation, + Type: mcpv1beta1.WorkflowStepTypeElicitation, }, }, }, @@ -424,17 +423,17 @@ func TestConvertCompositeTools(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeTools: tt.compositeTools, }, }, } 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 @@ -453,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{ - Config: vmcpconfig.Config{Group: "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(). @@ -489,13 +488,13 @@ 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) 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 @@ -1006,14 +1005,14 @@ 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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", // Aggregation with tool overrides (map) Aggregation: &vmcpconfig.AggregationConfig{ ConflictResolution: vmcp.ConflictStrategyPrefix, @@ -1050,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, }, }, }, @@ -1074,7 +1073,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. @@ -1116,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", @@ -1145,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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "test-composite-tool"}, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -1189,12 +1188,12 @@ 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 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 @@ -1238,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", @@ -1259,26 +1258,26 @@ 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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeTools: []vmcpconfig.CompositeToolConfig{ { Name: "inline-tool", @@ -1296,7 +1295,7 @@ func TestVirtualMCPServerReconciler_CompositeToolRefs_MergeInlineAndReferenced(t {Name: "referenced-tool"}, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -1316,12 +1315,12 @@ 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 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 @@ -1356,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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "non-existent-tool"}, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -1400,12 +1399,12 @@ 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 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") } @@ -1419,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{ - Config: vmcpconfig.Config{Group: "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 }, }, @@ -1459,12 +1458,12 @@ 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 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 @@ -1501,23 +1500,24 @@ func TestConfigMapContent_StaticMode_UsesExplicitBackendsWhenGroupEmpty(t *testi ctx := context.Background() testScheme := createRunConfigTestScheme() - mcpGroup := &mcpv1alpha1.MCPGroup{ + mcpGroup := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: "empty-group", Namespace: "default", }, - 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", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "empty-group"}, Config: vmcpconfig.Config{ Group: "empty-group", Backends: []vmcpconfig.StaticBackendConfig{ @@ -1533,10 +1533,10 @@ func TestConfigMapContent_StaticMode_UsesExplicitBackendsWhenGroupEmpty(t *testi }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "inline", }, }, @@ -1558,7 +1558,7 @@ func TestConfigMapContent_StaticMode_UsesExplicitBackendsWhenGroupEmpty(t *testi require.Empty(t, workloadNames, "group should be empty") statusCollector := virtualmcpserverstatus.NewStatusManager(vmcpServer) - err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, statusCollector) + err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, nil, statusCollector) require.NoError(t, err) configMap := &corev1.ConfigMap{} @@ -1588,38 +1588,39 @@ func TestConfigMapContent_StaticMode_MergesDiscoveredAndExplicitBackends(t *test ctx := context.Background() testScheme := createRunConfigTestScheme() - 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, }, } - mcpServer := &mcpv1alpha1.MCPServer{ + mcpServer := &mcpv1beta1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "discovered-backend", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Transport: vmcpconfig.TransportSSE, }, - Status: mcpv1alpha1.MCPServerStatus{ - Phase: mcpv1alpha1.MCPServerPhaseReady, + Status: mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, URL: "http://discovered-backend.default.svc.cluster.local:8080", }, } - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ Group: "test-group", Backends: []vmcpconfig.StaticBackendConfig{ @@ -1638,10 +1639,10 @@ func TestConfigMapContent_StaticMode_MergesDiscoveredAndExplicitBackends(t *test }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "inline", }, }, @@ -1664,7 +1665,7 @@ func TestConfigMapContent_StaticMode_MergesDiscoveredAndExplicitBackends(t *test require.NotEmpty(t, workloadNames, "group should have discovered backends") statusCollector := virtualmcpserverstatus.NewStatusManager(vmcpServer) - err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, statusCollector) + err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, nil, statusCollector) require.NoError(t, err) configMap := &corev1.ConfigMap{} @@ -1709,50 +1710,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: "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{ - Config: vmcpconfig.Config{Group: "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, }, }, }, @@ -1772,12 +1773,12 @@ 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 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 @@ -1816,59 +1817,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: "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{ - Config: vmcpconfig.Config{Group: "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 }, }, @@ -1887,13 +1888,13 @@ 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") // 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 @@ -2028,24 +2029,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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroup}, Config: vmcpconfig.Config{ - Group: testGroup, Optimizer: &vmcpconfig.OptimizerConfig{}, }, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{ Name: "shared-embedding", }, }, @@ -2056,17 +2057,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{ - Config: vmcpconfig.Config{ - Group: testGroup, - // No Optimizer — validation auto-populates it when ref is set - }, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroup}, + // No Optimizer — validation auto-populates it when ref is set + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{ Name: "shared-embedding", }, }, @@ -2084,31 +2083,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), @@ -2137,7 +2136,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 @@ -2173,15 +2172,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:", @@ -2200,7 +2199,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, }, { @@ -2208,12 +2207,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", }, @@ -2235,16 +2234,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{ - Config: vmcpconfig.Config{Group: testGroup}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroup}, SessionStorage: tt.sessionStorage, }, } @@ -2261,7 +2260,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{} @@ -2301,35 +2300,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{ - 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, - }, - }, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "oidc", + 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", }, @@ -2339,21 +2331,32 @@ 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 := &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ + Issuer: incomingIssuer, + ClientID: clientID, + }, + }, } 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). - WithObjects(testVmcp, mcpGroup). + WithObjects(testVmcp, mcpGroup, oidcConfig). Build() r := &VirtualMCPServerReconciler{ @@ -2363,7 +2366,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. @@ -2376,18 +2379,18 @@ 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) 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 @@ -2513,7 +2516,7 @@ func TestBuildCABundlePathMap(t *testing.T) { tests := []struct { name string - entries []mcpv1alpha1.MCPServerEntry + entries []mcpv1beta1.MCPServerEntry typedWorkloads []workloads.TypedWorkload expectedMap map[string]string }{ @@ -2527,13 +2530,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: "test-group", + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -2544,14 +2547,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: "test-group", - CABundleRef: &mcpv1alpha1.CABundleSource{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "ca-cm"}, }, @@ -2568,14 +2571,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: "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", @@ -2593,14 +2596,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: "test-group", - CABundleRef: &mcpv1alpha1.CABundleSource{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + CABundleRef: &mcpv1beta1.CABundleSource{ ConfigMapRef: &corev1.ConfigMapKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: "ca-cm"}, }, @@ -2609,10 +2612,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: "test-group", + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -2632,7 +2635,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 a816da9102..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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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{ - Config: vmcpconfig.Config{Group: "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: "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{ - Config: vmcpconfig.Config{Group: "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: "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{ - Config: vmcpconfig.Config{Group: "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: "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{ - Config: vmcpconfig.Config{Group: "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: "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,19 +1080,21 @@ 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) - if mcpServer.Spec.GroupRef == "" { + 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{mcpServer.Spec.GroupRef} + return []string{name} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) + name := mcpRemoteProxy.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }). Build() @@ -1126,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{ @@ -1165,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, @@ -1185,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{ @@ -1216,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{ @@ -1247,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 @@ -1292,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{ @@ -1318,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{ @@ -1338,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{ @@ -1393,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", }, }, @@ -1419,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", }, }, @@ -1438,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, }, }, @@ -1456,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", }, }, @@ -1473,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", }, }, @@ -1501,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{ - Config: vmcpconfig.Config{Group: "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", @@ -1521,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: "test-group", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, @@ -1540,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{ - Config: vmcpconfig.Config{Group: "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", @@ -1560,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: "test-group", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "other-auth", }, }, @@ -1579,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{ - Config: vmcpconfig.Config{Group: "nonexistent-group"}, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "nonexistent-group"}, + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", }, }, @@ -1596,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{ - Config: vmcpconfig.Config{Group: "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", @@ -1616,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: "test-group", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "other-auth", }, }, @@ -1634,9 +1636,9 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Name: "backend-server-2", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, @@ -1646,8 +1648,8 @@ func TestVmcpReferencesExternalAuthConfig(t *testing.T) { Name: "backend-server-3", Namespace: "default", }, - Spec: mcpv1alpha1.MCPServerSpec{ - GroupRef: "test-group", + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, }, @@ -1656,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{ - Config: vmcpconfig.Config{Group: "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", @@ -1676,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: "test-group", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "test-auth", }, }, @@ -1695,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{ - Config: vmcpconfig.Config{Group: "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", @@ -1715,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: "test-group", - ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ Name: "other-auth", }, }, @@ -1740,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 @@ -1762,19 +1764,21 @@ 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) - if mcpServer.Spec.GroupRef == "" { + 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{mcpServer.Spec.GroupRef} + return []string{name} }). - WithIndex(&mcpv1alpha1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { - mcpRemoteProxy := obj.(*mcpv1alpha1.MCPRemoteProxy) - if mcpRemoteProxy.Spec.GroupRef == "" { + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, "spec.groupRef", func(obj client.Object) []string { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) + name := mcpRemoteProxy.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }). Build() @@ -1794,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{ - Config: vmcpconfig.Config{Group: "test-group"}, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{Name: "shared-embedding"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{Name: "shared-embedding"}, }, }, }, @@ -1824,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{ - Config: vmcpconfig.Config{Group: "test-group"}, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{Name: "shared-embedding"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{Name: "shared-embedding"}, }, }, { @@ -1846,9 +1850,9 @@ func TestMapEmbeddingServerToVirtualMCPServer(t *testing.T) { Name: "vmcp-2", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - Config: vmcpconfig.Config{Group: "test-group"}, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{Name: "shared-embedding"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{Name: "shared-embedding"}, }, }, }, @@ -1857,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{ - Config: vmcpconfig.Config{Group: "test-group"}, - EmbeddingServerRef: &mcpv1alpha1.EmbeddingServerRef{Name: "other-embedding"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + EmbeddingServerRef: &mcpv1beta1.EmbeddingServerRef{Name: "other-embedding"}, }, }, }, @@ -1886,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 @@ -1930,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() @@ -1940,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 522397ef6b..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,14 +196,15 @@ 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) - if mcpServer.Spec.GroupRef == "" { + mcpServer := obj.(*mcpv1beta1.MCPServer) + 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) @@ -210,14 +213,15 @@ 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) - if mcpRemoteProxy.Spec.GroupRef == "" { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) + 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) @@ -226,14 +230,15 @@ 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) - if mcpServerEntry.Spec.GroupRef == "" { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + 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/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go index cfd967badd..7be902303b 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 } @@ -395,18 +395,15 @@ 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 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) @@ -418,18 +415,58 @@ 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). // -// 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, + authConfig *mcpv1beta1.EmbeddedAuthServerConfig, allowedAudiences []string, scopesSupported []string, + resourceURL string, ) (*authserver.RunConfig, error) { config := &authserver.RunConfig{ SchemaVersion: authserver.CurrentSchemaVersion, @@ -474,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 @@ -492,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) } @@ -564,7 +601,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 } @@ -583,7 +620,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 @@ -613,12 +650,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, + provider *mcpv1beta1.UpstreamProviderConfig, envVarName string, + resourceURL string, ) *authserver.UpstreamRunConfig { config := &authserver.UpstreamRunConfig{ Name: provider.Name, @@ -626,13 +672,18 @@ func buildUpstreamRunConfig( } switch provider.Type { - case mcpv1alpha1.UpstreamProviderTypeOIDC: + case mcpv1beta1.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, - Scopes: provider.OIDCConfig.Scopes, + IssuerURL: provider.OIDCConfig.IssuerURL, + ClientID: provider.OIDCConfig.ClientID, + RedirectURI: redirectURI, + Scopes: provider.OIDCConfig.Scopes, + AdditionalAuthorizationParams: provider.OIDCConfig.AdditionalAuthorizationParams, } // If client secret is configured, reference it via env var if provider.OIDCConfig.ClientSecretRef != nil { @@ -642,14 +693,19 @@ 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 != "" { + redirectURI = defaultRedirectURI(resourceURL) + } 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: redirectURI, + Scopes: provider.OAuth2Config.Scopes, + AdditionalAuthorizationParams: provider.OAuth2Config.AdditionalAuthorizationParams, } // If client secret is configured, reference it via env var if provider.OAuth2Config.ClientSecretRef != nil { @@ -675,7 +731,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, @@ -702,8 +758,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 { @@ -716,7 +772,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", @@ -738,7 +794,7 @@ func AddAuthServerRefOptions( c client.Client, namespace string, mcpServerName string, - authServerRef *mcpv1alpha1.AuthServerRef, + authServerRef *mcpv1beta1.AuthServerRef, oidcConfig *oidc.OIDCConfig, options *[]runner.RunConfigBuilderOption, ) error { @@ -758,10 +814,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, ) } @@ -770,18 +826,15 @@ 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 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 e10b9cbec4..426d46a11b 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 }, }, @@ -690,21 +690,24 @@ 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 + authConfig *mcpv1beta1.EmbeddedAuthServerConfig allowedAudiences []string scopesSupported []string + resourceURL string checkFunc func(t *testing.T, config *authserver.RunConfig) }{ { 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"}, }, }, @@ -725,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"}, }, }, @@ -749,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", @@ -774,20 +777,21 @@ func TestBuildAuthServerRunConfig(t *testing.T) { }, }, { - name: "with OIDC upstream provider", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + name: "with OIDC upstream provider", + resourceURL: defaultResourceURL, + 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", @@ -811,31 +815,32 @@ func TestBuildAuthServerRunConfig(t *testing.T) { }, }, { - name: "with OAuth2 upstream provider with userinfo config", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + name: "with OAuth2 upstream provider with userinfo config", + resourceURL: defaultResourceURL, + 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"}, @@ -867,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"}, }, }, @@ -886,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"}, }, }, @@ -904,25 +909,26 @@ func TestBuildAuthServerRunConfig(t *testing.T) { }, }, { - name: "with multiple upstream providers all are included", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + name: "with multiple upstream providers all are included", + resourceURL: defaultResourceURL, + 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", }, @@ -930,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", }, @@ -967,13 +973,215 @@ func TestBuildAuthServerRunConfig(t *testing.T) { assert.Equal(t, UpstreamClientSecretEnvVar+"_GITHUB", github.OAuth2Config.ClientSecretEnvVar) }, }, + { + name: "OIDC upstream propagates AdditionalAuthorizationParams", + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "okta", + 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"}, + 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: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "github", + 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", + 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) + }, + }, + { + name: "OIDC upstream with empty redirectUri defaults to resourceURL/oauth/callback", + resourceURL: "https://mcp.example.com", + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "okta", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.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: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "github", + 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 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: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "okta", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.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: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "okta", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.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) @@ -986,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"}, }, }, @@ -1036,6 +1244,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"}, }, @@ -1044,11 +1253,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 { @@ -1065,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, ) @@ -1084,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"}, }, @@ -1115,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 @@ -1123,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", }, @@ -1152,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", }, @@ -1195,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, }, @@ -1261,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"}, }, @@ -1276,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, }, @@ -1287,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", }, }, @@ -1297,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, @@ -1309,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, @@ -1342,7 +1572,7 @@ func TestBuildStorageRunConfig(t *testing.T) { tests := []struct { name string - authConfig *mcpv1alpha1.EmbeddedAuthServerConfig + authConfig *mcpv1beta1.EmbeddedAuthServerConfig wantNil bool wantErr bool errContains string @@ -1350,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", @@ -1407,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"}, }, }, }, @@ -1434,15 +1664,15 @@ func TestBuildStorageRunConfig(t *testing.T) { }, { name: "Redis storage with direct address builds correctly", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - Storage: &mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ + Storage: &mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ Address: "dragonfly.toolhive-operator-stg.svc.cluster.local:6379", - 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"}, }, }, }, @@ -1455,10 +1685,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, @@ -1466,14 +1696,14 @@ func TestBuildStorageRunConfig(t *testing.T) { }, { name: "Redis storage without address or 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"}, }, }, }, @@ -1483,19 +1713,19 @@ func TestBuildStorageRunConfig(t *testing.T) { }, { name: "Redis storage with address and sentinel config returns error", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - Storage: &mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ + Storage: &mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ Address: "dragonfly:6379", - SentinelConfig: &mcpv1alpha1.RedisSentinelConfig{ + SentinelConfig: &mcpv1beta1.RedisSentinelConfig{ MasterName: "mymaster", SentinelAddrs: []string{"10.0.0.1: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"}, }, }, }, @@ -1505,12 +1735,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"}, }, @@ -1522,17 +1752,17 @@ func TestBuildStorageRunConfig(t *testing.T) { }, { name: "Redis storage with direct address and sentinel TLS returns error", - authConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{ + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ Issuer: "https://auth.example.com", - Storage: &mcpv1alpha1.AuthServerStorageConfig{ - Type: mcpv1alpha1.AuthServerStorageTypeRedis, - Redis: &mcpv1alpha1.RedisStorageConfig{ + Storage: &mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ Address: "dragonfly:6379", - 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"}, }, - SentinelTLS: &mcpv1alpha1.RedisTLSConfig{}, + SentinelTLS: &mcpv1beta1.RedisTLSConfig{}, }, }, }, @@ -1573,24 +1803,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"}, }, }, }, @@ -1600,6 +1830,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) @@ -1614,21 +1845,21 @@ func TestBuildAuthServerRunConfig_WithRedisStorage(t *testing.T) { func TestBuildAuthServerRunConfig_WithDirectRedisStorage(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{ + Storage: &mcpv1beta1.AuthServerStorageConfig{ + Type: mcpv1beta1.AuthServerStorageTypeRedis, + Redis: &mcpv1beta1.RedisStorageConfig{ Address: "dragonfly.toolhive-operator-stg.svc.cluster.local:6379", - 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"}, }, }, }, @@ -1638,6 +1869,7 @@ func TestBuildAuthServerRunConfig_WithDirectRedisStorage(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) @@ -1654,24 +1886,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"}, }, }, @@ -1679,26 +1911,27 @@ 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, }, } } validOIDCConfig := &oidc.OIDCConfig{ + Audience: "https://mcp.example.com", ResourceURL: "https://mcp.example.com", Scopes: []string{"openid"}, } tests := []struct { name string - authServerRef *mcpv1alpha1.AuthServerRef + authServerRef *mcpv1beta1.AuthServerRef oidcConfig *oidc.OIDCConfig objects func() []runtime.Object wantErr bool @@ -1714,7 +1947,7 @@ func TestAddAuthServerRefOptions(t *testing.T) { }, { name: "unsupported kind returns error", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "Foo", Name: "some-config", }, @@ -1724,7 +1957,7 @@ func TestAddAuthServerRefOptions(t *testing.T) { }, { name: "non-existent config returns error", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "non-existent", }, @@ -1734,7 +1967,7 @@ func TestAddAuthServerRefOptions(t *testing.T) { }, { name: "wrong type returns error", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "unauth-config", }, @@ -1745,7 +1978,7 @@ func TestAddAuthServerRefOptions(t *testing.T) { }, { name: "valid ref appends option", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "auth-server-config", }, @@ -1756,7 +1989,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", }, @@ -1765,6 +1998,36 @@ func TestAddAuthServerRefOptions(t *testing.T) { wantErr: true, errContains: "OIDC config is required", }, + { + name: "audience mismatch with resourceUrl returns error", + authServerRef: &mcpv1beta1.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: &mcpv1beta1.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 { @@ -1799,24 +2062,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"}, }, }, @@ -1824,15 +2087,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", }, }, @@ -1840,14 +2103,15 @@ func TestValidateAndAddAuthServerRefOptions(t *testing.T) { } validOIDC := &oidc.OIDCConfig{ + Audience: "https://mcp.example.com", ResourceURL: "https://mcp.example.com", Scopes: []string{"openid"}, } 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 @@ -1864,7 +2128,7 @@ func TestValidateAndAddAuthServerRefOptions(t *testing.T) { }, { name: "authServerRef set with nil externalAuthConfigRef succeeds", - authServerRef: &mcpv1alpha1.AuthServerRef{ + authServerRef: &mcpv1beta1.AuthServerRef{ Kind: "MCPExternalAuthConfig", Name: "embedded-config", }, @@ -1876,11 +2140,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, @@ -1890,11 +2154,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, @@ -1904,11 +2168,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 a665de020a..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, @@ -220,14 +220,42 @@ 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 *mcpv1beta1.VirtualMCPServer, +) (*mcpv1beta1.MCPTelemetryConfig, error) { + if vmcp.Spec.TelemetryConfigRef == nil { + return nil, nil + } + + telemetryConfig := &mcpv1beta1.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, 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 eeeb33d68d..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, @@ -554,3 +554,98 @@ func TestGetTelemetryConfigForMCPRemoteProxy(t *testing.T) { }) } } + +func TestGetTelemetryConfigForVirtualMCPServer(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) + + tests := []struct { + name string + vmcp *mcpv1beta1.VirtualMCPServer + telemetryConfig *mcpv1beta1.MCPTelemetryConfig + expectNil bool + expectError bool + expectedName string + }{ + { + name: "nil ref returns nil without error", + vmcp: &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{TelemetryConfigRef: nil}, + }, + expectNil: true, + expectError: false, + }, + { + name: "fetches referenced config", + vmcp: &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "my-telemetry"}, + }, + }, + telemetryConfig: &mcpv1beta1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "my-telemetry", Namespace: "default"}, + }, + expectNil: false, + expectError: false, + expectedName: "my-telemetry", + }, + { + name: "not found returns nil without error", + vmcp: &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "missing"}, + }, + }, + expectNil: true, + expectError: false, + }, + { + name: "cross-namespace returns nil (not found)", + vmcp: &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "namespace-b"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + TelemetryConfigRef: &mcpv1beta1.MCPTelemetryConfigReference{Name: "shared-config"}, + }, + }, + telemetryConfig: &mcpv1beta1.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/controllerutil/oidc.go b/cmd/thv-operator/pkg/controllerutil/oidc.go index d3f6dd9933..c2dbf05427 100644 --- a/cmd/thv-operator/pkg/controllerutil/oidc.go +++ b/cmd/thv-operator/pkg/controllerutil/oidc.go @@ -11,61 +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" - "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" - "github.com/stacklok/toolhive/pkg/runner" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) -// 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( 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, @@ -83,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 f6f9f78054..abbc1d1d06 100644 --- a/cmd/thv-operator/pkg/controllerutil/oidc_volumes.go +++ b/cmd/thv-operator/pkg/controllerutil/oidc_volumes.go @@ -8,72 +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" ) -// 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( - 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/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/controllerutil/podtemplatespec_builder.go b/cmd/thv-operator/pkg/controllerutil/podtemplatespec_builder.go index b9bc87e15b..8f597474a9 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 e9964584e3..172b6f678c 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 e3b4f3b1cf..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" @@ -19,40 +19,13 @@ 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, 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 @@ -69,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 } @@ -110,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 { @@ -126,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: @@ -151,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 @@ -215,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 @@ -230,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 @@ -277,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 @@ -295,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 } @@ -325,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 1367ee8684..1ef494163d 100644 --- a/cmd/thv-operator/pkg/oidc/mocks/mock_resolver.go +++ b/cmd/thv-operator/pkg/oidc/mocks/mock_resolver.go @@ -13,91 +13,11 @@ 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" ) -// 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,23 +42,8 @@ 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) { +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 fc91f4f841..2f15f56f41 100644 --- a/cmd/thv-operator/pkg/oidc/resolver.go +++ b/cmd/thv-operator/pkg/oidc/resolver.go @@ -1,21 +1,17 @@ // 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" - 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 +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" ) // OIDCConfig represents the resolved OIDC configuration values @@ -45,30 +40,17 @@ 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. ResolveFromConfigRef( ctx context.Context, - oidcConfigRef *mcpv1alpha1.MCPOIDCConfigReference, - oidcConfig *mcpv1alpha1.MCPOIDCConfig, + oidcConfigRef *mcpv1beta1.MCPOIDCConfigReference, + oidcConfig *mcpv1beta1.MCPOIDCConfig, serverName, namespace string, proxyPort int32, ) (*OIDCConfig, error) @@ -87,38 +69,13 @@ 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. 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) { @@ -132,9 +89,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) @@ -145,15 +102,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, } } @@ -190,8 +147,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 { @@ -219,163 +176,9 @@ 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 == "true" { - config.JWKSAllowPrivateIP = true - } - if v, exists := configMap.Data["insecureAllowHTTP"]; exists && v == "true" { - 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, - 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 { +func computeCABundlePath(caBundleRef *mcpv1beta1.CABundleSource) string { if caBundleRef == nil || caBundleRef.ConfigMapRef == nil { return "" } @@ -387,28 +190,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_configref_test.go b/cmd/thv-operator/pkg/oidc/resolver_configref_test.go index f2297a533e..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,42 @@ 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", + }, + }, + }, + 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: "empty resourceUrl falls back to derived service URL", + ref: &mcpv1beta1.MCPOIDCConfigReference{ + Name: "k", Audience: "my-aud", Scopes: []string{"openid"}, + ResourceURL: "", + }, + oidcCfg: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount, + KubernetesServiceAccount: &mcpv1beta1.KubernetesServiceAccountOIDCConfig{ Issuer: "https://kubernetes.default.svc", }, }, @@ -72,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, }, }, @@ -89,15 +112,38 @@ func TestResolveFromConfigRef_KubernetesServiceAccountType(t *testing.T) { JWKSAllowPrivateIP: true, }, }, + { + name: "explicit resourceUrl overrides derived service URL", + ref: &mcpv1beta1.MCPOIDCConfigReference{ + Name: "k", Audience: "my-aud", Scopes: []string{"openid"}, + ResourceURL: "https://mcp-gateway.example.com/mcp", + }, + oidcCfg: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeKubernetesServiceAccount, + KubernetesServiceAccount: &mcpv1beta1.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{ + 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), }, @@ -129,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", }, @@ -155,34 +201,60 @@ func TestResolveFromConfigRef_InlineType(t *testing.T) { }, }, { - name: "custom resource URL from ref overrides service URL", - ref: &mcpv1alpha1.MCPOIDCConfigReference{ - Name: "i", Audience: "inline-aud", ResourceURL: "https://mcp.example.com/mcp", - }, - oidcCfg: &mcpv1alpha1.MCPOIDCConfig{ - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + name: "protectedResourceAllowPrivateIP propagated from shared inline config", + ref: &mcpv1beta1.MCPOIDCConfigReference{ + Name: "i", Audience: "inline-aud", + }, + oidcCfg: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.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: "explicit resourceUrl overrides derived service URL for inline config", + ref: &mcpv1beta1.MCPOIDCConfigReference{ + Name: "i", Audience: "inline-aud", Scopes: []string{"openid"}, + ResourceURL: "https://mcp.corp.internal/tools", + }, + oidcCfg: &mcpv1beta1.MCPOIDCConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://accounts.google.com", ClientID: "gid", }, }, }, expected: &OIDCConfig{ - Issuer: "https://accounts.google.com", - Audience: "inline-aud", + Issuer: "https://accounts.google.com", Audience: "inline-aud", ClientID: "gid", - ResourceURL: "https://mcp.example.com/mcp", + ResourceURL: "https://mcp.corp.internal/tools", + Scopes: []string{"openid"}, }, }, { 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, @@ -209,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/oidc/resolver_test.go b/cmd/thv-operator/pkg/oidc/resolver_test.go deleted file mode 100644 index 30d39bf6d0..0000000000 --- a/cmd/thv-operator/pkg/oidc/resolver_test.go +++ /dev/null @@ -1,939 +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" -) - -func TestResolve_KubernetesType(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - mcpServer *mcpv1alpha1.MCPServer - 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 - }, - }, - }, - 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", - 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), - }, - }, - }, - }, - 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", - 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), - }, - }, - }, - }, - 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", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - 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.mcpServer) - require.NoError(t, err) - assert.Equal(t, tt.expected, config) - }) - } -} - -func TestResolve_ConfigMapType(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - mcpServer *mcpv1alpha1.MCPServer - 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", - }, - }, - }, - }, - 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", - }, - }, - 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, - }, - }, - { - 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", - }, - }, - }, - }, - 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 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", - }, - }, - }, - }, - 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", - 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", - }, - }, - }, - }, - 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", - 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", - }, - }, - }, - }, - 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", - 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", - }, - }, - }, - }, - 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", - 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", - }, - }, - }, - }, - expectErr: true, - }, - { - 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, - }, - }, - }, - 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"}, - }, - }, - }, - }, - }, - }, - 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.mcpServer) - - 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 - mcpServer *mcpv1alpha1.MCPServer - 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"}, - }, - }, - JWKSAuthTokenPath: "/etc/auth/inline-token", - JWKSAllowPrivateIP: 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, - }, - }, - { - 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", - }, - }, - }, - }, - expected: &OIDCConfig{ - Issuer: "https://inline.example.com", - Audience: "inline-audience", - ResourceURL: "https://custom-resource.example.com", - }, - }, - { - 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, - }, - }, - }, - }, - 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 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"}, - }, - }, - }, - }, - 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", - mcpServer: &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-server", - Namespace: "test-ns", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - 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.mcpServer) - require.NoError(t, err) - assert.Equal(t, tt.expected, config) - }) - } -} - -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", - }, - }, - } - - resolver := NewResolver(nil) - config, err := resolver.Resolve(context.Background(), mcpServer) - - 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() - - 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", - }, - }, - }, - } - - resolver := NewResolver(nil) // No client provided - config, err := resolver.Resolve(context.Background(), mcpServer) - - 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 - mcpServer *mcpv1alpha1.MCPServer - 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", - }, - }, - }, - }, - }, - 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", - 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", - }, - }, - }, - }, - }, - 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", - 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", - }, - }, - }, - }, - 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.mcpServer) - require.NoError(t, err) - assert.Equal(t, tt.expected, config) - }) - } -} - -func TestResolve_InlineWithCABundleRef(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - mcpServer *mcpv1alpha1.MCPServer - 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"}, - }, - }, - }, - }, - }, - }, - 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", - 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{}, - }, - }, - }, - }, - 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.mcpServer) - - 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/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 0d748464d2..da9c6a89e3 100644 --- a/cmd/thv-operator/pkg/runconfig/telemetry.go +++ b/cmd/thv-operator/pkg/runconfig/telemetry.go @@ -5,37 +5,17 @@ package runconfig import ( - "context" - - 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" ) -// 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). 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 c121538ef5..1d3d9b11f0 100644 --- a/cmd/thv-operator/pkg/runconfig/telemetry_test.go +++ b/cmd/thv-operator/pkg/runconfig/telemetry_test.go @@ -9,9 +9,8 @@ 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" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/pkg/runner" ) @@ -20,237 +19,12 @@ 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() tests := []struct { name string - spec *mcpv1alpha1.MCPTelemetryConfigSpec + spec *mcpv1beta1.MCPTelemetryConfigSpec serviceNameOverride string defaultServiceName string caBundleFilePath string @@ -269,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", @@ -292,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", @@ -331,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 59425d869e..3fd554e66c 100644 --- a/cmd/thv-operator/pkg/spectoconfig/telemetry.go +++ b/cmd/thv-operator/pkg/spectoconfig/telemetry.go @@ -5,108 +5,19 @@ 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/cmd/thv-operator/api/v1beta1" "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 // NormalizeTelemetryConfig for endpoint normalization and service name defaulting. func NormalizeMCPTelemetryConfig( - spec *v1alpha1.MCPTelemetryConfigSpec, + spec *v1beta1.MCPTelemetryConfigSpec, serviceNameOverride string, defaultServiceName string, ) *telemetry.Config { @@ -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..a1e52133a8 100644 --- a/cmd/thv-operator/pkg/spectoconfig/telemetry_test.go +++ b/cmd/thv-operator/pkg/spectoconfig/telemetry_test.go @@ -4,13 +4,12 @@ package spectoconfig import ( - "context" "testing" "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" ) @@ -187,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 @@ -201,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", }, @@ -216,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", }, @@ -231,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", @@ -248,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", }, @@ -263,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", }, @@ -278,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", @@ -294,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 @@ -328,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", }, @@ -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() @@ -542,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 e0b0087e9f..3b11c534d5 100644 --- a/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go +++ b/cmd/thv-operator/pkg/virtualmcpserverstatus/collector.go @@ -12,26 +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 - hasChanges bool - phase *mcpv1alpha1.VirtualMCPServerPhase - message *string - url *string - observedGeneration *int64 - oidcConfigHash *string - conditions map[string]metav1.Condition - discoveredBackends []mcpv1alpha1.DiscoveredBackend + vmcp *mcpv1beta1.VirtualMCPServer + hasChanges bool + phase *mcpv1beta1.VirtualMCPServerPhase + message *string + url *string + observedGeneration *int64 + oidcConfigHash *string + telemetryConfigHash *string + conditions map[string]metav1.Condition + 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), @@ -39,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) { if s.vmcp.Status.Phase == phase { return } @@ -96,19 +97,30 @@ 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(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. @@ -148,28 +160,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 { @@ -198,6 +210,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 == "" { @@ -211,20 +228,23 @@ 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 == mcpv1beta1.BackendStatusReady || + backend.Status == mcpv1beta1.BackendStatusUnauthenticated { + routableCount++ } } - vmcpStatus.BackendCount = readyCount + vmcpStatus.BackendCount = routableCount } ctxLogger.V(1).Info("Batched status update applied", "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..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", @@ -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 := &mcpv1beta1.VirtualMCPServer{} + collector := NewStatusManager(vmcp) + + collector.SetTelemetryConfigHash("tel-hash-456") + + status := &mcpv1beta1.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 := &mcpv1beta1.VirtualMCPServer{} + collector := NewStatusManager(vmcp) + + collector.SetTelemetryConfigHash("") + + status := &mcpv1beta1.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 := &mcpv1beta1.VirtualMCPServer{} + collector := NewStatusManager(vmcp) + + collector.SetTelemetryConfigRefValidatedCondition( + "TelemetryConfigRefValid", "MCPTelemetryConfig is valid", metav1.ConditionTrue) + + status := &mcpv1beta1.VirtualMCPServerStatus{} + hasUpdates := collector.UpdateStatus(context.Background(), status) + + assert.True(t, hasUpdates) + assert.Len(t, status.Conditions, 1) + 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 889486a63f..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) } @@ -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() @@ -223,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 0820405dfb..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) @@ -65,10 +65,16 @@ 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) + 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 9bf2d83442..ade547617e 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go @@ -14,11 +14,12 @@ 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" "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, + 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 @@ -82,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) @@ -92,35 +101,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 +126,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), + // The inline config.telemetry field is no longer read by the operator. + 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 @@ -169,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 { @@ -205,13 +196,13 @@ 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( ctx context.Context, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) (*vmcpconfig.OIDCConfig, error) { if vmcp.Spec.IncomingAuth == nil { return nil, nil @@ -219,7 +210,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) @@ -241,76 +232,15 @@ 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, - AllowedClientIDs: resolved.AllowedClientIDs, - Audience: resolved.Audience, - Resource: resolved.ResourceURL, - JWKSURL: resolved.JWKSURL, - IntrospectionURL: resolved.IntrospectionURL, - ProtectedResourceAllowPrivateIP: 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. func mapResolvedOIDCToVmcpConfigFromRef( resolved *oidc.OIDCConfig, - oidcCfg *mcpv1alpha1.MCPOIDCConfig, + oidcCfg *mcpv1beta1.MCPOIDCConfig, ) *vmcpconfig.OIDCConfig { if resolved == nil { return nil @@ -322,14 +252,17 @@ func mapResolvedOIDCToVmcpConfigFromRef( AllowedClientIDs: resolved.AllowedClientIDs, 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, } // 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 @@ -338,14 +271,30 @@ func mapResolvedOIDCToVmcpConfigFromRef( return config } +// 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( + _ context.Context, + vmcp *mcpv1beta1.VirtualMCPServer, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, +) *telemetry.Config { + if vmcp.Spec.TelemetryConfigRef != nil && telemetryCfg != nil { + return spectoconfig.NormalizeMCPTelemetryConfig( + &telemetryCfg.Spec, vmcp.Spec.TelemetryConfigRef.ServiceName, vmcp.Name) + } + return nil +} + // convertSessionStorage populates SessionStorage from the VirtualMCPServer spec. // spec.sessionStorage is the authoritative source; always overwrite whatever // 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, @@ -360,7 +309,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,6 +320,7 @@ func (*Converter) convertAuthServerConfig( vmcp.Spec.AuthServerConfig, deriveAllowedAudiences(config), deriveScopesSupported(config), + deriveResourceURL(config), ) } @@ -397,6 +347,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"]). @@ -410,10 +370,39 @@ 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 *mcpv1beta1.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 *mcpv1beta1.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, - vmcp *mcpv1alpha1.VirtualMCPServer, + vmcp *mcpv1beta1.VirtualMCPServer, ) (*vmcpconfig.OutgoingAuthConfig, error) { outgoing := &vmcpconfig.OutgoingAuthConfig{ Source: vmcp.Spec.OutgoingAuth.Source, @@ -444,35 +433,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 } - // 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 == 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, @@ -495,7 +474,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() @@ -529,7 +508,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 @@ -580,7 +559,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 { @@ -653,7 +632,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 @@ -663,7 +642,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 @@ -681,7 +660,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, @@ -703,8 +682,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, @@ -718,7 +697,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) @@ -740,14 +719,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, @@ -773,7 +752,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 1c467af140..c6a8e184bd 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" @@ -30,18 +30,12 @@ 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 { t.Helper() ctrl := gomock.NewController(t) mockResolver := oidcmocks.NewMockResolver(ctrl) - mockResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() return mockResolver } @@ -49,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() } @@ -62,33 +56,68 @@ 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 { - return &mcpv1alpha1.VirtualMCPServer{ +// newTestVMCPServer creates a VirtualMCPServer with an MCPOIDCConfigReference for testing. +func newTestVMCPServer(oidcConfigRef *mcpv1beta1.MCPOIDCConfigReference) *mcpv1beta1.VirtualMCPServer { + return &mcpv1beta1.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}, + 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 mcpv1beta1.MCPOIDCConfigSourceType) *mcpv1beta1.MCPOIDCConfig { + return &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: specType, + }, + } +} + +// newTestMCPOIDCConfigInline creates an MCPOIDCConfig resource with inline config for testing. +func newTestMCPOIDCConfigInline(inline *mcpv1beta1.InlineOIDCSharedConfig) *mcpv1beta1.MCPOIDCConfig { + return &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.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 *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", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{Type: mcpv1alpha1.OIDCConfigTypeKubernetes}, + name: "successful resolution maps all fields", + 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", 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,12 +130,30 @@ 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: "resolution error returns error (fail-closed)", - oidcConfig: &mcpv1alpha1.OIDCConfigRef{Type: mcpv1alpha1.OIDCConfigTypeConfigMap}, - mockErr: errors.New("configmap not found"), + name: "fields mapped independently - jwksAllowPrivateIP true, protectedResourceAllowPrivateIP false", + 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, + }, + 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) + }, + }, + { + name: "resolution error returns error (fail-closed)", + 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() require.Error(t, err) @@ -114,9 +161,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: &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() require.NoError(t, err) @@ -124,16 +172,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: &mcpv1beta1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + oidcConfig: newTestMCPOIDCConfigInline(&mcpv1beta1.InlineOIDCSharedConfig{ + Issuer: "https://issuer.example.com", + ClientSecretRef: &mcpv1beta1.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() @@ -142,22 +189,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: &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() require.NoError(t, err) @@ -172,11 +207,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)) + config, _, err := converter.Convert(ctx, newTestVMCPServer(tt.oidcConfigRef), nil) tt.validate(t, config, err) }) @@ -186,14 +223,14 @@ func TestConverter_OIDCResolution(t *testing.T) { func TestConverter_MCPOIDCConfigRefMapsAllowedClientIDs(t *testing.T) { t.Parallel() - sharedConfig := &mcpv1alpha1.MCPOIDCConfig{ + sharedConfig := &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "shared-oidc", Namespace: "default", }, - Spec: mcpv1alpha1.MCPOIDCConfigSpec{ - Type: mcpv1alpha1.MCPOIDCConfigTypeInline, - Inline: &mcpv1alpha1.InlineOIDCSharedConfig{ + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ Issuer: "https://cognito-idp.eu-north-1.amazonaws.com/eu-north-1_Mr6I2Vf9J", AllowedClientIDs: []string{"sre-audit-agent", "litellm"}, }, @@ -214,13 +251,13 @@ func TestConverter_MCPOIDCConfigRefMapsAllowedClientIDs(t *testing.T) { converter, err := NewConverter(mockResolver, newTestK8sClient(t, sharedConfig)) require.NoError(t, err) - vmcp := &mcpv1alpha1.VirtualMCPServer{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "hub-agents", Namespace: "default"}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{Group: "test-group"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: "shared-oidc", Scopes: []string{"toolhive-mcp/automation"}, }, @@ -228,7 +265,7 @@ func TestConverter_MCPOIDCConfigRefMapsAllowedClientIDs(t *testing.T) { }, } - config, _, err := converter.Convert(log.IntoContext(context.Background(), logr.Discard()), vmcp) + config, _, err := converter.Convert(log.IntoContext(context.Background(), logr.Discard()), vmcp, nil) require.NoError(t, err) require.NotNil(t, config) require.NotNil(t, config.IncomingAuth) @@ -243,14 +280,14 @@ func TestConverter_MCPOIDCConfigRefMapsAllowedClientIDs(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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeTools: []vmcpconfig.CompositeToolConfig{ { Name: "test-composite-tool", @@ -277,7 +314,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) @@ -301,12 +338,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 + incomingAuth *mcpv1beta1.IncomingAuthConfig + oidcConfig *mcpv1beta1.MCPOIDCConfig // MCPOIDCConfig object to add to fake client expectedAuthType string expectedOIDCConfig *vmcpconfig.OIDCConfig expectNilAuth bool + mockReturn *oidc.OIDCConfig description string }{ { @@ -317,24 +358,26 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { }, { name: "explicit anonymous auth", - incomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + incomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, expectedAuthType: "anonymous", description: "Should use anonymous auth when explicitly specified", }, { - name: "explicit oidc auth with inline config", - incomingAuth: &mcpv1alpha1.IncomingAuthConfig{ - Type: "oidc", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://example.com", - ClientID: "test-client", - Audience: "test-audience", - }, - }, + name: "explicit oidc auth via MCPOIDCConfigRef", + incomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "oidc", + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + }, + oidcConfig: newTestMCPOIDCConfigInline(&mcpv1beta1.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{ @@ -342,21 +385,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"}, - }, - }, + incomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "oidc", + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "google-audience"}, + }, + oidcConfig: newTestMCPOIDCConfigInline(&mcpv1beta1.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{ @@ -369,18 +414,22 @@ 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", - }, - }, + incomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "oidc", + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: oidcConfigName, Audience: "test-audience"}, + }, + oidcConfig: newTestMCPOIDCConfigInline(&mcpv1beta1.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{ @@ -398,13 +447,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{ - Config: vmcpconfig.Config{Group: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, IncomingAuth: tt.incomingAuth, }, } @@ -413,23 +462,26 @@ 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) + config, _, err := converter.Convert(ctx, vmcpServer, nil) require.NoError(t, err, tt.description) require.NotNil(t, config, tt.description) @@ -459,26 +511,18 @@ func TestConverter_IncomingAuthRequired(t *testing.T) { func TestConverter_InlineAuthzPrimaryUpstreamProvider(t *testing.T) { t.Parallel() - vmcpServer := &mcpv1alpha1.VirtualMCPServer{ + vmcpServer := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ Config: vmcpconfig.Config{Group: "test-group"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ - Type: "oidc", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://issuer.example.com", - ClientID: "test-client", - Audience: "test-audience", - }, - }, - AuthzConfig: &mcpv1alpha1.AuthzConfigRef{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "anonymous", + AuthzConfig: &mcpv1beta1.AuthzConfigRef{ Type: authzLabelValueInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{"permit(principal, action, resource);"}, PrimaryUpstreamProvider: "cognito", }, @@ -489,15 +533,10 @@ func TestConverter_InlineAuthzPrimaryUpstreamProvider(t *testing.T) { ctrl := gomock.NewController(t) mockResolver := oidcmocks.NewMockResolver(ctrl) - mockResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(&oidc.OIDCConfig{ - Issuer: "https://issuer.example.com", - ClientID: "test-client", - Audience: "test-audience", - }, nil) 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) require.NotNil(t, config) @@ -511,7 +550,7 @@ func TestConverter_InlineAuthzPrimaryUpstreamProvider(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 } @@ -520,8 +559,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 @@ -529,27 +568,27 @@ 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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "referenced-tool"}, }, }, }, }, - 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", @@ -577,14 +616,14 @@ 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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeTools: []vmcpconfig.CompositeToolConfig{ { Name: "inline-tool", @@ -604,13 +643,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", @@ -640,34 +679,34 @@ 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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "non-existent-tool"}, }, }, }, }, - 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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeTools: []vmcpconfig.CompositeToolConfig{ { Name: "duplicate-tool", @@ -687,13 +726,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", @@ -713,30 +752,30 @@ 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{ - Config: vmcpconfig.Config{Group: "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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "tool1"}, {Name: "tool2"}, @@ -744,13 +783,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", @@ -769,7 +808,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", @@ -798,27 +837,27 @@ 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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "referenced-tool"}, }, }, }, }, - 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", @@ -890,7 +929,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) @@ -964,24 +1003,24 @@ 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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", CompositeToolRefs: []vmcpconfig.CompositeToolRef{ {Name: "comprehensive-tool"}, }, @@ -1001,7 +1040,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) @@ -1011,19 +1050,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 { @@ -1044,7 +1083,7 @@ func TestResolveMCPToolConfig(t *testing.T) { tests := []struct { name string configName string - existing *mcpv1alpha1.MCPToolConfig + existing *mcpv1beta1.MCPToolConfig expectError bool }{ { @@ -1061,7 +1100,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")}), }, } @@ -1099,7 +1138,7 @@ func TestMergeToolConfigFilter(t *testing.T) { tests := []struct { name string existing []string - config *mcpv1alpha1.MCPToolConfig + config *mcpv1beta1.MCPToolConfig expected []string }{ { @@ -1146,25 +1185,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"), @@ -1179,8 +1218,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), }), @@ -1195,7 +1234,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{ @@ -1221,7 +1260,7 @@ func TestConvertCRDToolOverride(t *testing.T) { tests := []struct { name string - input mcpv1alpha1.ToolOverride + input mcpv1beta1.ToolOverride expected *vmcpconfig.ToolOverride }{ { @@ -1231,7 +1270,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), @@ -1248,7 +1287,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"), }), @@ -1271,7 +1310,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 @@ -1294,7 +1333,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")}, @@ -1308,7 +1347,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")}, @@ -1331,7 +1370,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"}, } @@ -1356,7 +1395,7 @@ func TestResolveToolConfigRefs_FailClosed(t *testing.T) { tests := []struct { name string tools []*vmcpconfig.WorkloadToolConfig - existingConfig *mcpv1alpha1.MCPToolConfig + existingConfig *mcpv1beta1.MCPToolConfig expectError bool expectedErrMsg string }{ @@ -1406,7 +1445,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"}, } @@ -1430,18 +1469,18 @@ 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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", Aggregation: &vmcpconfig.AggregationConfig{ Tools: []*vmcpconfig.WorkloadToolConfig{{ Workload: "backend1", @@ -1457,11 +1496,11 @@ 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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", Aggregation: &vmcpconfig.AggregationConfig{ Tools: []*vmcpconfig.WorkloadToolConfig{{ Workload: "backend1", @@ -1476,10 +1515,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{ - Config: vmcpconfig.Config{Group: "test-group"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, }, }, existingConfig: nil, @@ -1502,7 +1541,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) @@ -1516,44 +1555,26 @@ 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{ + vmcp := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: "test-vmcp", Namespace: "default", }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, Config: vmcpconfig.Config{ - Group: "test-group", - Telemetry: inputTelemetry, + Telemetry: &telemetry.Config{ + Endpoint: "otlp-collector:4317", + ServiceName: "should-be-ignored", + }, }, }, } @@ -1561,133 +1582,27 @@ 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) - - // 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) - 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. 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{ - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, Config: vmcpconfig.Config{ - Group: "test-group", Telemetry: nil, // No telemetry config }, }, @@ -1696,95 +1611,25 @@ 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") } -// 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) - 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() 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:", @@ -1798,7 +1643,7 @@ func TestConverter_SessionStorage(t *testing.T) { }, { name: "memory provider results in nil SessionStorage", - sessionStorage: &mcpv1alpha1.SessionStorageConfig{ + sessionStorage: &mcpv1beta1.SessionStorageConfig{ Provider: "memory", }, expectedStorage: nil, @@ -1823,14 +1668,14 @@ 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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, Config: vmcpconfig.Config{ - Group: "test-group", SessionStorage: tt.inlineConfig, }, SessionStorage: tt.sessionStorage, @@ -1840,7 +1685,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) @@ -1958,6 +1803,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 @@ -1967,43 +1866,42 @@ 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(&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{ - 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", - }, - }, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "oidc", + 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", }, @@ -2015,7 +1913,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) @@ -2036,3 +1934,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 := &mcpv1beta1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "shared-telemetry", Namespace: "default"}, + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ + Enabled: true, + Endpoint: "https://otel-collector:4317", + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{ + Enabled: true, + SamplingRate: "0.5", + }, + Metrics: &mcpv1beta1.OpenTelemetryMetricsConfig{ + Enabled: true, + }, + }, + }, + } + + k8sClient := newTestK8sClient(t) + converter, err := NewConverter(newNoOpMockResolver(t), k8sClient) + require.NoError(t, err) + + vmcp := &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, + TelemetryConfigRef: &mcpv1beta1.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/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 5bffcf0630..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: 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: 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: 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: 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: 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: 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: 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: 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: 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 c41e0a9f2b..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,14 +103,15 @@ 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) - if mcpServer.Spec.GroupRef == "" { + mcpServer := obj.(*mcpv1beta1.MCPServer) + name := mcpServer.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServer.Spec.GroupRef} + return []string{name} }, ) Expect(err).ToNot(HaveOccurred()) @@ -118,14 +119,15 @@ 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) - if mcpRemoteProxy.Spec.GroupRef == "" { + mcpRemoteProxy := obj.(*mcpv1beta1.MCPRemoteProxy) + name := mcpRemoteProxy.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }, ) Expect(err).ToNot(HaveOccurred()) @@ -133,14 +135,15 @@ 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) - if mcpServerEntry.Spec.GroupRef == "" { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + 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/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 1b95278ab3..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 ( @@ -20,16 +20,15 @@ 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. -func newTestMCPRemoteProxy(name, namespace string, oidcConfigRefName string) *mcpv1alpha1.MCPRemoteProxy { - proxy := &mcpv1alpha1.MCPRemoteProxy{ +// 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) *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", @@ -37,20 +36,11 @@ 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"}, } - } else { - proxy.Spec.OIDCConfig = &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "https://auth.example.com", - Audience: "test-audience", - ClientID: "test-client", - }, - } } return proxy @@ -62,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 ) @@ -80,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", }, @@ -97,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, @@ -109,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 } } @@ -129,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, @@ -137,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, @@ -162,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, @@ -170,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 @@ -185,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 ) @@ -212,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, @@ -220,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, @@ -234,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()) }) }) @@ -249,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 @@ -269,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", }, @@ -286,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, @@ -299,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 } } @@ -312,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, @@ -336,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, @@ -347,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, @@ -360,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, @@ -379,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 ) @@ -397,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", }, @@ -414,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, @@ -431,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, @@ -439,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 @@ -459,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, @@ -473,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, @@ -491,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, @@ -506,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 ) @@ -524,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", }, @@ -541,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, @@ -553,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 } } @@ -566,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, @@ -574,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 @@ -585,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, @@ -605,27 +595,19 @@ 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, }, 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 Eventually(func() bool { - cfg := &mcpv1alpha1.MCPOIDCConfig{} + cfg := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -633,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 @@ -644,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, @@ -656,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()) }) @@ -667,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 ) @@ -685,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", }, @@ -702,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, @@ -719,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, @@ -727,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 @@ -748,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, @@ -756,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 6bb4651ff3..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 @@ -11,8 +11,9 @@ 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" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) @@ -28,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 ) @@ -49,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, @@ -58,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", }, @@ -75,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, @@ -87,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 } } @@ -95,19 +96,21 @@ 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{ - Config: vmcpconfig.Config{Group: groupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, + Config: vmcpconfig.Config{Group: groupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ - Name: configName, - Audience: "test-vmcp-audience", - Scopes: []string{"openid"}, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ + Name: configName, + Audience: "test-vmcp-audience", + Scopes: []string{"openid"}, + ResourceURL: "https://mcp-gateway.example.com/mcp", }, }, }, @@ -125,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, @@ -133,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 } @@ -143,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, @@ -155,9 +158,39 @@ var _ = Describe("MCPOIDCConfig and VirtualMCPServer Cross-Resource Integration }, timeout, interval).Should(BeTrue()) }) + 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 { + 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") + + // 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() { Eventually(func() bool { - updated := &mcpv1alpha1.MCPOIDCConfig{} + updated := &mcpv1beta1.MCPOIDCConfig{} err := k8sClient.Get(ctx, types.NamespacedName{ Name: configName, Namespace: namespace, @@ -165,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 @@ -182,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 ) @@ -203,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, @@ -212,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", }, @@ -229,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, @@ -241,16 +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{ - Config: vmcpconfig.Config{Group: groupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, + Config: vmcpconfig.Config{Group: groupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: configName, Audience: "test-vmcp-audience", Scopes: []string{"openid"}, @@ -262,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, @@ -270,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 @@ -292,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, @@ -300,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 @@ -317,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 ) @@ -338,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, @@ -347,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", }, @@ -364,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, @@ -376,16 +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{ - Config: vmcpconfig.Config{Group: groupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, + Config: vmcpconfig.Config{Group: groupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: configName, Audience: "test-vmcp-audience", Scopes: []string{"openid"}, @@ -397,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, @@ -405,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 @@ -425,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, @@ -440,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, @@ -458,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, @@ -473,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 ) @@ -492,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, @@ -501,16 +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{ - Config: vmcpconfig.Config{Group: groupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, + Config: vmcpconfig.Config{Group: groupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: "does-not-exist", Audience: "test-vmcp-audience", Scopes: []string{"openid"}, @@ -529,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, @@ -537,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()) }) }) @@ -554,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 ) @@ -577,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, @@ -586,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", }, @@ -603,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, @@ -615,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 } } @@ -623,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"}, @@ -640,16 +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{ - Config: vmcpconfig.Config{Group: groupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: groupName}, + Config: vmcpconfig.Config{Group: groupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfigRef: &mcpv1alpha1.MCPOIDCConfigReference{ + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ Name: configName, Audience: "test-vmcp-audience", Scopes: []string{"openid"}, @@ -670,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, @@ -678,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 7c97130b9f..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,23 +102,25 @@ 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 mcpServer.Spec.GroupRef == "" { + 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 } - return []string{mcpServer.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } // 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 == "" { + 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 } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } @@ -126,14 +128,15 @@ 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) - if mcpServerEntry.Spec.GroupRef == "" { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + 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-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 a06573cdf6..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 @@ -9,10 +9,9 @@ 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" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" ) var _ = Describe("MCPRemoteProxy Configuration Validation", Label("k8s", "remoteproxy", "validation"), func() { @@ -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") @@ -128,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, ) }) @@ -148,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, ) }) @@ -166,50 +94,19 @@ 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, ) }) }) 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"). @@ -217,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 { @@ -229,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 } } @@ -245,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 { @@ -257,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 } } @@ -273,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 { @@ -285,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 } } @@ -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 5ae72a1ca8..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,19 +83,10 @@ 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", - // 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", - }, - }, }, }, } @@ -109,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 @@ -117,27 +108,28 @@ 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, } return rb } -// WithOIDCConfigRef sets the OIDCConfigRef for the proxy and clears -// the inline OIDCConfig since they are mutually exclusive. -func (rb *RemoteProxyBuilder) WithOIDCConfigRef(name, audience string) *RemoteProxyBuilder { - rb.proxy.Spec.OIDCConfig = nil - rb.proxy.Spec.OIDCConfigRef = &mcpv1alpha1.MCPOIDCConfigReference{ - Name: name, - Audience: audience, +// WithOIDCConfigRef sets the OIDCConfigRef for the proxy. +// 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 = &mcpv1beta1.MCPOIDCConfigReference{ + Name: name, + Audience: resourceURL, + ResourceURL: resourceURL, } return rb } // 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 @@ -145,20 +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 = name - 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, - }, - } + rb.proxy.Spec.GroupRef = &mcpv1beta1.MCPGroupRef{Name: name} return rb } @@ -168,26 +147,11 @@ func (rb *RemoteProxyBuilder) WithRemoteURL(url string) *RemoteProxyBuilder { 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{ - Type: mcpv1alpha1.AuthzConfigTypeInline, - Inline: &mcpv1alpha1.InlineAuthzConfig{ + rb.proxy.Spec.AuthzConfig = &mcpv1beta1.AuthzConfigRef{ + Type: mcpv1beta1.AuthzConfigTypeInline, + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: policies, }, } @@ -196,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, }, @@ -210,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, }, @@ -226,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") @@ -239,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, @@ -251,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 @@ -262,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 @@ -289,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 c98c633472..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,23 +104,25 @@ 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 mcpServer.Spec.GroupRef == "" { + 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 } - return []string{mcpServer.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } // 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 == "" { + 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 } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } @@ -128,14 +130,15 @@ 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) - if mcpServerEntry.Spec.GroupRef == "" { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + 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_authserverref_integration_test.go b/cmd/thv-operator/test-integration/mcp-server/mcpserver_authserverref_integration_test.go index c6534c2ce5..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,18 +71,19 @@ 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{ - Name: oidcConfigName, - Audience: "https://test-resource.example.com", + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ + Name: oidcConfigName, + Audience: "https://test-resource.example.com", + ResourceURL: "https://test-resource.example.com", }, }, } @@ -90,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 } @@ -148,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", }, }, @@ -161,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", }, @@ -184,21 +185,22 @@ 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{ - Name: oidcConfigName, - Audience: "https://test-resource.example.com", + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ + Name: oidcConfigName, + Audience: "https://test-resource.example.com", + ResourceURL: "https://test-resource.example.com", }, }, } @@ -206,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 { @@ -257,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, }, @@ -281,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 "" } @@ -333,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", }, }, @@ -345,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", }, @@ -367,17 +369,18 @@ 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{ - Name: oidcConfigName, - Audience: "https://test-resource.example.com", + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ + Name: oidcConfigName, + Audience: "https://test-resource.example.com", + ResourceURL: "https://test-resource.example.com", }, }, } @@ -385,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}, }) }) @@ -414,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 ebf761dab9..72e7f00b4b 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 @@ -7,199 +7,32 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "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" ) // 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 { - return &mcpv1alpha1.MCPServer{ +// AuthzConfigRef for CEL validation testing. +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", - 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", &mcpv1beta1.AuthzConfigRef{ Type: "configMap", }) err := k8sClient.Create(ctx, server) @@ -208,12 +41,12 @@ 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", &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);"}, }, }) @@ -223,9 +56,9 @@ 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", &mcpv1beta1.AuthzConfigRef{ Type: "configMap", - ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{ + ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{ Name: "test-cm", }, }) @@ -236,7 +69,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", &mcpv1beta1.AuthzConfigRef{ Type: "inline", }) err := k8sClient.Create(ctx, server) @@ -245,12 +78,12 @@ 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", &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", }, }) @@ -260,9 +93,9 @@ 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", &mcpv1beta1.AuthzConfigRef{ Type: "inline", - Inline: &mcpv1alpha1.InlineAuthzConfig{ + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{"permit(principal, action, resource);"}, }, }) @@ -274,9 +107,9 @@ 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", &mcpv1beta1.AuthzConfigRef{ Type: "configMap", - Inline: &mcpv1alpha1.InlineAuthzConfig{ + Inline: &mcpv1beta1.InlineAuthzConfig{ Policies: []string{"permit(principal, action, resource);"}, }, }) @@ -289,115 +122,60 @@ 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")) + Context("groupRef transition shim", func() { + createRaw := func(name string, groupRef any) error { + obj := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "toolhive.stacklok.dev/v1beta1", + "kind": "MCPServer", + "metadata": map[string]any{"name": name, "namespace": "default"}, + "spec": map[string]any{"image": "example/mcp-server:latest", "groupRef": groupRef}, + }} + return k8sClient.Create(ctx, obj) + } + + It("should accept and decode the legacy string form", func() { + Expect(createRaw("groupref-string", "platform")).To(Succeed()) + + server := &mcpv1beta1.MCPServer{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "groupref-string", Namespace: "default"}, server)).To(Succeed()) + Expect(server.Spec.GroupRef.GetName()).To(Equal("platform")) }) - 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()) - }) - }) + It("should accept the object form", func() { + server := newMinimalMCPServer("groupref-object", nil) + server.Spec.GroupRef = &mcpv1beta1.MCPGroupRef{Name: "platform"} + Expect(k8sClient.Create(ctx, server)).To(Succeed()) - 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")) + fetched := &mcpv1beta1.MCPServer{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "groupref-object", Namespace: "default"}, fetched)).To(Succeed()) + Expect(fetched.Spec.GroupRef.GetName()).To(Equal("platform")) }) - 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 the legacy string form through v1alpha1", func() { + obj := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "toolhive.stacklok.dev/v1alpha1", + "kind": "MCPServer", + "metadata": map[string]any{"name": "groupref-string-v1alpha1", "namespace": "default"}, + "spec": map[string]any{"image": "example/mcp-server:latest", "groupRef": "platform"}, + }} + Expect(k8sClient.Create(ctx, obj)).To(Succeed()) }) - 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()) + It("should decode an unexpected groupRef shape to an empty name without failing", func() { + Expect(createRaw("groupref-number", int64(42))).To(Succeed()) + + list := &mcpv1beta1.MCPServerList{} + Expect(k8sClient.List(ctx, list)).To(Succeed()) + server := &mcpv1beta1.MCPServer{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "groupref-number", Namespace: "default"}, server)).To(Succeed()) + Expect(server.Spec.GroupRef.GetName()).To(BeEmpty()) }) }) Context("SecretRef schema validation", func() { It("should accept multiple keys from the same Kubernetes Secret", func() { - server := newMinimalMCPServer("secret-ref-multi-key", nil, nil) - server.Spec.Secrets = []mcpv1alpha1.SecretRef{ + server := newMinimalMCPServer("secret-ref-multi-key", nil) + server.Spec.Secrets = []mcpv1beta1.SecretRef{ {Name: "shared-credentials", Key: "username", TargetEnvName: "API_USERNAME"}, {Name: "shared-credentials", Key: "password", TargetEnvName: "API_PASSWORD"}, } @@ -407,8 +185,8 @@ var _ = Describe("CEL Validation for OIDCConfigRef and AuthzConfigRef", Label("k }) It("should still reject an exact duplicate secret reference", func() { - server := newMinimalMCPServer("secret-ref-duplicate-key", nil, nil) - server.Spec.Secrets = []mcpv1alpha1.SecretRef{ + server := newMinimalMCPServer("secret-ref-duplicate-key", nil) + server.Spec.Secrets = []mcpv1beta1.SecretRef{ {Name: "shared-credentials", Key: "token", TargetEnvName: "API_TOKEN"}, {Name: "shared-credentials", Key: "token", TargetEnvName: "SECOND_API_TOKEN"}, } 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..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: "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: 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 edcb62a849..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, @@ -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,29 +466,28 @@ 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 - telCfg := &mcpv1alpha1.MCPTelemetryConfig{ + // Create the MCPTelemetryConfig resource + 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, @@ -568,18 +495,17 @@ 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 - mcpServer := &mcpv1alpha1.MCPServer{ + // Create MCPServer with telemetryConfigRef + 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", }, @@ -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()) @@ -626,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, @@ -650,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 }, @@ -700,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");`, @@ -766,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}, }, @@ -860,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" @@ -1006,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 ca9359dd27..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) @@ -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-operator/test-integration/mcp-server/suite_test.go b/cmd/thv-operator/test-integration/mcp-server/suite_test.go index 71f0343f47..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,23 +104,25 @@ 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 mcpServer.Spec.GroupRef == "" { + 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 } - return []string{mcpServer.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } // 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 == "" { + 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 } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } @@ -128,14 +130,15 @@ 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) - if mcpServerEntry.Spec.GroupRef == "" { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + 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-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 7a045d2d3a..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,23 +104,25 @@ 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 mcpServer.Spec.GroupRef == "" { + 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 } - return []string{mcpServer.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } // 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 == "" { + 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 } - return []string{mcpRemoteProxy.Spec.GroupRef} + return []string{name} }); err != nil { Expect(err).ToNot(HaveOccurred()) } @@ -128,14 +130,15 @@ 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) - if mcpServerEntry.Spec.GroupRef == "" { + mcpServerEntry := obj.(*mcpv1beta1.MCPServerEntry) + name := mcpServerEntry.Spec.GroupRef.GetName() + if name == "" { return nil } - return []string{mcpServerEntry.Spec.GroupRef} + return []string{name} }, ) Expect(err).ToNot(HaveOccurred()) @@ -146,6 +149,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_compositetool_watch_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_compositetool_watch_test.go index 994ae71f12..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,29 +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{ + 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", }, }, @@ -89,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, @@ -100,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 @@ -120,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", @@ -159,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, @@ -170,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 @@ -181,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, @@ -196,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, @@ -204,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), )) }) }) @@ -216,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() { @@ -228,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", }, } @@ -241,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", @@ -271,19 +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{ + 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", }, }, @@ -292,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, @@ -311,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, @@ -325,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, @@ -339,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, @@ -347,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), )) }) }) @@ -359,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() { @@ -371,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", }, } @@ -384,23 +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{ - Config: vmcpconfig.Config{Group: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + Config: vmcpconfig.Config{Group: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, // No CompositeToolRefs @@ -410,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, @@ -428,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, @@ -445,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", @@ -470,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 29218fbdbe..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,19 +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{ + 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", }, }, @@ -148,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, @@ -159,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 @@ -176,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, @@ -185,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, @@ -214,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")) @@ -225,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")) @@ -240,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() { @@ -252,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", }, } @@ -265,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", @@ -287,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{ @@ -300,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{ @@ -313,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{ @@ -330,19 +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{ + 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", }, }, @@ -351,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, @@ -367,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, @@ -393,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, @@ -401,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)) } @@ -423,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() { @@ -435,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", }, } @@ -448,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", @@ -470,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"}, @@ -487,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"}, @@ -505,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"}, }, @@ -516,19 +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{ + 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", }, }, @@ -537,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, @@ -553,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, @@ -563,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")) @@ -577,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, @@ -585,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 75cd9773d3..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: 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,17 +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{ - Config: vmcpconfig.Config{Group: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + Config: vmcpconfig.Config{Group: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, - OutgoingAuth: &mcpv1alpha1.OutgoingAuthConfig{ + OutgoingAuth: &mcpv1beta1.OutgoingAuthConfig{ Source: "discovered", // Use discovered mode }, }, @@ -121,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, @@ -140,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, @@ -154,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, @@ -168,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, @@ -176,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 11255e8dd5..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,26 +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{ - Config: vmcpconfig.Config{Group: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + Config: vmcpconfig.Config{Group: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, // Invalid PodTemplateSpec - containers should be an array, not a string @@ -97,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, @@ -117,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, @@ -149,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, @@ -158,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")) @@ -170,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() { @@ -191,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", }, } @@ -205,14 +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{ - Config: vmcpconfig.Config{Group: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + Config: vmcpconfig.Config{Group: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, PodTemplateSpec: &runtime.RawExtension{ @@ -234,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, @@ -273,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() { @@ -294,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", }, } @@ -308,14 +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{ - Config: vmcpconfig.Config{Group: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + Config: vmcpconfig.Config{Group: mcpGroupName}, + 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 3643bf46e0..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,26 +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{ - Config: vmcpconfig.Config{Group: "test-group-replicas"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-replicas"}, + Config: vmcpconfig.Config{Group: "test-group-replicas"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, Replicas: &replicas, @@ -91,30 +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{ - Config: vmcpconfig.Config{Group: "test-group-nil-replicas"}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-nil-replicas"}, + Config: vmcpconfig.Config{Group: "test-group-nil-replicas"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, }, @@ -160,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, @@ -180,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 c7383854d3..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,18 +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{ - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, Config: vmcpconfig.Config{ @@ -35,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) @@ -44,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: "", }) @@ -53,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", }) @@ -62,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, @@ -74,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 new file mode 100644 index 0000000000..84a06f8ed9 --- /dev/null +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_telemetryconfig_integration_test.go @@ -0,0 +1,372 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package controllers contains integration tests for the VirtualMCPServer controller +package controllers + +import ( + "fmt" + "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" + "sigs.k8s.io/yaml" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + 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 *mcpv1beta1.MCPGroup + telemetryConfig *mcpv1beta1.MCPTelemetryConfig + virtualMCPServer *mcpv1beta1.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 = &mcpv1beta1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-group-telemetry-hash", + Namespace: namespace, + }, + Spec: mcpv1beta1.MCPGroupSpec{ + Description: "Test group for telemetry config hash test", + }, + } + Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) + + telemetryConfig = &mcpv1beta1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-telemetry-vmcp-hash", + Namespace: namespace, + }, + } + telemetryConfig.Spec.OpenTelemetry = &mcpv1beta1.MCPTelemetryOTelConfig{ + Enabled: true, + Endpoint: "https://otel-collector:4317", + 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 := &mcpv1beta1.MCPTelemetryConfig{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: telemetryConfig.Name, + Namespace: namespace, + }, fetched) + return err == nil && fetched.Status.ConfigHash != "" + }, timeout, interval).Should(BeTrue()) + + virtualMCPServer = &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vmcp-telemetry-hash", + Namespace: namespace, + }, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-telemetry-hash"}, + Config: vmcpconfig.Config{Group: "test-group-telemetry-hash"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "anonymous", + }, + TelemetryConfigRef: &mcpv1beta1.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 := &mcpv1beta1.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 := &mcpv1beta1.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 == mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated { + return cond.Status == metav1.ConditionTrue && + cond.Reason == mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefValid + } + } + 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() { + var ( + mcpGroup *mcpv1beta1.MCPGroup + telemetryConfig *mcpv1beta1.MCPTelemetryConfig + virtualMCPServer *mcpv1beta1.VirtualMCPServer + initialHash string + ) + + BeforeAll(func() { + mcpGroup = &mcpv1beta1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-group-telemetry-update", + Namespace: namespace, + }, + Spec: mcpv1beta1.MCPGroupSpec{ + Description: "Test group for telemetry config update test", + }, + } + Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) + + telemetryConfig = &mcpv1beta1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-telemetry-vmcp-update", + Namespace: namespace, + }, + } + telemetryConfig.Spec.OpenTelemetry = &mcpv1beta1.MCPTelemetryOTelConfig{ + Enabled: true, + Endpoint: "https://otel-collector:4317", + Tracing: &mcpv1beta1.OpenTelemetryTracingConfig{Enabled: true}, + } + Expect(k8sClient.Create(ctx, telemetryConfig)).Should(Succeed()) + + // Wait for the MCPTelemetryConfig controller to set ConfigHash + Eventually(func() bool { + fetched := &mcpv1beta1.MCPTelemetryConfig{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: telemetryConfig.Name, + Namespace: namespace, + }, fetched) + return err == nil && fetched.Status.ConfigHash != "" + }, timeout, interval).Should(BeTrue()) + + virtualMCPServer = &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vmcp-telemetry-update", + Namespace: namespace, + }, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-telemetry-update"}, + Config: vmcpconfig.Config{Group: "test-group-telemetry-update"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "anonymous", + }, + TelemetryConfigRef: &mcpv1beta1.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 := &mcpv1beta1.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 := &mcpv1beta1.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 := &mcpv1beta1.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()) + + // 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()) + }) + }) + + Context("VirtualMCPServer referencing non-existent MCPTelemetryConfig", Ordered, func() { + var ( + mcpGroup *mcpv1beta1.MCPGroup + virtualMCPServer *mcpv1beta1.VirtualMCPServer + ) + + BeforeAll(func() { + mcpGroup = &mcpv1beta1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-group-telemetry-notfound", + Namespace: namespace, + }, + Spec: mcpv1beta1.MCPGroupSpec{ + Description: "Test group for telemetry config not found test", + }, + } + Expect(k8sClient.Create(ctx, mcpGroup)).Should(Succeed()) + + virtualMCPServer = &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vmcp-telemetry-notfound", + Namespace: namespace, + }, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group-telemetry-notfound"}, + Config: vmcpconfig.Config{Group: "test-group-telemetry-notfound"}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "anonymous", + }, + TelemetryConfigRef: &mcpv1beta1.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 := &mcpv1beta1.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 == mcpv1beta1.ConditionTypeVirtualMCPServerTelemetryConfigRefValidated { + return cond.Status == metav1.ConditionFalse && + cond.Reason == mcpv1beta1.ConditionReasonVirtualMCPServerTelemetryConfigRefNotFound + } + } + return false + }, timeout, interval).Should(BeTrue()) + }) + }) + + }) 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/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/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/common.go b/cmd/thv/app/common.go index 83cf55ef01..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" ) @@ -78,9 +79,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) @@ -154,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/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/export.go b/cmd/thv/app/export.go index b0d8161161..cacb816587 100644 --- a/cmd/thv/app/export.go +++ b/cmd/thv/app/export.go @@ -96,6 +96,18 @@ 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") + } + + // 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/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/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/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/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 cb874584e7..94c159af8a 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), @@ -955,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, @@ -1016,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/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/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/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/cmd/thv/app/vmcp.go b/cmd/thv/app/vmcp.go new file mode 100644 index 0000000000..15fb12e92f --- /dev/null +++ b/cmd/thv/app/vmcp.go @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +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. +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.`, + Hidden: true, + } + cmd.AddCommand(newVMCPServeCommand()) + cmd.AddCommand(newVMCPValidateCommand()) + cmd.AddCommand(newVMCPInitCommand()) + return cmd +} + +// newVMCPServeCommand returns the "vmcp serve" subcommand. +func newVMCPServeCommand() *cobra.Command { + var ( + configPath string + group string + host string + port int + enableAudit bool + enableOptimizer bool + enableEmbedding bool + embeddingModel string + embeddingImage string + ) + 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. + +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, + 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") + 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 + 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/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/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/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/cmd/vmcp/app/commands.go b/cmd/vmcp/app/commands.go index 1381735f31..219f2deecb 100644 --- a/cmd/vmcp/app/commands.go +++ b/cmd/vmcp/app/commands.go @@ -5,52 +5,14 @@ package app import ( - "context" - "errors" "fmt" "log/slog" - "net" - "os" - "path/filepath" - "strings" - "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" -) - -const ( - envVMCPSessionOwnerURL = "THV_VMCP_SESSION_OWNER_URL" - envPodIP = "POD_IP" - backendResponseMargin = 5 * time.Second + "github.com/stacklok/toolhive/pkg/versions" + vmcpcli "github.com/stacklok/toolhive/pkg/vmcp/cli" ) var rootCmd = &cobra.Command{ @@ -113,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 @@ -131,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)) }, } } @@ -149,598 +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, - backendInitTimeout time.Duration, - backendRequestTimeout time.Duration, - perWorkloadRequestTimeouts map[string]time.Duration, -) (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)) - } - if backendInitTimeout > 0 { - opts = append(opts, vmcpsession.WithBackendInitTimeout(backendInitTimeout)) - } - if backendRequestTimeout > 0 || len(perWorkloadRequestTimeouts) > 0 { - opts = append(opts, vmcpsession.WithBackendRequestTimeouts( - backendRequestTimeout, - perWorkloadRequestTimeouts, - )) - } - - 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 -} - -func backendInitTimeoutFromConfig(cfg *config.Config) time.Duration { - if cfg == nil || cfg.Operational == nil { - return 0 - } - - if cfg.Operational.FailureHandling != nil && cfg.Operational.FailureHandling.HealthCheckTimeout > 0 { - return time.Duration(cfg.Operational.FailureHandling.HealthCheckTimeout) - } - - if cfg.Operational.Timeouts != nil && cfg.Operational.Timeouts.Default > 0 { - return time.Duration(cfg.Operational.Timeouts.Default) - } - - return 0 -} - -func backendRequestTimeoutsFromConfig(cfg *config.Config) (time.Duration, map[string]time.Duration) { - if cfg == nil || cfg.Operational == nil || cfg.Operational.Timeouts == nil { - return 0, nil - } - - timeouts := cfg.Operational.Timeouts - perWorkload := make(map[string]time.Duration, len(timeouts.PerWorkload)) - for workloadID, timeout := range timeouts.PerWorkload { - perWorkload[workloadID] = time.Duration(timeout) - } - - return time.Duration(timeouts.Default), perWorkload -} - -func serverWriteTimeoutFromConfig(cfg *config.Config) time.Duration { - defaultTimeout, perWorkload := backendRequestTimeoutsFromConfig(cfg) - maxTimeout := defaultTimeout - for _, timeout := range perWorkload { - if timeout > maxTimeout { - maxTimeout = timeout - } - } - if maxTimeout <= 0 { - return 0 - } - return maxTimeout + backendResponseMargin -} - -// 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) - } - - backendRequestTimeout, perWorkloadRequestTimeouts := backendRequestTimeoutsFromConfig(cfg) - sessionFactory, err := createSessionFactory( - outgoingRegistry, - agg, - backendInitTimeoutFromConfig(cfg), - backendRequestTimeout, - perWorkloadRequestTimeouts, - ) - 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: backendWatcher, - StatusReporter: statusReporter, - OptimizerConfig: optCfg, - SessionStorage: cfg.SessionStorage, - SessionTTL: time.Duration(cfg.SessionTTL), - WriteTimeout: serverWriteTimeoutFromConfig(cfg), - SessionOwnerAdvertiseURL: resolveSessionOwnerAdvertiseURL( - os.Getenv(envVMCPSessionOwnerURL), - os.Getenv(envPodIP), - port, - ), - SessionFactory: sessionFactory, - } - - // 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) -} - -func resolveSessionOwnerAdvertiseURL(explicit, podIP string, port int) string { - if explicit = strings.TrimSpace(explicit); explicit != "" { - return explicit - } - if podIP == "" || port <= 0 { - return "" - } - return fmt.Sprintf("http://%s/mcp", net.JoinHostPort(podIP, fmt.Sprintf("%d", port))) -} 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/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 \ diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index 9e4869fca9..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.19.0 -appVersion: "v0.19.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 42722e6368..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.19.0](https://img.shields.io/badge/Version-0.19.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-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 ccda3bf768..85ee53e177 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,1031 @@ 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 + address: + description: |- + Address is the Redis-compatible server address for direct mode (host:port). + Mutually exclusive with SentinelConfig. + type: string + 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 for HA mode. + Mutually exclusive with Address. + 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 + 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: |- @@ -485,6 +1509,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. @@ -631,6 +1667,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. @@ -666,6 +1716,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_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 9401d8eaeb..7641edd441 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,309 @@ 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: + allowedClientIds: + description: |- + AllowedClientIDs is the set of OAuth client IDs accepted when validating + tokens by their client_id claim instead of an audience claim. + items: + type: string + type: array + 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: |- @@ -138,8 +440,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 @@ -150,8 +454,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_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 c159775034..c462fcc180 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,596 @@ 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 + 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 + 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 should be unique per server to prevent token replay attacks. It may + be omitted only when the referenced provider validates tokens by + allowedClientIds because the issuer does not emit an aud claim (for + example Cognito client_credentials access tokens). + 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: + - name + type: object + podTemplateSpec: + description: |- + PodTemplateSpec allows customizing the pod template for the proxy deployment. + Scheduling fields (nodeSelector, tolerations, affinity) are applied to the proxy pods. + type: object + x-kubernetes-preserve-unknown-fields: true + 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 + replicas: + description: |- + Replicas is the number of proxy deployment replicas. + When nil, defaults to 1. Set to nil when using an external HPA. + format: int32 + minimum: 1 + 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 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: |- @@ -178,9 +767,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. @@ -229,227 +826,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 - 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 - 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. @@ -471,8 +847,10 @@ spec: type: string 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. + 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: |- @@ -650,90 +1028,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 @@ -781,13 +1080,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))' - - 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_mcpserverentries.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpserverentries.yaml index 3c584effe7..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 @@ -28,13 +28,267 @@ spec: - jsonPath: .spec.remoteUrl name: Remote URL type: string - - jsonPath: .spec.groupRef + - jsonPath: .spec.groupRef.name name: Group type: string - 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: |- @@ -110,10 +364,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 c7d8d21e92..ef975bb047 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,882 @@ 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 + 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 + 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. + Accepts {name: <group>} or, for objects created before v0.20, a plain + group name string. Both forms decode to the same MCPGroupRef. The schema + is untyped to admit both forms, so CEL cannot validate it; an invalid + value decodes to an empty name and is reported by the GroupRefValidated + condition. + x-kubernetes-preserve-unknown-fields: true + 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 should be unique per server to prevent token replay attacks. It may + be omitted only when the referenced provider validates tokens by + allowedClientIds because the issuer does not emit an aud claim (for + example Cognito client_credentials access tokens). + 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: + - 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. + The same Kubernetes Secret may be referenced multiple times as long as each + entry points at a different key within that Secret. + 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 + - key + 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 + usernameRef: + description: UsernameRef is a reference to a Secret key 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: + - 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 @@ -212,9 +1087,14 @@ 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. + Accepts {name: <group>} or, for objects created before v0.20, a plain + group name string. Both forms decode to the same MCPGroupRef. The schema + is untyped to admit both forms, so CEL cannot validate it; an invalid + value decodes to an empty name and is reported by the GroupRefValidated + condition. + x-kubernetes-preserve-unknown-fields: true image: description: Image is the container image for the MCP server type: string @@ -224,233 +1104,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 - 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 - 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: |- @@ -466,8 +1125,10 @@ spec: type: string 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. + 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: |- @@ -896,90 +1557,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 @@ -1057,24 +1639,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: 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'')' - - 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/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 e9a8b4936f..fa33181d27 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,2338 @@ 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 + address: + description: |- + Address is the Redis-compatible server address for direct mode (host:port). + Mutually exclusive with SentinelConfig. + type: string + 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 for HA mode. + Mutually exclusive with Address. + 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 + 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: + allowedClientIds: + description: |- + AllowedClientIDs is the set of OAuth client IDs accepted when validating + tokens by their client_id claim instead of an audience claim. + items: + type: string + type: array + 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 + sessionTTL: + description: |- + SessionTTL configures the idle TTL for vMCP sessions. + When omitted, the server default is used. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + 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. + Optional in the schema so objects created before groupRef was introduced + still validate; the controller rejects a VirtualMCPServer without it. + 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 + 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 + 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 should be unique per server to prevent token replay attacks. It may + be omitted only when the referenced provider validates tokens by + allowedClientIds because the issuer does not emit an aud claim (for + example Cognito client_credentials access tokens). + 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: + - 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 + usernameRef: + description: UsernameRef is a reference to a Secret key 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: + - 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: + - 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: |- @@ -71,7 +2402,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: @@ -370,6 +2701,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. @@ -516,6 +2859,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. @@ -551,6 +2908,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 @@ -644,11 +3004,8 @@ 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. - The telemetry and audit config from here are also supported, but not required. + Config is the Virtual MCP server configuration. + The audit config from here is also supported, but not required. properties: aggregation: description: |- @@ -794,6 +3151,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: |- @@ -1177,7 +3543,8 @@ spec: groupRef: description: |- Group references an existing MCPGroup that defines backend workloads. - In Kubernetes, the referenced MCPGroup must exist in the same namespace. + 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: |- @@ -1701,6 +4068,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: |- @@ -1785,8 +4155,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: @@ -1802,6 +4170,21 @@ spec: required: - name type: object + groupRef: + description: |- + GroupRef references the MCPGroup that defines backend workloads. + The referenced MCPGroup must exist in the same namespace. + Optional in the schema so objects created before groupRef was introduced + still validate; the controller rejects a VirtualMCPServer without it. + 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. @@ -1876,236 +4259,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 - 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 - 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: |- @@ -2121,8 +4280,10 @@ spec: type: string 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. + 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: |- @@ -2147,13 +4308,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. @@ -2169,7 +4326,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 @@ -2183,7 +4340,6 @@ spec: enum: - discovered - externalAuthConfigRef - - external_auth_config_ref type: string required: - type @@ -2199,7 +4355,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 @@ -2213,7 +4369,6 @@ spec: enum: - discovered - externalAuthConfigRef - - external_auth_config_ref type: string required: - type @@ -2333,6 +4488,26 @@ 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. + 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 @@ -2341,8 +4516,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: @@ -2455,7 +4630,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: @@ -2491,6 +4666,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_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 16bc9f92bb..be9d535c94 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,1031 @@ 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 + address: + description: |- + Address is the Redis-compatible server address for direct mode (host:port). + Mutually exclusive with SentinelConfig. + type: string + 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 for HA mode. + Mutually exclusive with Address. + 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 + 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: |- @@ -488,6 +1512,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. @@ -634,6 +1670,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. @@ -669,6 +1719,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_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 3c0af8c125..17936b8650 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,309 @@ 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: + allowedClientIds: + description: |- + AllowedClientIDs is the set of OAuth client IDs accepted when validating + tokens by their client_id claim instead of an audience claim. + items: + type: string + type: array + 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: |- @@ -141,8 +443,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 @@ -153,8 +457,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_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 da94d72cec..fe45e3ea28 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,596 @@ 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 + 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 + 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 should be unique per server to prevent token replay attacks. It may + be omitted only when the referenced provider validates tokens by + allowedClientIds because the issuer does not emit an aud claim (for + example Cognito client_credentials access tokens). + 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: + - name + type: object + podTemplateSpec: + description: |- + PodTemplateSpec allows customizing the pod template for the proxy deployment. + Scheduling fields (nodeSelector, tolerations, affinity) are applied to the proxy pods. + type: object + x-kubernetes-preserve-unknown-fields: true + 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 + replicas: + description: |- + Replicas is the number of proxy deployment replicas. + When nil, defaults to 1. Set to nil when using an external HPA. + format: int32 + minimum: 1 + 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 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: |- @@ -181,9 +770,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. @@ -232,227 +829,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 - 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 - 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. @@ -474,8 +850,10 @@ spec: type: string 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. + 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: |- @@ -653,90 +1031,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 @@ -784,13 +1083,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))' - - 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_mcpserverentries.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml index 030644ea3b..2cff1d9a86 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml @@ -31,13 +31,267 @@ spec: - jsonPath: .spec.remoteUrl name: Remote URL type: string - - jsonPath: .spec.groupRef + - jsonPath: .spec.groupRef.name name: Group type: string - 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: |- @@ -113,10 +367,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 9700a6f003..da66fd8192 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,882 @@ 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 + 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 + 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. + Accepts {name: <group>} or, for objects created before v0.20, a plain + group name string. Both forms decode to the same MCPGroupRef. The schema + is untyped to admit both forms, so CEL cannot validate it; an invalid + value decodes to an empty name and is reported by the GroupRefValidated + condition. + x-kubernetes-preserve-unknown-fields: true + 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 should be unique per server to prevent token replay attacks. It may + be omitted only when the referenced provider validates tokens by + allowedClientIds because the issuer does not emit an aud claim (for + example Cognito client_credentials access tokens). + 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: + - 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. + The same Kubernetes Secret may be referenced multiple times as long as each + entry points at a different key within that Secret. + 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 + - key + 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 + usernameRef: + description: UsernameRef is a reference to a Secret key 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: + - 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 @@ -215,9 +1090,14 @@ 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. + Accepts {name: <group>} or, for objects created before v0.20, a plain + group name string. Both forms decode to the same MCPGroupRef. The schema + is untyped to admit both forms, so CEL cannot validate it; an invalid + value decodes to an empty name and is reported by the GroupRefValidated + condition. + x-kubernetes-preserve-unknown-fields: true image: description: Image is the container image for the MCP server type: string @@ -227,233 +1107,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 - 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 - 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: |- @@ -469,8 +1128,10 @@ spec: type: string 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. + 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: |- @@ -899,90 +1560,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 @@ -1060,24 +1642,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: 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'')' - - 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_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 f402b36a00..eb6ed618a0 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,2338 @@ 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 + address: + description: |- + Address is the Redis-compatible server address for direct mode (host:port). + Mutually exclusive with SentinelConfig. + type: string + 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 for HA mode. + Mutually exclusive with Address. + 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 + 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: + allowedClientIds: + description: |- + AllowedClientIDs is the set of OAuth client IDs accepted when validating + tokens by their client_id claim instead of an audience claim. + items: + type: string + type: array + 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 + sessionTTL: + description: |- + SessionTTL configures the idle TTL for vMCP sessions. + When omitted, the server default is used. + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + 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. + Optional in the schema so objects created before groupRef was introduced + still validate; the controller rejects a VirtualMCPServer without it. + 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 + 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 + 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 should be unique per server to prevent token replay attacks. It may + be omitted only when the referenced provider validates tokens by + allowedClientIds because the issuer does not emit an aud claim (for + example Cognito client_credentials access tokens). + 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: + - 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 + usernameRef: + description: UsernameRef is a reference to a Secret key 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: + - 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: + - 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: |- @@ -74,7 +2405,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: @@ -373,6 +2704,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. @@ -519,6 +2862,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. @@ -554,6 +2911,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 @@ -647,11 +3007,8 @@ 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. - The telemetry and audit config from here are also supported, but not required. + Config is the Virtual MCP server configuration. + The audit config from here is also supported, but not required. properties: aggregation: description: |- @@ -797,6 +3154,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: |- @@ -1180,7 +3546,8 @@ spec: groupRef: description: |- Group references an existing MCPGroup that defines backend workloads. - In Kubernetes, the referenced MCPGroup must exist in the same namespace. + 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: |- @@ -1704,6 +4071,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: |- @@ -1788,8 +4158,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: @@ -1805,6 +4173,21 @@ spec: required: - name type: object + groupRef: + description: |- + GroupRef references the MCPGroup that defines backend workloads. + The referenced MCPGroup must exist in the same namespace. + Optional in the schema so objects created before groupRef was introduced + still validate; the controller rejects a VirtualMCPServer without it. + 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. @@ -1879,236 +4262,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 - 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 - 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: |- @@ -2124,8 +4283,10 @@ spec: type: string 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. + 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: |- @@ -2150,13 +4311,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. @@ -2172,7 +4329,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 @@ -2186,7 +4343,6 @@ spec: enum: - discovered - externalAuthConfigRef - - external_auth_config_ref type: string required: - type @@ -2202,7 +4358,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 @@ -2216,7 +4372,6 @@ spec: enum: - discovered - externalAuthConfigRef - - external_auth_config_ref type: string required: - type @@ -2336,6 +4491,26 @@ 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. + 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 @@ -2344,8 +4519,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: @@ -2458,7 +4633,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: @@ -2494,6 +4669,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/Chart.yaml b/deploy/charts/operator/Chart.yaml index 7a7ae616f6..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.19.0 -appVersion: "v0.19.0" +version: 0.23.1 +appVersion: "v0.23.1" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index 216025b6c1..e96bc0589c 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.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. @@ -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+ @@ -52,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.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":{},"pdb":{"enabled":false,"maxUnavailable":1},"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.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":{},"pdb":{"enabled":false,"maxUnavailable":1},"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 | @@ -68,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.19.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 | @@ -98,10 +92,10 @@ 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.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.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/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 628e79de26..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.0.1" diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index 8ccc20ad64..64575e2ace 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.23.1 # -- 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.23.1 # -- 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.23.1 # -- Host for the proxy deployed by the operator proxyHost: 0.0.0.0 @@ -213,4 +213,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/keycloak/mcpserver-with-auth.yaml b/deploy/keycloak/mcpserver-with-auth.yaml index f0c9709fab..85ecf97a3a 100644 --- a/deploy/keycloak/mcpserver-with-auth.yaml +++ b/deploy/keycloak/mcpserver-with-auth.yaml @@ -1,4 +1,19 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 +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/v1beta1 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/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 526c849b15..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 (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/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/05-runconfig-and-permissions.md b/docs/arch/05-runconfig-and-permissions.md index 41a59802fd..3127909f04 100644 --- a/docs/arch/05-runconfig-and-permissions.md +++ b/docs/arch/05-runconfig-and-permissions.md @@ -90,9 +90,9 @@ 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` - - Node: Default `node:22-alpine` - - Python: Default `python:3.13-slim` + - Go: Default `golang:1.26-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) **CLI usage:** @@ -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/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 d3f17173ca..82eff27f4a 100644 --- a/docs/arch/09-operator-architecture.md +++ b/docs/arch/09-operator-architecture.md @@ -129,17 +129,21 @@ graph TB VMCP -->|groupRef| Group VMCP -->|compositeToolRefs| CTD VMCP -.->|oidcConfigRef| OIDCCfg + VMCP -.->|telemetryConfigRef| TelCfg 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 Entry -->|groupRef| Group Entry -.->|externalAuthConfigRef| ExtAuth @@ -150,17 +154,19 @@ 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. 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`) +- `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. -Both ref fields are mutually exclusive with their inline counterparts (enforced by CEL validation). +**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`) for change detection on referenced CRDs. +**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 @@ -172,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. @@ -184,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. @@ -196,11 +202,15 @@ 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 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. -**Referenced by MCPServer** using `oidcConfig.type: external`. +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 and MCPRemoteProxy** using `externalAuthConfigRef` or `authServerRef`. **Controller**: `cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go` @@ -208,7 +218,7 @@ MCPExternalAuthConfig allows you to define reusable OIDC authentication configur 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. @@ -219,6 +229,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). @@ -232,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. @@ -243,7 +254,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` @@ -255,15 +266,33 @@ 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) -- `externalAuthConfigRef` - Token exchange for remote service authentication +- `oidcConfigRef` - Reference to shared MCPOIDCConfig (with per-server `audience`, `scopes`, and `resourceUrl`) +- `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 -`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` +**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/v1beta1 +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/v1beta1/mcpremoteproxy_types.go` **Controller**: `cmd/thv-operator/controllers/mcpremoteproxy_controller.go` @@ -281,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` @@ -289,13 +318,13 @@ 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` 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` @@ -303,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: @@ -349,7 +378,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` @@ -372,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. @@ -611,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 @@ -630,12 +659,13 @@ 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:** ```yaml # Define shared telemetry config once -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPTelemetryConfig metadata: name: shared-otel @@ -657,29 +687,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/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/arch/11-auth-server-storage.md b/docs/arch/11-auth-server-storage.md index 9302d0b235..224e8c190d 100644 --- a/docs/arch/11-auth-server-storage.md +++ b/docs/arch/11-auth-server-storage.md @@ -173,7 +173,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/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/docs/cli/thv_client_register.md b/docs/cli/thv_client_register.md index bd7a02c046..41c42e0a11 100644 --- a/docs/cli/thv_client_register.md +++ b/docs/cli/thv_client_register.md @@ -29,8 +29,10 @@ 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 - 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..f1d6044c01 100644 --- a/docs/cli/thv_client_remove.md +++ b/docs/cli/thv_client_remove.md @@ -29,8 +29,10 @@ 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 - kiro: Kiro AI IDE - lm-studio: LM Studio application - mistral-vibe: Mistral Vibe IDE 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/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 443473ca5e..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) @@ -177,6 +178,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/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/docs/observability.md b/docs/observability.md index 9acc2c460e..4f48062e4b 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -127,13 +127,13 @@ 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 +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 @@ -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 -deprecated and will be removed in v1beta1. They are mutually exclusive with -`telemetryConfigRef` (CEL enforced). +**Inline (deprecated)**: The inline `spec.telemetry` (MCPServer, MCPRemoteProxy) +and `spec.config.telemetry` (VirtualMCPServer) fields still work but are +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`. For VirtualMCPServer telemetry, see the [vMCP observability docs](./operator/virtualmcpserver-observability.md). 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 265a69eb2d..2fc318e59d 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 @@ -36,6 +37,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 /> | @@ -216,7 +218,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,12 +261,12 @@ _Validation:_ - Type: object _Appears in:_ -- [api.v1alpha1.VirtualMCPServerSpec](#apiv1alpha1virtualmcpserverspec) +- [api.v1beta1.VirtualMCPServerSpec](#apiv1beta1virtualmcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `name` _string_ | Name is the virtual MCP server name. | | Optional: \{\} <br /> | -| `groupRef` _string_ | Group references an existing MCPGroup that defines backend workloads.<br />In Kubernetes, the referenced MCPGroup must exist in the same namespace. | | Required: \{\} <br /> | +| `groupRef` _string_ | Group references an existing MCPGroup that defines backend workloads.<br />In standalone CLI mode, this is set from the YAML config file.<br />In Kubernetes, the operator populates this from spec.groupRef during conversion. | | Optional: \{\} <br /> | | `backends` _[vmcp.config.StaticBackendConfig](#vmcpconfigstaticbackendconfig) array_ | Backends defines pre-configured backend servers for static mode.<br />When OutgoingAuth.Source is "inline", this field contains the full list of backend<br />servers with their URLs and transport types, eliminating the need for K8s API access.<br />When OutgoingAuth.Source is "discovered", this field is empty and backends are<br />discovered at runtime via Kubernetes API. | | Optional: \{\} <br /> | | `incomingAuth` _[vmcp.config.IncomingAuthConfig](#vmcpconfigincomingauthconfig)_ | IncomingAuth configures how clients authenticate to the virtual MCP server.<br />When using the Kubernetes operator, this is populated by the converter from<br />VirtualMCPServerSpec.IncomingAuth and any values set here will be superseded. | | Optional: \{\} <br /> | | `outgoingAuth` _[vmcp.config.OutgoingAuthConfig](#vmcpconfigoutgoingauthconfig)_ | OutgoingAuth configures how the virtual MCP server authenticates to backends.<br />When using the Kubernetes operator, this is populated by the converter from<br />VirtualMCPServerSpec.OutgoingAuth and any values set here will be superseded. | | Optional: \{\} <br /> | @@ -273,7 +275,7 @@ _Appears in:_ | `compositeToolRefs` _[vmcp.config.CompositeToolRef](#vmcpconfigcompositetoolref) array_ | CompositeToolRefs references VirtualMCPCompositeToolDefinition resources<br />for complex, reusable workflows. Only applicable when running in Kubernetes.<br />Referenced resources must be in the same namespace as the VirtualMCPServer. | | Optional: \{\} <br /> | | `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<br />including distributed tracing, OTLP metrics export, and Prometheus metrics endpoint. | | Optional: \{\} <br /> | +| `telemetry` _[pkg.telemetry.Config](#pkgtelemetryconfig)_ | Telemetry configures OpenTelemetry-based observability for the Virtual MCP server<br />including distributed tracing, OTLP metrics export, and Prometheus metrics endpoint.<br />Deprecated (Kubernetes operator only): When deploying via the operator, use<br />VirtualMCPServer.spec.telemetryConfigRef to reference a shared MCPTelemetryConfig<br />resource instead. This field remains valid for standalone (non-operator) deployments. | | Optional: \{\} <br /> | | `audit` _[pkg.audit.Config](#pkgauditconfig)_ | Audit configures audit logging for the Virtual MCP server.<br />When present, audit logs include MCP protocol operations.<br />See audit.Config for available configuration options. | | Optional: \{\} <br /> | | `optimizer` _[vmcp.config.OptimizerConfig](#vmcpconfigoptimizerconfig)_ | Optimizer configures the MCP optimizer for context optimization on large toolsets.<br />When enabled, vMCP exposes only find_tool and call_tool operations to clients<br />instead of all backend tools directly. This reduces token usage by allowing<br />LLMs to discover relevant tools on demand rather than receiving all tool definitions. | | Optional: \{\} <br /> | | `sessionStorage` _[vmcp.config.SessionStorageConfig](#vmcpconfigsessionstorageconfig)_ | SessionStorage configures session storage for stateful horizontal scaling.<br />When provider is "redis", the operator injects Redis connection parameters<br />(address, db, keyPrefix) here. The Redis password is provided separately via<br />the THV_SESSION_REDIS_PASSWORD environment variable. | | Optional: \{\} <br /> | @@ -471,7 +473,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 | | --- | --- | --- | --- | @@ -645,7 +647,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 | @@ -764,7 +766,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 @@ -775,20 +855,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 @@ -797,15 +877,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 @@ -816,8 +896,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 | | --- | --- | --- | --- | @@ -825,7 +905,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 @@ -834,15 +914,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_ @@ -851,7 +931,7 @@ AuthServerStorageType represents the type of storage backend for the embedded au _Appears in:_ -- [api.v1alpha1.AuthServerStorageConfig](#apiv1alpha1authserverstorageconfig) +- [api.v1beta1.AuthServerStorageConfig](#apiv1beta1authserverstorageconfig) | Field | Description | | --- | --- | @@ -859,7 +939,7 @@ _Appears in:_ | `redis` | AuthServerStorageTypeRedis is the Redis storage backend<br /> | -#### api.v1alpha1.AuthzConfigRef +#### api.v1beta1.AuthzConfigRef @@ -868,18 +948,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 @@ -888,15 +968,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 external_auth_config_ref] <br />Required: \{\} <br /> | -| `externalAuthConfigRef` _[api.v1alpha1.ExternalAuthConfigRef](#apiv1alpha1externalauthconfigref)_ | ExternalAuthConfigRef references an MCPExternalAuthConfig resource<br />Only used when Type is "externalAuthConfigRef" (or deprecated "external_auth_config_ref") | | Optional: \{\} <br /> | +| `type` _string_ | Type defines the authentication type | | Enum: [discovered externalAuthConfigRef] <br />Required: \{\} <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 @@ -907,14 +987,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 @@ -923,18 +1003,16 @@ 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) +- [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 @@ -943,7 +1021,7 @@ ConfigMapAuthzRef references a ConfigMap containing authorization configuration _Appears in:_ -- [api.v1alpha1.AuthzConfigRef](#apiv1alpha1authzconfigref) +- [api.v1beta1.AuthzConfigRef](#apiv1beta1authzconfigref) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -951,27 +1029,9 @@ _Appears in:_ | `key` _string_ | Key is the key in the ConfigMap that contains the authorization configuration | authz.json | Optional: \{\} <br /> | -#### 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: \{\} <br /> | -| `key` _string_ | Key is the key in the ConfigMap that contains the OIDC configuration | oidc.json | 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.<br />If the ConfigMap data contains an explicit thvCABundlePath key, it takes precedence. | | Optional: \{\} <br /> | - - - - -#### api.v1alpha1.EmbeddedAuthServerConfig +#### api.v1beta1.EmbeddedAuthServerConfig @@ -981,21 +1041,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 @@ -1004,16 +1064,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 @@ -1022,20 +1082,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 @@ -1047,15 +1107,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_ @@ -1065,7 +1125,7 @@ _Validation:_ - Enum: [Pending Downloading Ready Failed Terminating] _Appears in:_ -- [api.v1alpha1.EmbeddingServerStatus](#apiv1alpha1embeddingserverstatus) +- [api.v1beta1.EmbeddingServerStatus](#apiv1beta1embeddingserverstatus) | Field | Description | | --- | --- | @@ -1076,7 +1136,7 @@ _Appears in:_ | `Terminating` | EmbeddingServerPhaseTerminating means the EmbeddingServer is being deleted<br /> | -#### api.v1alpha1.EmbeddingServerRef +#### api.v1beta1.EmbeddingServerRef @@ -1086,14 +1146,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 @@ -1102,25 +1162,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 @@ -1129,19 +1189,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 @@ -1150,16 +1210,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 @@ -1168,9 +1228,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 | | --- | --- | --- | --- | @@ -1178,7 +1238,7 @@ _Appears in:_ | `value` _string_ | Value of the environment variable | | Required: \{\} <br /> | -#### api.v1alpha1.ExternalAuthConfigRef +#### api.v1beta1.ExternalAuthConfigRef @@ -1188,17 +1248,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_ @@ -1207,7 +1267,7 @@ ExternalAuthType represents the type of external authentication _Appears in:_ -- [api.v1alpha1.MCPExternalAuthConfigSpec](#apiv1alpha1mcpexternalauthconfigspec) +- [api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec) | Field | Description | | --- | --- | @@ -1220,7 +1280,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 @@ -1229,16 +1289,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 @@ -1247,15 +1307,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 @@ -1266,15 +1326,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 @@ -1283,17 +1343,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 /> | -| `oidcConfig` _[api.v1alpha1.OIDCConfigRef](#apiv1alpha1oidcconfigref)_ | OIDCConfig defines OIDC authentication configuration.<br />Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead.<br />This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. | | 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 VirtualMCPServer.<br />Per-server overrides (audience, scopes) are specified here; shared provider config<br />lives in the MCPOIDCConfig resource. Mutually exclusive with oidcConfig. | | 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 @@ -1302,7 +1361,7 @@ InlineAuthzConfig contains direct authorization configuration _Appears in:_ -- [api.v1alpha1.AuthzConfigRef](#apiv1alpha1authzconfigref) +- [api.v1beta1.AuthzConfigRef](#apiv1beta1authzconfigref) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -1311,34 +1370,7 @@ _Appears in:_ | `primaryUpstreamProvider` _string_ | PrimaryUpstreamProvider names the upstream IDP provider whose access<br />token should be used as the source of JWT claims for Cedar evaluation.<br />When empty, claims from the ToolHive-issued token are used.<br />Must match an upstream provider name configured in the embedded auth server<br />(e.g. "default", "github"). Only relevant when the embedded auth server is active. | | Optional: \{\} <br /> | -#### 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: \{\} <br /> | -| `audience` _string_ | Audience is the expected audience for the token | | Optional: \{\} <br /> | -| `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 the CA bundle path. | | Optional: \{\} <br /> | -| `jwksAuthTokenPath` _string_ | JWKSAuthTokenPath is the path to file containing bearer token for JWKS/OIDC requests<br />The file must be mounted into the pod (e.g., via Secret volume) | | Optional: \{\} <br /> | -| `jwksAllowPrivateIP` _boolean_ | JWKSAllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses<br />Use with caution - only enable for trusted internal IDPs | false | Optional: \{\} <br /> | -| `protectedResourceAllowPrivateIP` _boolean_ | ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses<br />Use with caution - only enable for trusted internal IDPs or testing | 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<br />Only enable for local development, testing, or trusted internal networks | false | Optional: \{\} <br /> | -| `scopes` _string array_ | Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)<br />If empty, defaults to ["openid"] | | Optional: \{\} <br /> | - - -#### api.v1alpha1.InlineOIDCSharedConfig +#### api.v1beta1.InlineOIDCSharedConfig @@ -1349,7 +1381,7 @@ via MCPOIDCConfigReference. _Appears in:_ -- [api.v1alpha1.MCPOIDCConfigSpec](#apiv1alpha1mcpoidcconfigspec) +- [api.v1beta1.MCPOIDCConfigSpec](#apiv1beta1mcpoidcconfigspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -1358,37 +1390,15 @@ _Appears in:_ | `introspectionUrl` _string_ | IntrospectionURL is the URL for token introspection endpoint | | Optional: \{\} <br /> | | `clientId` _string_ | ClientID is the OIDC client ID | | Optional: \{\} <br /> | | `allowedClientIds` _string array_ | AllowedClientIDs is the set of OAuth client IDs accepted when validating<br />tokens by their client_id claim instead of an audience claim. | | 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 | false | Optional: \{\} <br /> | -| `protectedResourceAllowPrivateIP` _boolean_ | ProtectedResourceAllowPrivateIP allows protected resource endpoint on private IP addresses | false | 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.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<br />If empty, uses the pod's service account | | Optional: \{\} <br /> | -| `namespace` _string_ | Namespace is the namespace of the service account<br />If empty, uses the MCPServer's namespace | | Optional: \{\} <br /> | -| `audience` _string_ | Audience is the expected audience for the token | toolhive | Optional: \{\} <br /> | -| `issuer` _string_ | Issuer is the OIDC issuer URL | https://kubernetes.default.svc | Optional: \{\} <br /> | -| `jwksUrl` _string_ | JWKSURL is the URL to fetch the JWKS from<br />If empty, OIDC discovery will be used to automatically determine the JWKS URL | | Optional: \{\} <br /> | -| `introspectionUrl` _string_ | IntrospectionURL is the URL for token introspection endpoint<br />If empty, OIDC discovery will be used to automatically determine the introspection URL | | Optional: \{\} <br /> | -| `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.KubernetesServiceAccountOIDCConfig +#### api.v1beta1.KubernetesServiceAccountOIDCConfig @@ -1398,7 +1408,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 | | --- | --- | --- | --- | @@ -1410,7 +1420,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 @@ -1422,20 +1432,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 @@ -1447,15 +1457,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 @@ -1466,20 +1476,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 @@ -1488,17 +1498,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 @@ -1507,20 +1517,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 @@ -1532,15 +1542,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_ @@ -1550,7 +1560,7 @@ _Validation:_ - Enum: [Ready Pending Failed] _Appears in:_ -- [api.v1alpha1.MCPGroupStatus](#apiv1alpha1mcpgroupstatus) +- [api.v1beta1.MCPGroupStatus](#apiv1beta1mcpgroupstatus) | Field | Description | | --- | --- | @@ -1559,7 +1569,27 @@ _Appears in:_ | `Failed` | MCPGroupPhaseFailed indicates the MCPGroup has failed<br /> | -#### api.v1alpha1.MCPGroupSpec +#### api.v1beta1.MCPGroupRef + + + +MCPGroupRef defines a reference to an MCPGroup resource. +The referenced MCPGroup must be in the same namespace. + + + +_Appears in:_ +- [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.v1beta1.MCPGroupSpec @@ -1568,14 +1598,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 @@ -1584,12 +1614,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 /> | @@ -1599,7 +1629,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 @@ -1611,20 +1641,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 @@ -1636,15 +1666,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 @@ -1654,19 +1684,19 @@ 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 | | --- | --- | --- | --- | | `name` _string_ | Name is the name of the MCPOIDCConfig resource | | MinLength: 1 <br />Required: \{\} <br /> | | `audience` _string_ | Audience is the expected audience for token validation.<br />This should be unique per server to prevent token replay attacks. It may<br />be omitted only when the referenced provider validates tokens by<br />allowedClientIds because the issuer does not emit an aud claim (for<br />example Cognito client_credentials access tokens). | | Optional: \{\} <br /> | -| `resourceUrl` _string_ | ResourceURL is the explicit resource URL for OAuth discovery endpoint (RFC 9728).<br />If not specified, defaults to the in-cluster Kubernetes service URL. | | Optional: \{\} <br /> | | `scopes` _string array_ | Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728).<br />If empty, defaults to ["openid"]. | | Optional: \{\} <br /> | +| `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_ @@ -1675,7 +1705,7 @@ MCPOIDCConfigSourceType represents the type of OIDC configuration source for MCP _Appears in:_ -- [api.v1alpha1.MCPOIDCConfigSpec](#apiv1alpha1mcpoidcconfigspec) +- [api.v1beta1.MCPOIDCConfigSpec](#apiv1beta1mcpoidcconfigspec) | Field | Description | | --- | --- | @@ -1683,7 +1713,7 @@ _Appears in:_ | `inline` | MCPOIDCConfigTypeInline is the type for inline OIDC configuration<br /> | -#### api.v1alpha1.MCPOIDCConfigSpec +#### api.v1beta1.MCPOIDCConfigSpec @@ -1694,16 +1724,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 @@ -1712,17 +1742,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 @@ -1731,20 +1761,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 @@ -1756,15 +1786,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_ @@ -1774,7 +1804,7 @@ _Validation:_ - Enum: [Pending Ready Failed Terminating] _Appears in:_ -- [api.v1alpha1.MCPRegistryStatus](#apiv1alpha1mcpregistrystatus) +- [api.v1beta1.MCPRegistryStatus](#apiv1beta1mcpregistrystatus) | Field | Description | | --- | --- | @@ -1784,7 +1814,7 @@ _Appears in:_ | `Terminating` | MCPRegistryPhaseTerminating means the MCPRegistry is being deleted<br /> | -#### api.v1alpha1.MCPRegistrySpec +#### api.v1beta1.MCPRegistrySpec @@ -1793,7 +1823,7 @@ MCPRegistrySpec defines the desired state of MCPRegistry _Appears in:_ -- [api.v1alpha1.MCPRegistry](#apiv1alpha1mcpregistry) +- [api.v1beta1.MCPRegistry](#apiv1beta1mcpregistry) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -1805,7 +1835,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 @@ -1814,19 +1844,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 @@ -1836,20 +1866,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 @@ -1861,15 +1891,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_ @@ -1879,7 +1909,7 @@ _Validation:_ - Enum: [Pending Ready Failed Terminating] _Appears in:_ -- [api.v1alpha1.MCPRemoteProxyStatus](#apiv1alpha1mcpremoteproxystatus) +- [api.v1beta1.MCPRemoteProxyStatus](#apiv1beta1mcpremoteproxystatus) | Field | Description | | --- | --- | @@ -1889,7 +1919,7 @@ _Appears in:_ | `Terminating` | MCPRemoteProxyPhaseTerminating means the proxy is being deleted<br /> | -#### api.v1alpha1.MCPRemoteProxySpec +#### api.v1beta1.MCPRemoteProxySpec @@ -1898,35 +1928,33 @@ 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 /> | -| `oidcConfig` _[api.v1alpha1.OIDCConfigRef](#apiv1alpha1oidcconfigref)_ | OIDCConfig defines OIDC authentication configuration for the proxy.<br />Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead.<br />This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. | | 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 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.<br />Mutually exclusive with the deprecated inline Telemetry field. | | Optional: \{\} <br /> | -| `telemetry` _[api.v1alpha1.TelemetryConfig](#apiv1alpha1telemetryconfig)_ | Telemetry defines inline observability configuration for the proxy.<br />Deprecated: Use TelemetryConfigRef to reference a shared MCPTelemetryConfig resource instead.<br />This field will be removed in a future release. Setting both telemetry and telemetryConfigRef<br />is rejected by CEL validation. | | 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 /> | | `replicas` _integer_ | Replicas is the number of proxy deployment replicas.<br />When nil, defaults to 1. Set to nil when using an external HPA. | | Minimum: 1 <br />Optional: \{\} <br /> | | `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec allows customizing the pod template for the proxy deployment.<br />Scheduling fields (nodeSelector, tolerations, affinity) are applied to the proxy pods. | | Optional: \{\} <br /> | -| `resourceOverrides` _[api.v1alpha1.ResourceOverrides](#apiv1alpha1resourceoverrides)_ | ResourceOverrides allows overriding annotations and labels for resources created by the operator | | Optional: \{\} <br /> | -| `groupRef` _string_ | GroupRef is the name of the MCPGroup this proxy belongs to<br />Must reference an existing MCPGroup 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 @@ -1935,11 +1963,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 /> | @@ -1952,7 +1980,7 @@ _Appears in:_ | `message` _string_ | Message provides additional information about the current phase | | Optional: \{\} <br /> | -#### api.v1alpha1.MCPServer +#### api.v1beta1.MCPServer @@ -1961,20 +1989,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 @@ -1985,20 +2013,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 @@ -2010,15 +2038,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_ @@ -2028,7 +2056,7 @@ _Validation:_ - Enum: [Valid Pending Failed] _Appears in:_ -- [api.v1alpha1.MCPServerEntryStatus](#apiv1alpha1mcpserverentrystatus) +- [api.v1beta1.MCPServerEntryStatus](#apiv1beta1mcpserverentrystatus) | Field | Description | | --- | --- | @@ -2037,7 +2065,7 @@ _Appears in:_ | `Failed` | MCPServerEntryPhaseFailed indicates one or more referenced resources are missing or invalid.<br /> | -#### api.v1alpha1.MCPServerEntrySpec +#### api.v1beta1.MCPServerEntrySpec @@ -2048,19 +2076,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` _string_ | GroupRef is the name of the MCPGroup this entry belongs to.<br />Required — every MCPServerEntry must be part of a group for vMCP discovery. | | MinLength: 1 <br />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 @@ -2069,16 +2097,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 @@ -2090,15 +2118,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_ @@ -2108,7 +2136,7 @@ _Validation:_ - Enum: [Pending Ready Failed Terminating Stopped] _Appears in:_ -- [api.v1alpha1.MCPServerStatus](#apiv1alpha1mcpserverstatus) +- [api.v1beta1.MCPServerStatus](#apiv1beta1mcpserverstatus) | Field | Description | | --- | --- | @@ -2119,7 +2147,7 @@ _Appears in:_ | `Stopped` | MCPServerPhaseStopped means the MCPServer is scaled to zero<br /> | -#### api.v1alpha1.MCPServerSpec +#### api.v1beta1.MCPServerSpec @@ -2128,7 +2156,7 @@ MCPServerSpec defines the desired state of MCPServer _Appears in:_ -- [api.v1alpha1.MCPServer](#apiv1alpha1mcpserver) +- [api.v1beta1.MCPServer](#apiv1beta1mcpserver) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2138,34 +2166,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.<br />The same Kubernetes Secret may be referenced multiple times as long as each<br />entry points at a different key within that Secret. | | 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.<br />The same Kubernetes Secret may be referenced multiple times as long as each<br />entry points at a different key within that Secret. | | 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 /> | -| `oidcConfig` _[api.v1alpha1.OIDCConfigRef](#apiv1alpha1oidcconfigref)_ | OIDCConfig defines OIDC authentication configuration for the MCP server.<br />Deprecated: Use OIDCConfigRef to reference a shared MCPOIDCConfig resource instead.<br />This field will be removed in v1beta1. OIDCConfig and OIDCConfigRef are mutually exclusive. | | 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. Mutually exclusive with oidcConfig. | | 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.<br />Mutually exclusive with the deprecated inline Telemetry field. | | Optional: \{\} <br /> | -| `telemetry` _[api.v1alpha1.TelemetryConfig](#apiv1alpha1telemetryconfig)_ | Telemetry defines inline observability configuration for the MCP server.<br />Deprecated: Use TelemetryConfigRef to reference a shared MCPTelemetryConfig resource instead.<br />This field will be removed in a future release. Setting both telemetry and telemetryConfigRef<br />is rejected by CEL validation. | | 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` _string_ | GroupRef is the name of the MCPGroup this server belongs to<br />Must reference an existing MCPGroup 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.<br />Accepts \{name: <group>\} or, for objects created before v0.20, a plain<br />group name string. Both forms decode to the same MCPGroupRef. The schema<br />is untyped to admit both forms, so CEL cannot validate it; an invalid<br />value decodes to an empty name and is reported by the GroupRefValidated<br />condition. | | Schemaless: \{\} <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 /> | | `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 @@ -2174,7 +2200,7 @@ MCPServerStatus defines the observed state of MCPServer _Appears in:_ -- [api.v1alpha1.MCPServer](#apiv1alpha1mcpserver) +- [api.v1beta1.MCPServer](#apiv1beta1mcpserver) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2186,12 +2212,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 @@ -2203,20 +2229,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 @@ -2228,15 +2254,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 @@ -2247,8 +2273,9 @@ same namespace as the MCPServer. _Appears in:_ -- [api.v1alpha1.MCPRemoteProxySpec](#apiv1alpha1mcpremoteproxyspec) -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) +- [api.v1beta1.VirtualMCPServerSpec](#apiv1beta1virtualmcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2256,7 +2283,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 @@ -2267,15 +2294,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 @@ -2284,17 +2311,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 @@ -2308,7 +2335,7 @@ Unlike OpenTelemetryConfig (used by inline MCPServer telemetry), this type: _Appears in:_ -- [api.v1alpha1.MCPTelemetryConfigSpec](#apiv1alpha1mcptelemetryconfigspec) +- [api.v1beta1.MCPTelemetryConfigSpec](#apiv1beta1mcptelemetryconfigspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2316,15 +2343,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 @@ -2336,20 +2363,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 @@ -2361,15 +2388,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 @@ -2380,15 +2407,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 @@ -2397,17 +2424,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 @@ -2416,7 +2443,7 @@ ModelCacheConfig configures persistent storage for model caching _Appears in:_ -- [api.v1alpha1.EmbeddingServerSpec](#apiv1alpha1embeddingserverspec) +- [api.v1beta1.EmbeddingServerSpec](#apiv1beta1embeddingserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2426,7 +2453,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 @@ -2435,15 +2462,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 @@ -2453,43 +2480,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 /> | - - -#### api.v1alpha1.OIDCConfigRef +| `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 /> | - -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 | -| --- | --- | --- | --- | -| `type` _string_ | Type is the type of OIDC configuration | kubernetes | Enum: [kubernetes configMap inline] <br /> | -| `resourceUrl` _string_ | ResourceURL is the explicit resource URL for OAuth discovery endpoint (RFC 9728)<br />If not specified, defaults to the in-cluster Kubernetes service URL | | Optional: \{\} <br /> | -| `kubernetes` _[api.v1alpha1.KubernetesOIDCConfig](#apiv1alpha1kubernetesoidcconfig)_ | Kubernetes configures OIDC for Kubernetes service account token validation<br />Only used when Type is "kubernetes" | | Optional: \{\} <br /> | -| `configMap` _[api.v1alpha1.ConfigMapOIDCRef](#apiv1alpha1configmapoidcref)_ | ConfigMap references a ConfigMap containing OIDC configuration<br />Only used when Type is "configmap" | | Optional: \{\} <br /> | -| `inline` _[api.v1alpha1.InlineOIDCConfig](#apiv1alpha1inlineoidcconfig)_ | Inline contains direct OIDC configuration<br />Only used when Type is "inline" | | Optional: \{\} <br /> | - - -#### api.v1alpha1.OIDCUpstreamConfig +#### api.v1beta1.OIDCUpstreamConfig @@ -2499,42 +2505,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"]. | | 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 /> | - - -#### 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: \{\} <br /> | -| `endpoint` _string_ | Endpoint is the OTLP endpoint URL for tracing and metrics | | Optional: \{\} <br /> | -| `serviceName` _string_ | ServiceName is the service name for telemetry<br />If not specified, defaults to the MCPServer name | | Optional: \{\} <br /> | -| `headers` _string array_ | Headers contains authentication headers for the OTLP endpoint<br />Specified as key=value pairs | | Optional: \{\} <br /> | -| `insecure` _boolean_ | Insecure indicates whether to use HTTP instead of HTTPS for the OTLP endpoint | false | 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 /> | -| `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 /> | +| `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.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 @@ -2543,15 +2527,14 @@ OpenTelemetryMetricsConfig defines OpenTelemetry metrics configuration _Appears in:_ -- [api.v1alpha1.MCPTelemetryOTelConfig](#apiv1alpha1mcptelemetryotelconfig) -- [api.v1alpha1.OpenTelemetryConfig](#apiv1alpha1opentelemetryconfig) +- [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 @@ -2560,8 +2543,7 @@ OpenTelemetryTracingConfig defines OpenTelemetry tracing configuration _Appears in:_ -- [api.v1alpha1.MCPTelemetryOTelConfig](#apiv1alpha1mcptelemetryotelconfig) -- [api.v1alpha1.OpenTelemetryConfig](#apiv1alpha1opentelemetryconfig) +- [api.v1beta1.MCPTelemetryOTelConfig](#apiv1beta1mcptelemetryotelconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2569,7 +2551,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 @@ -2578,7 +2560,7 @@ OutboundNetworkPermissions defines the outbound network permissions _Appears in:_ -- [api.v1alpha1.NetworkPermissions](#apiv1alpha1networkpermissions) +- [api.v1beta1.NetworkPermissions](#apiv1beta1networkpermissions) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2587,7 +2569,7 @@ _Appears in:_ | `allowPort` _integer array_ | AllowPort is a list of ports to allow connections to | | Optional: \{\} <br /> | -#### api.v1alpha1.OutgoingAuthConfig +#### api.v1beta1.OutgoingAuthConfig @@ -2596,16 +2578,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 @@ -2614,7 +2596,7 @@ PermissionProfileRef defines a reference to a permission profile _Appears in:_ -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2625,7 +2607,7 @@ _Appears in:_ -#### api.v1alpha1.PrometheusConfig +#### api.v1beta1.PrometheusConfig @@ -2634,15 +2616,14 @@ PrometheusConfig defines Prometheus-specific configuration _Appears in:_ -- [api.v1alpha1.MCPTelemetryConfigSpec](#apiv1alpha1mcptelemetryconfigspec) -- [api.v1alpha1.TelemetryConfig](#apiv1alpha1telemetryconfig) +- [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 @@ -2651,18 +2632,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 @@ -2672,8 +2653,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 | | --- | --- | --- | --- | @@ -2681,7 +2662,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 @@ -2691,16 +2672,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 @@ -2709,15 +2690,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 @@ -2726,17 +2707,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 @@ -2746,21 +2727,21 @@ Use Address for a direct backend such as DragonflyDB, or SentinelConfig for HA R _Appears in:_ -- [api.v1alpha1.AuthServerStorageConfig](#apiv1alpha1authserverstorageconfig) +- [api.v1beta1.AuthServerStorageConfig](#apiv1beta1authserverstorageconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | | `address` _string_ | Address is the Redis-compatible server address for direct mode (host:port).<br />Mutually exclusive with SentinelConfig. | | Optional: \{\} <br /> | -| `sentinelConfig` _[api.v1alpha1.RedisSentinelConfig](#apiv1alpha1redissentinelconfig)_ | SentinelConfig holds Redis Sentinel configuration for HA mode.<br />Mutually exclusive with Address. | | Optional: \{\} <br /> | -| `aclUserConfig` _[api.v1alpha1.RedisACLUserConfig](#apiv1alpha1redisacluserconfig)_ | ACLUserConfig configures Redis ACL user authentication. | | Required: \{\} <br /> | +| `sentinelConfig` _[api.v1beta1.RedisSentinelConfig](#apiv1beta1redissentinelconfig)_ | SentinelConfig holds Redis Sentinel configuration for HA mode.<br />Mutually exclusive with Address. | | Optional: \{\} <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 @@ -2770,15 +2751,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 @@ -2787,7 +2768,7 @@ ResourceList is a set of (resource name, quantity) pairs _Appears in:_ -- [api.v1alpha1.ResourceRequirements](#apiv1alpha1resourcerequirements) +- [api.v1beta1.ResourceRequirements](#apiv1beta1resourcerequirements) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2795,7 +2776,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 @@ -2804,10 +2785,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 | | --- | --- | --- | --- | @@ -2815,7 +2796,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 @@ -2824,16 +2805,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 @@ -2842,17 +2823,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 @@ -2864,7 +2845,7 @@ Exactly one of Claim or Matcher must be specified. _Appears in:_ -- [api.v1alpha1.AWSStsConfig](#apiv1alpha1awsstsconfig) +- [api.v1beta1.AWSStsConfig](#apiv1beta1awsstsconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2874,7 +2855,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 @@ -2883,20 +2864,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.InlineOIDCConfig](#apiv1alpha1inlineoidcconfig) -- [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 | | --- | --- | --- | --- | @@ -2904,7 +2884,7 @@ _Appears in:_ | `key` _string_ | Key is the key within the secret | | Required: \{\} <br /> | -#### api.v1alpha1.SecretRef +#### api.v1beta1.SecretRef @@ -2913,7 +2893,7 @@ SecretRef is a reference to a secret _Appears in:_ -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -2922,7 +2902,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 @@ -2933,28 +2913,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 @@ -2969,8 +2949,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 | | --- | --- | --- | --- | @@ -2978,29 +2958,11 @@ _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 /> | -| `usernameRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | UsernameRef is a reference to a Secret key containing the Redis ACL username | | Optional: \{\} <br /> | - - -#### 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: \{\} <br /> | -| `prometheus` _[api.v1alpha1.PrometheusConfig](#apiv1alpha1prometheusconfig)_ | Prometheus defines Prometheus-specific configuration | | Optional: \{\} <br /> | +| `passwordRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | PasswordRef is a reference to a Secret key containing the Redis password | | Optional: \{\} <br /> | +| `usernameRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | UsernameRef is a reference to a Secret key containing the Redis ACL username | | Optional: \{\} <br /> | -#### api.v1alpha1.TokenExchangeConfig +#### api.v1beta1.TokenExchangeConfig @@ -3012,13 +2974,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 /> | @@ -3026,7 +2988,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 @@ -3035,7 +2997,7 @@ TokenLifespanConfig holds configuration for token lifetimes. _Appears in:_ -- [api.v1alpha1.EmbeddedAuthServerConfig](#apiv1alpha1embeddedauthserverconfig) +- [api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -3044,7 +3006,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 @@ -3055,7 +3017,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 | | --- | --- | --- | --- | @@ -3065,7 +3027,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 @@ -3076,7 +3038,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 | | --- | --- | --- | --- | @@ -3087,7 +3049,7 @@ _Appears in:_ | `openWorldHint` _boolean_ | OpenWorldHint overrides the open-world hint annotation. | | Optional: \{\} <br /> | -#### api.v1alpha1.ToolConfigRef +#### api.v1beta1.ToolConfigRef @@ -3097,15 +3059,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 @@ -3116,16 +3078,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 @@ -3135,16 +3097,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 @@ -3155,14 +3117,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 @@ -3171,17 +3133,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_ @@ -3190,7 +3152,7 @@ UpstreamProviderType identifies the type of upstream Identity Provider. _Appears in:_ -- [api.v1alpha1.UpstreamProviderConfig](#apiv1alpha1upstreamproviderconfig) +- [api.v1beta1.UpstreamProviderConfig](#apiv1beta1upstreamproviderconfig) | Field | Description | | --- | --- | @@ -3198,7 +3160,7 @@ _Appears in:_ | `oauth2` | UpstreamProviderTypeOAuth2 is for pure OAuth 2.0 providers with explicit endpoints<br /> | -#### api.v1alpha1.UserInfoConfig +#### api.v1beta1.UserInfoConfig @@ -3209,20 +3171,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. @@ -3239,11 +3201,11 @@ Example for GitHub: _Appears in:_ -- [api.v1alpha1.UserInfoConfig](#apiv1alpha1userinfoconfig) +- [api.v1beta1.UserInfoConfig](#apiv1beta1userinfoconfig) -#### api.v1alpha1.ValidationStatus +#### api.v1beta1.ValidationStatus _Underlying type:_ _string_ @@ -3253,7 +3215,7 @@ _Validation:_ - Enum: [Valid Invalid Unknown] _Appears in:_ -- [api.v1alpha1.VirtualMCPCompositeToolDefinitionStatus](#apiv1alpha1virtualmcpcompositetooldefinitionstatus) +- [api.v1beta1.VirtualMCPCompositeToolDefinitionStatus](#apiv1beta1virtualmcpcompositetooldefinitionstatus) | Field | Description | | --- | --- | @@ -3262,7 +3224,7 @@ _Appears in:_ | `Unknown` | ValidationStatusUnknown indicates validation hasn't been performed yet<br /> | -#### api.v1alpha1.VirtualMCPCompositeToolDefinition +#### api.v1beta1.VirtualMCPCompositeToolDefinition @@ -3273,20 +3235,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 @@ -3298,15 +3260,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 @@ -3317,7 +3279,7 @@ between CLI and operator usage. _Appears in:_ -- [api.v1alpha1.VirtualMCPCompositeToolDefinition](#apiv1alpha1virtualmcpcompositetooldefinition) +- [api.v1beta1.VirtualMCPCompositeToolDefinition](#apiv1beta1virtualmcpcompositetooldefinition) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -3329,7 +3291,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 @@ -3338,18 +3300,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 @@ -3359,20 +3321,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 @@ -3384,15 +3346,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_ @@ -3402,7 +3364,7 @@ _Validation:_ - Enum: [Pending Ready Degraded Failed] _Appears in:_ -- [api.v1alpha1.VirtualMCPServerStatus](#apiv1alpha1virtualmcpserverstatus) +- [api.v1beta1.VirtualMCPServerStatus](#apiv1beta1virtualmcpserverstatus) | Field | Description | | --- | --- | @@ -3412,7 +3374,7 @@ _Appears in:_ | `Failed` | VirtualMCPServerPhaseFailed indicates the VirtualMCPServer has failed<br /> | -#### api.v1alpha1.VirtualMCPServerSpec +#### api.v1beta1.VirtualMCPServerSpec @@ -3421,24 +3383,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 /> | -| `config` _[vmcp.config.Config](#vmcpconfigconfig)_ | Config is the Virtual MCP server configuration<br />The only field currently required within config is `config.groupRef`.<br />GroupRef references an existing MCPGroup that defines backend workloads.<br />The referenced MCPGroup must exist in the same namespace.<br />The telemetry and audit config from here are also supported, but not required. | | Type: object <br />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.OIDCConfig<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 /> | +| `groupRef` _[api.v1beta1.MCPGroupRef](#apiv1beta1mcpgroupref)_ | GroupRef references the MCPGroup that defines backend workloads.<br />The referenced MCPGroup must exist in the same namespace.<br />Optional in the schema so objects created before groupRef was introduced<br />still validate; the controller rejects a VirtualMCPServer without it. | | Optional: \{\} <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.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 @@ -3447,21 +3411,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 /> | -| `backendCount` _integer_ | BackendCount is the number of healthy/ready backends<br />(excludes unavailable, degraded, and unknown backends) | | 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 @@ -3470,7 +3435,7 @@ Volume represents a volume to mount in a container _Appears in:_ -- [api.v1alpha1.MCPServerSpec](#apiv1alpha1mcpserverspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -3480,7 +3445,7 @@ _Appears in:_ | `readOnly` _boolean_ | ReadOnly specifies whether the volume should be mounted read-only | false | Optional: \{\} <br /> | -#### api.v1alpha1.WorkloadReference +#### api.v1beta1.WorkloadReference @@ -3490,10 +3455,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 a48ecd8ca5..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,14 +727,14 @@ 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 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..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 @@ -24,17 +24,18 @@ 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) **Example**: ```yaml spec: - config: - groupRef: engineering-team + groupRef: + name: engineering-team ``` ### Backend Types @@ -55,14 +56,15 @@ 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 spec: remoteUrl: https://mcp.context7.com/mcp transport: streamable-http - groupRef: engineering-team + groupRef: + name: engineering-team # No externalAuthConfigRef — public endpoint, no auth needed ``` @@ -76,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`. @@ -113,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. @@ -163,7 +143,7 @@ spec: source: inline backends: github: - type: external_auth_config_ref + type: externalAuthConfigRef externalAuthConfigRef: name: github-token-exchange slack: @@ -181,8 +161,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) @@ -202,8 +182,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 +199,8 @@ spec: **Example (priority strategy)**: ```yaml spec: - config: - groupRef: my-services + groupRef: + name: my-services aggregation: conflictResolution: priority conflictResolutionConfig: @@ -230,8 +210,8 @@ spec: **Example (manual strategy)**: ```yaml spec: - config: - groupRef: my-services + groupRef: + name: my-services aggregation: conflictResolution: manual tools: @@ -374,8 +354,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" @@ -467,16 +448,17 @@ 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 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 +590,9 @@ status: The VirtualMCPServer CRD includes comprehensive validation: 1. **Required Fields**: - - `spec.config.groupRef` must be specified + - `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 (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..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,13 +178,14 @@ Add `groupRef` to each exported MCPServer: ```yaml # github.yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer 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 @@ -199,14 +200,15 @@ 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 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: @@ -360,21 +362,21 @@ 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 --- -# 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 +apiVersion: toolhive.stacklok.dev/v1beta1 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 <name>` @@ -436,27 +438,29 @@ 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 spec: remoteUrl: https://mcp.context7.com/mcp transport: streamable-http - groupRef: engineering-team + groupRef: + name: engineering-team # Requires OIDC config, deploys proxy pod ``` **After (MCPServerEntry — zero infrastructure):** ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServerEntry metadata: name: context7 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 <group-name> ``` -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: <group-name> + groupRef: + name: <group-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 <backend-name> -o yaml | grep groupRef +kubectl get mcpserver <backend-name> -o yaml | grep -A1 groupRef ``` **Solution**: Verify backend has correct `groupRef`: ```bash -kubectl patch mcpserver <backend-name> -p '{"spec":{"groupRef":"<group-name>"}}' +kubectl patch mcpserver <backend-name> --type merge -p '{"spec":{"groupRef":{"name":"<group-name>"}}}' ``` **2. Namespace Mismatch** diff --git a/docs/operator/virtualmcpserver-observability.md b/docs/operator/virtualmcpserver-observability.md index cb53245ec7..d322f34c70 100644 --- a/docs/operator/virtualmcpserver-observability.md +++ b/docs/operator/virtualmcpserver-observability.md @@ -154,18 +154,64 @@ 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 -apiVersion: toolhive.stacklok.dev/v1alpha1 +# Shared telemetry configuration +apiVersion: toolhive.stacklok.dev/v1beta1 +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/v1beta1 +kind: VirtualMCPServer +metadata: + name: my-vmcp +spec: + telemetryConfigRef: + name: shared-otel + serviceName: my-vmcp + groupRef: + name: 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 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/v1beta1 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/docs/redis-storage.md b/docs/redis-storage.md index e0db17d861..d656858f2b 100644 --- a/docs/redis-storage.md +++ b/docs/redis-storage.md @@ -59,7 +59,7 @@ spec: Use `sentinelConfig` for HA Redis deployments: ```yaml -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: name: my-auth-config @@ -228,7 +228,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: @@ -236,11 +236,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 @@ -259,7 +259,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/runtime-version-customization.md b/docs/runtime-version-customization.md index 28cd8a74da..5fabc6683a 100644 --- a/docs/runtime-version-customization.md +++ b/docs/runtime-version-customization.md @@ -6,9 +6,9 @@ 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) -- **Node**: `node:22-alpine` (builder and runtime) -- **Python**: `python:3.13-slim` (builder and runtime) +- **Go**: `golang:1.26-alpine` (builder), `alpine:3.23` (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. @@ -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 6d8235a1be..b56ad78082 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" @@ -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" @@ -316,6 +320,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" @@ -402,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" @@ -446,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" @@ -467,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" }, @@ -827,7 +849,9 @@ const docTemplate = `{ "gemini-cli", "vscode-server", "mistral-vibe", - "codex" + "codex", + "kimi-cli", + "factory" ], "type": "string", "x-enum-varnames": [ @@ -855,7 +879,9 @@ const docTemplate = `{ "GeminiCli", "VSCodeServer", "MistralVibe", - "Codex" + "Codex", + "KimiCli", + "Factory" ] }, "github_com_stacklok_toolhive_pkg_client.ClientAppStatus": { @@ -905,6 +931,7 @@ const docTemplate = `{ "removing", "unknown", "unauthenticated", + "policy_stopped", "running", "stopped", "error", @@ -914,6 +941,7 @@ const docTemplate = `{ "removing", "unknown", "unauthenticated", + "policy_stopped", "running", "stopped", "error", @@ -922,7 +950,8 @@ const docTemplate = `{ "unhealthy", "removing", "unknown", - "unauthenticated" + "unauthenticated", + "policy_stopped" ], "type": "string", "x-enum-varnames": [ @@ -934,7 +963,8 @@ const docTemplate = `{ "WorkloadStatusUnhealthy", "WorkloadStatusRemoving", "WorkloadStatusUnknown", - "WorkloadStatusUnauthenticated" + "WorkloadStatusUnauthenticated", + "WorkloadStatusPolicyStopped" ] }, "github_com_stacklok_toolhive_pkg_container_templates.RuntimeConfig": { @@ -949,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:24-alpine\", \"python:3.14-slim\"", "type": "string" } }, @@ -1240,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.", @@ -1283,6 +1313,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" @@ -1529,6 +1563,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": { @@ -1775,6 +1855,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": { @@ -2138,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" }, @@ -2149,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" @@ -2462,11 +2847,29 @@ const docTemplate = `{ }, "type": "object" }, - "pkg_api_v1.providerCapabilitiesResponse": { - "description": "Capabilities of the secrets provider", + "pkg_api_v1.paginationV01Metadata": { + "description": "Metadata contains pagination information", "properties": { - "can_cleanup": { - "description": "Whether the provider can cleanup all secrets", + "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": { + "can_cleanup": { + "description": "Whether the provider can cleanup all secrets", "type": "boolean" }, "can_delete": { @@ -2498,6 +2901,20 @@ 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" + } + }, + "type": "object" + }, "pkg_api_v1.registryInfo": { "description": "Basic information about a registry", "properties": { @@ -2625,6 +3042,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": { @@ -2667,6 +3101,23 @@ const docTemplate = `{ }, "type": "object" }, + "pkg_api_v1.skillsV01Response": { + "description": "Paginated list of skills from the registry", + "properties": { + "metadata": { + "$ref": "#/components/schemas/pkg_api_v1.paginationV01Metadata" + }, + "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": { @@ -3265,6 +3716,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": {}, @@ -3274,6 +3871,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" @@ -4756,6 +5428,16 @@ const docTemplate = `{ } }, "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Gateway" } }, "summary": "Install a skill", @@ -4908,6 +5590,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", @@ -5960,6 +6704,319 @@ const docTemplate = `{ "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 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 0c05fa748e..c3a71286bb 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" @@ -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" @@ -309,6 +313,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" @@ -395,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" @@ -439,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" @@ -460,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" }, @@ -820,7 +842,9 @@ "gemini-cli", "vscode-server", "mistral-vibe", - "codex" + "codex", + "kimi-cli", + "factory" ], "type": "string", "x-enum-varnames": [ @@ -848,7 +872,9 @@ "GeminiCli", "VSCodeServer", "MistralVibe", - "Codex" + "Codex", + "KimiCli", + "Factory" ] }, "github_com_stacklok_toolhive_pkg_client.ClientAppStatus": { @@ -898,6 +924,7 @@ "removing", "unknown", "unauthenticated", + "policy_stopped", "running", "stopped", "error", @@ -907,6 +934,7 @@ "removing", "unknown", "unauthenticated", + "policy_stopped", "running", "stopped", "error", @@ -915,7 +943,8 @@ "unhealthy", "removing", "unknown", - "unauthenticated" + "unauthenticated", + "policy_stopped" ], "type": "string", "x-enum-varnames": [ @@ -927,7 +956,8 @@ "WorkloadStatusUnhealthy", "WorkloadStatusRemoving", "WorkloadStatusUnknown", - "WorkloadStatusUnauthenticated" + "WorkloadStatusUnauthenticated", + "WorkloadStatusPolicyStopped" ] }, "github_com_stacklok_toolhive_pkg_container_templates.RuntimeConfig": { @@ -942,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:24-alpine\", \"python:3.14-slim\"", "type": "string" } }, @@ -1233,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.", @@ -1276,6 +1306,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" @@ -1522,6 +1556,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": { @@ -1768,6 +1848,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": { @@ -2131,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" }, @@ -2142,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" @@ -2455,11 +2840,29 @@ }, "type": "object" }, - "pkg_api_v1.providerCapabilitiesResponse": { - "description": "Capabilities of the secrets provider", + "pkg_api_v1.paginationV01Metadata": { + "description": "Metadata contains pagination information", "properties": { - "can_cleanup": { - "description": "Whether the provider can cleanup all secrets", + "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": { + "can_cleanup": { + "description": "Whether the provider can cleanup all secrets", "type": "boolean" }, "can_delete": { @@ -2491,6 +2894,20 @@ }, "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": { "description": "Basic information about a registry", "properties": { @@ -2618,6 +3035,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": { @@ -2660,6 +3094,23 @@ }, "type": "object" }, + "pkg_api_v1.skillsV01Response": { + "description": "Paginated list of skills from the registry", + "properties": { + "metadata": { + "$ref": "#/components/schemas/pkg_api_v1.paginationV01Metadata" + }, + "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": { @@ -3258,6 +3709,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": {}, @@ -3267,6 +3864,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" @@ -4749,6 +5421,16 @@ } }, "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "Bad Gateway" } }, "summary": "Install a skill", @@ -4901,6 +5583,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", @@ -5953,6 +6697,319 @@ "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 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 ec3fd55396..873abafbea 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: |- @@ -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. @@ -310,6 +320,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 @@ -388,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. @@ -430,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. @@ -458,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 @@ -800,6 +835,8 @@ components: - vscode-server - mistral-vibe - codex + - kimi-cli + - factory type: string x-enum-varnames: - RooCode @@ -827,6 +864,8 @@ components: - VSCodeServer - MistralVibe - Codex + - KimiCli + - Factory github_com_stacklok_toolhive_pkg_client.ClientAppStatus: properties: client_type: @@ -866,6 +905,7 @@ components: - removing - unknown - unauthenticated + - policy_stopped - running - stopped - error @@ -875,6 +915,7 @@ components: - removing - unknown - unauthenticated + - policy_stopped - running - stopped - error @@ -884,6 +925,7 @@ components: - removing - unknown - unauthenticated + - policy_stopped type: string x-enum-varnames: - WorkloadStatusRunning @@ -895,6 +937,7 @@ components: - WorkloadStatusRemoving - WorkloadStatusUnknown - WorkloadStatusUnauthenticated + - WorkloadStatusPolicyStopped github_com_stacklok_toolhive_pkg_container_templates.RuntimeConfig: description: |- RuntimeConfig allows overriding the default runtime configuration @@ -914,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:24-alpine", "python:3.14-slim" type: string type: object github_com_stacklok_toolhive_pkg_core.Workload: @@ -1170,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. @@ -1210,6 +1253,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) @@ -1416,6 +1466,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: @@ -1663,6 +1747,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: @@ -1937,6 +2243,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: @@ -1945,6 +2255,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 @@ -2184,6 +2501,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: @@ -2210,6 +2540,16 @@ components: description: OCI reference to push 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: description: Basic information about a registry properties: @@ -2312,6 +2652,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: @@ -2344,6 +2696,18 @@ components: type: array uniqueItems: false type: object + pkg_api_v1.skillsV01Response: + description: Paginated list of skills from the registry + properties: + metadata: + $ref: '#/components/schemas/pkg_api_v1.paginationV01Metadata' + 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: @@ -2868,12 +3232,194 @@ 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: {} 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. @@ -3791,6 +4337,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 @@ -3984,6 +4536,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 @@ -4529,3 +5121,200 @@ 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 + 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/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 b37ecbbbb9..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 @@ -28,9 +28,21 @@ spec: name: api-bearer-token key: token +--- +# Shared OIDC configuration for incoming client authentication +apiVersion: toolhive.stacklok.dev/v1beta1 +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 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPRemoteProxy metadata: name: api-proxy @@ -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/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 e421f7b9b8..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 @@ -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..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 @@ -23,11 +23,12 @@ 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) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServerEntry metadata: name: context7 @@ -35,11 +36,12 @@ metadata: spec: remoteUrl: https://mcp.context7.com/mcp transport: streamable-http - groupRef: engineering-team + groupRef: + name: engineering-team --- # VirtualMCPServer aggregates both backends -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: eng-tools @@ -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..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 @@ -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..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 @@ -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..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 @@ -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/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/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_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/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/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 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 12f2b07108..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,13 +32,14 @@ spec: --- # Backend MCP Server: Fetch (for HTTP requests) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer 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 @@ -53,13 +54,14 @@ 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 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: @@ -77,13 +79,14 @@ 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 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: @@ -101,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 @@ -282,14 +285,15 @@ spec: --- # VirtualMCPServer using the complex composite tool -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer 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..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,13 +25,14 @@ spec: --- # Backend MCP Server: Fetch (for HTTP requests) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/gofetch/server transport: streamable-http proxyPort: 8080 @@ -46,13 +47,14 @@ 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 namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: sse env: @@ -70,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 @@ -140,14 +142,15 @@ spec: --- # VirtualMCPServer using the simple composite tool -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer 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..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,13 +33,14 @@ 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 namespace: default spec: - groupRef: deployment-services + groupRef: + name: deployment-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: streamable-http env: @@ -57,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 @@ -197,14 +198,15 @@ spec: --- # VirtualMCPServer using the interactive composite tool -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer 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..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,13 +25,14 @@ spec: --- # Create backend MCPServers used by all examples -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer 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: @@ -48,13 +49,14 @@ spec: memory: "64Mi" --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/gofetch/server transport: streamable-http proxyPort: 8080 @@ -68,13 +70,14 @@ spec: memory: "64Mi" --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer 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: @@ -94,14 +97,15 @@ 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 namespace: default spec: + groupRef: + name: my-services config: - groupRef: my-services # Prefix strategy configuration aggregation: conflictResolution: prefix @@ -134,14 +138,15 @@ 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 namespace: default spec: + groupRef: + name: my-services config: - groupRef: my-services # Priority strategy configuration aggregation: conflictResolution: priority @@ -177,14 +182,15 @@ 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 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..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,13 +50,14 @@ spec: --- # Create backend MCPServer: yardstick with SSE transport -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer 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: @@ -74,13 +75,14 @@ spec: --- # Create backend MCPServer: fetch with streamable-http transport -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/gofetch/server transport: streamable-http proxyPort: 8080 @@ -95,13 +97,14 @@ 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 namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: streamable-http env: @@ -118,14 +121,15 @@ spec: memory: "64Mi" --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: inline-auth-vmcp namespace: default spec: + groupRef: + name: my-services config: - groupRef: my-services # Aggregation configuration aggregation: conflictResolution: prefix @@ -137,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_optimizer_all_options.yaml b/examples/operator/virtual-mcps/vmcp_optimizer_all_options.yaml index f55207168b..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,13 +40,14 @@ spec: --- # Step 2: Create MCPServer backend - yardstick -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer 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 @@ -60,13 +61,14 @@ 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 namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: ghcr.io/stackloklabs/gofetch/server transport: streamable-http proxyPort: 8080 @@ -85,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 @@ -138,15 +140,15 @@ 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 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..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,13 +83,14 @@ spec: --- # Step 2a: MCPServer backend - yardstick (unit conversion) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer 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 @@ -106,13 +107,14 @@ spec: --- # Step 2b: MCPServer backend - fetch (URL content fetching) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer metadata: name: fetch namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: ghcr.io/stackloklabs/gofetch/server transport: streamable-http proxyPort: 8080 @@ -130,13 +132,14 @@ 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 namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: ghcr.io/github/github-mcp-server transport: stdio proxyPort: 8080 @@ -154,13 +157,14 @@ 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 namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: docker.io/mcp/memory transport: stdio proxyPort: 8080 @@ -174,13 +178,14 @@ 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 namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: docker.io/mcp/puppeteer transport: stdio proxyPort: 8080 @@ -194,13 +199,14 @@ spec: --- # Step 2f: MCPServer backend - osv (OSV vulnerability database) -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPServer 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 @@ -214,13 +220,14 @@ 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 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 @@ -239,13 +246,14 @@ 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 namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: mcr.microsoft.com/playwright/mcp:v0.0.68 transport: stdio proxyPort: 8080 @@ -259,13 +267,14 @@ 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 namespace: default spec: - groupRef: optimizer-services + groupRef: + name: optimizer-services image: docker.io/mcp/everything:latest transport: stdio proxyPort: 8080 @@ -279,13 +288,14 @@ 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 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 @@ -302,13 +312,14 @@ 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 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 @@ -332,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 @@ -344,15 +355,15 @@ 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 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..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 @@ -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: @@ -82,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 @@ -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 @@ -105,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 @@ -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: @@ -130,7 +133,7 @@ spec: memory: "64Mi" --- -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: production-vmcp @@ -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 @@ -162,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/examples/operator/virtual-mcps/vmcp_simple_discovered.yaml b/examples/operator/virtual-mcps/vmcp_simple_discovered.yaml index 8803fe336c..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,13 +26,14 @@ 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 namespace: default spec: - groupRef: my-services + groupRef: + name: my-services image: ghcr.io/stackloklabs/yardstick/yardstick-server:1.1.1 transport: stdio proxyPort: 8080 @@ -46,15 +47,16 @@ spec: --- # Step 3: Create VirtualMCPServer -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: simple-vmcp 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..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,14 +34,15 @@ spec: --- # VirtualMCPServer with oidcConfigRef in incomingAuth -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer 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 new file mode 100644 index 0000000000..2339acb9e6 --- /dev/null +++ b/examples/operator/virtual-mcps/vmcp_with_telemetry_ref.yaml @@ -0,0 +1,109 @@ +# 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 a future API version. +# +# 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/v1beta1 +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/v1beta1 +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/v1beta1 +kind: MCPServer +metadata: + name: yardstick-telemetry + namespace: toolhive-system +spec: + groupRef: + name: 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/v1beta1 +kind: VirtualMCPServer +metadata: + name: telemetry-demo-vmcp + namespace: toolhive-system +spec: + # Shared telemetry configuration reference + telemetryConfigRef: + name: shared-otel + serviceName: telemetry-demo-vmcp + + groupRef: + name: telemetry-demo + + # vMCP configuration + config: + 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/go.mod b/go.mod index 6293de206b..4ded10462d 100644 --- a/go.mod +++ b/go.mod @@ -14,13 +14,13 @@ 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 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 @@ -30,9 +30,9 @@ 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/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,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.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 github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd @@ -54,9 +54,9 @@ 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/otlptrace/otlptracehttp v1.41.0 - go.opentelemetry.io/otel/exporters/prometheus v0.63.0 + 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.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 @@ -81,6 +81,10 @@ 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 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 @@ -142,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 @@ -201,7 +205,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 @@ -217,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 @@ -230,7 +233,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 @@ -243,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 @@ -272,18 +275,17 @@ 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 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.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 + 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 @@ -292,9 +294,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..239f4b3bb2 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= @@ -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= @@ -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= @@ -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= @@ -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= @@ -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= @@ -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= @@ -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= @@ -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= @@ -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= @@ -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= @@ -708,8 +709,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= @@ -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= @@ -796,10 +797,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.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= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -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= @@ -916,14 +915,14 @@ 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/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/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/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/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.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= @@ -934,10 +933,12 @@ 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.step.sm/crypto v0.74.0 h1:/APBEv45yYR4qQFg47HA8w1nesIGcxh44pGyQNw6JRA= -go.step.sm/crypto v0.74.0/go.mod h1:UoXqCAJjjRgzPte0Llaqen7O9P7XjPmgjgTHQGkKCDk= +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.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= @@ -959,8 +960,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= @@ -1112,18 +1113,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= -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= +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.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= +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= diff --git a/pkg/api/server.go b/pkg/api/server.go index 32a7a95c76..bc23dcd490 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)) @@ -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/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.go b/pkg/api/v1/registry.go index 7d09234a5e..3727ef3f8f 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"` } @@ -93,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/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/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 236d5313a0..ec19543e61 100644 --- a/pkg/api/v1/registry_v01_skills.go +++ b/pkg/api/v1/registry_v01_skills.go @@ -8,77 +8,30 @@ import ( "errors" "log/slog" "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 +// @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) + provider, ok := getRegistryProvider(w) if !ok { return } @@ -99,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, @@ -124,11 +70,24 @@ 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") - provider, ok := getSkillsProvider(w) + provider, ok := getRegistryProvider(w) if !ok { return } @@ -175,29 +134,12 @@ 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 - } - } - if l := r.URL.Query().Get("limit"); l != "" { - if v, err := strconv.Atoi(l); err == nil && v > 0 && 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 type skillsV01Response struct { - Skills []types.Skill `json:"skills"` - Metadata skillsV01Metadata `json:"metadata"` -} - -type skillsV01Metadata struct { - Total int `json:"total"` - Page int `json:"page"` - Limit int `json:"limit"` + // Skills is the list of skills on the current page + Skills []types.Skill `json:"skills"` + // Metadata contains pagination information + 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 80917d39fb..290700d152 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" @@ -46,7 +48,7 @@ func TestFilterSkillsV01(t *testing.T) { } } -func TestParseSkillsPagination(t *testing.T) { +func TestParsePaginationV01(t *testing.T) { t.Parallel() tests := []struct { @@ -55,32 +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, 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) @@ -96,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) @@ -133,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/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/api/v1/skills.go b/pkg/api/v1/skills.go index 7f39da4b9b..23667222b1 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 } @@ -86,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 @@ -322,3 +324,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/api/v1/workload_service.go b/pkg/api/v1/workload_service.go index 142ecdd33a..ceb304a91f 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" @@ -63,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 @@ -73,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, } } @@ -142,6 +150,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,31 +212,66 @@ 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. + // Both paths use s.imageVerification so their behavior stays in sync. + if imageMetadata != nil && registryResolvedMetadata != nil { + if err := retriever.VerifyImage(imageURL, imageMetadata, s.imageVerification); 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, "", // We do not let the user specify a CA cert path here. - retriever.VerifyImageWarn, - "", // TODO Add support for registry groups lookups for API + s.imageVerification, + "", // Registry-based group lookups are not supported retrievalRuntimeConfig, ) if err != nil { @@ -231,41 +288,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 +420,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 +620,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..4387e0593e 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,15 @@ 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/runner/retriever" + "github.com/stacklok/toolhive/pkg/secrets" workloadsmocks "github.com/stacklok/toolhive/pkg/workloads/mocks" ) @@ -154,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 @@ -427,3 +481,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 a7044486c3..180e4e5045 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 @@ -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/api/v1/workloads_test.go b/pkg/api/v1/workloads_test.go index e940dfc155..6091879908 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, ) @@ -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, }, } @@ -488,11 +489,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{ @@ -501,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, }, } @@ -558,26 +558,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 +613,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{ @@ -646,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, }, } @@ -670,14 +649,79 @@ 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(), + imageVerification: retriever.VerifyImageWarn, + }, + } + + 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 +730,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 } } diff --git a/pkg/audit/auditor.go b/pkg/audit/auditor.go index e642cbb62f..ec2ecc5700 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 654a2f5beb..4bd5b3b084 100644 --- a/pkg/audit/auditor_test.go +++ b/pkg/audit/auditor_test.go @@ -826,3 +826,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/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/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, 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/auth/token.go b/pkg/auth/token.go index 56a0dde50d..215406599a 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -646,7 +646,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, @@ -898,9 +904,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 } @@ -1081,10 +1093,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 b0bd491f86..941abd7e61 100644 --- a/pkg/auth/token_test.go +++ b/pkg/auth/token_test.go @@ -2772,3 +2772,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") + }) +} 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/integration_test.go b/pkg/authserver/integration_test.go index cedc2c0344..051cf0748e 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) // ============================================================================ 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 68f91bb728..03d8309edb 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 cad84c53eb..d40adc1042 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/server/handlers/token.go b/pkg/authserver/server/handlers/token.go index e343c7e949..420a731f9c 100644 --- a/pkg/authserver/server/handlers/token.go +++ b/pkg/authserver/server/handlers/token.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/ory/fosite" + "github.com/stacklok/toolhive/pkg/authserver/server" "github.com/stacklok/toolhive/pkg/authserver/server/session" ) @@ -50,7 +51,7 @@ func (h *Handler) TokenHandler(w http.ResponseWriter, req *http.Request) { return } switch { - case len(resources) == 1: + case len(resources) == 1 && resources[0] != "": resource := resources[0] // Validate URI format per RFC 8707 if err := server.ValidateAudienceURI(resource); err != nil { diff --git a/pkg/authserver/server/handlers/token_test.go b/pkg/authserver/server/handlers/token_test.go index b3f27657ce..74a793c11a 100644 --- a/pkg/authserver/server/handlers/token_test.go +++ b/pkg/authserver/server/handlers/token_test.go @@ -255,35 +255,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) + // ptr is a helper to take the address of a string literal. + ptr := func(s string) *string { return &s } - // 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"}, + 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) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + handler, storState, _ := handlerTestSetup(t) + authorizeCode := simulateAuthorizeFlow(t, handler, storState) - require.Equal(t, http.StatusOK, rec.Code, "expected 200 OK, got %d: %s", rec.Code, rec.Body.String()) + 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) + } - // 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") + 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 := jwt.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_DefaultsSingleAudienceWhenResourceOmitted(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) { 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 79c4a836dc..c81a254d78 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 @@ -164,6 +164,15 @@ 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 + // 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 @@ -189,16 +198,28 @@ 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. -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{}, entityFactory: NewEntityFactory(), primaryUpstreamProvider: options.PrimaryUpstreamProvider, groupClaimName: options.GroupClaimName, + roleClaimName: options.RoleClaimName, + serverName: serverName, claimKeyLog: syncutil.NewAtMost(30 * time.Second), } @@ -304,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, @@ -550,7 +568,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) } @@ -587,7 +607,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) } @@ -620,13 +642,16 @@ func (a *Authorizer) authorizeResourceRead( // Create attributes for the entities attributes := mergeContexts(map[string]interface{}{ + "name": resourceURI, "uri": resourceURI, "operation": "read", "feature": "resource", }, 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) } @@ -664,7 +689,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) } @@ -736,11 +763,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) @@ -769,51 +814,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. // -// 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...) +// 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"]). +// +// 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 } - for _, name := range names { - val, ok := claims[name] + // 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 + } + + var current interface{} = map[string]interface{}(claims) + for _, segment := range parts { + m, ok := current.(map[string]interface{}) if !ok { - continue + 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) - } + current, ok = m[segment] + if !ok { + return nil + } + } + 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 c5fbe42b4e..65ef464273 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -5,8 +5,10 @@ package cedar import ( "context" + "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" @@ -33,11 +35,15 @@ 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 + serverName string + expectError bool + errorType error + wantRoleClaimName string + wantServerName string }{ { name: "Valid policy and empty entities", @@ -76,6 +82,30 @@ 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", + }, + { + name: "Stores server name", + policies: []string{`permit(principal, action, resource);`}, + entitiesJSON: `[]`, + serverName: "my-mcp-server", + expectError: false, + wantServerName: "my-mcp-server", + }, } // Run test cases @@ -84,9 +114,10 @@ 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, + }, tc.serverName) // Check error expectations if tc.expectError { @@ -98,6 +129,11 @@ 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) + assert.Equal(t, tc.wantServerName, cedarAuthz.serverName) } }) } @@ -235,6 +271,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: ` @@ -328,7 +427,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 @@ -353,7 +452,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 @@ -652,6 +751,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) }) } } @@ -664,7 +767,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 @@ -725,7 +828,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 @@ -776,7 +879,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 @@ -788,9 +891,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) @@ -816,7 +919,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 @@ -825,7 +928,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) @@ -840,7 +943,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 @@ -935,7 +1038,7 @@ func TestIsAuthorizedWithEntities(t *testing.T) { ); `}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) // Cast to concrete type @@ -951,6 +1054,7 @@ func TestIsAuthorizedWithEntities(t *testing.T) { map[string]interface{}{"name": "Test User"}, map[string]interface{}{"name": "weather"}, nil, + "", ) require.NoError(t, err) @@ -966,6 +1070,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() @@ -1074,7 +1239,7 @@ func TestAuthorizeWithJWTClaims_UpstreamProvider(t *testing.T) { Policies: []string{policy}, EntitiesJSON: `[]`, PrimaryUpstreamProvider: providerName, - }) + }, "") require.NoError(t, err) upstreamToken := makeUnsignedJWT(jwt.MapClaims{ @@ -1232,9 +1397,10 @@ func TestAuthorizeWithJWTClaims_GroupMembership(t *testing.T) { ` authorizer, err := NewCedarAuthorizer(ConfigOptions{ - Policies: []string{policy}, - EntitiesJSON: `[]`, - }) + Policies: []string{policy}, + EntitiesJSON: `[]`, + GroupClaimName: "groups", + }, "") require.NoError(t, err) tests := []struct { @@ -1292,6 +1458,71 @@ 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, + GroupClaimName: "groups", + }, "") + 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 @@ -1303,9 +1534,10 @@ 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) identity := &auth.Identity{ @@ -1353,7 +1585,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. @@ -1402,7 +1634,8 @@ func TestAuthorizeWithJWTClaims_UpstreamProviderWithGroups(t *testing.T) { Policies: []string{policy}, EntitiesJSON: `[]`, PrimaryUpstreamProvider: providerName, - }) + GroupClaimName: "groups", + }, "") require.NoError(t, err) tests := []struct { @@ -1579,11 +1812,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{ @@ -1593,6 +1827,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) @@ -1606,6 +1841,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") }, @@ -1651,148 +1888,965 @@ 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"}, - }, - wantGroups: []string{"admin", "developers"}, + name: "exact_top_level_match", + claims: jwt.MapClaims{"groups": []interface{}{"eng", "platform"}}, + path: "groups", + want: []interface{}{"eng", "platform"}, }, { - name: "groups_claim_interface_slice", - claims: map[string]any{ - "sub": "user1", - "groups": []interface{}{"reader", "writer"}, + name: "dot_notation_traversal", + claims: jwt.MapClaims{ + "realm_access": map[string]interface{}{ + "roles": []interface{}{"admin", "user"}, + }, }, - wantGroups: []string{"reader", "writer"}, + path: "realm_access.roles", + want: []interface{}{"admin", "user"}, }, { - name: "roles_claim_string_slice", - claims: map[string]any{ - "sub": "user1", - "roles": []string{"viewer"}, + name: "auth0_url_claim_with_dots_matches_exact_first", + claims: jwt.MapClaims{ + "https://myapp.example.com/roles": []interface{}{"editor"}, }, - wantGroups: []string{"viewer"}, + path: "https://myapp.example.com/roles", + want: []interface{}{"editor"}, }, { - name: "cognito_groups_claim", - claims: map[string]any{ - "sub": "user1", - "cognito:groups": []string{"pool-admins"}, - }, - wantGroups: []string{"pool-admins"}, + name: "missing_claim_returns_nil", + claims: jwt.MapClaims{"sub": "user1"}, + path: "nonexistent", + want: nil, }, { - name: "custom_claim_name_takes_priority", - claims: map[string]any{ - "sub": "user1", - "https://example.com/groups": []string{"eng", "platform"}, - "groups": []string{"other"}, + name: "nested_traversal_hits_non_object", + claims: jwt.MapClaims{ + "foo": "a-string-not-a-map", }, - customClaimName: "https://example.com/groups", - wantGroups: []string{"eng", "platform"}, + path: "foo.bar", + want: nil, }, { - name: "custom_claim_name_falls_back_to_well_known", - claims: map[string]any{ - "sub": "user1", - "groups": []string{"fallback-group"}, + name: "three_level_nesting", + claims: jwt.MapClaims{ + "resource_access": map[string]interface{}{ + "my-app": map[string]interface{}{ + "roles": []interface{}{"viewer", "contributor"}, + }, + }, }, - customClaimName: "https://example.com/nonexistent", - wantGroups: []string{"fallback-group"}, + path: "resource_access.my-app.roles", + want: []interface{}{"viewer", "contributor"}, + }, + { + 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: "no_group_claim_present", - claims: map[string]any{ - "sub": "user1", - "name": "Alice", + name: "partial_nested_path_missing_leaf", + claims: jwt.MapClaims{ + "realm_access": map[string]interface{}{ + "other": "value", + }, }, - wantGroups: nil, + path: "realm_access.roles", + want: nil, }, { - name: "empty_groups_claim_returns_empty", - claims: map[string]any{ - "sub": "user1", - "groups": []string{}, + // 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"}, + }, }, - wantGroups: []string{}, + path: "realm_access.", + want: nil, }, { - name: "empty_interface_slice_returns_empty", - claims: map[string]any{ - "sub": "user1", - "groups": []interface{}{}, + name: "leading_dot_returns_nil", + claims: jwt.MapClaims{ + "roles": []interface{}{"admin"}, }, - wantGroups: []string{}, + path: ".roles", + want: nil, }, { - name: "empty_groups_does_not_fall_through_to_roles", - claims: map[string]any{ - "sub": "user1", - "groups": []string{}, - "roles": []string{"should-not-match"}, + name: "consecutive_dots_return_nil", + claims: jwt.MapClaims{ + "a": map[string]interface{}{ + "b": []interface{}{"x"}, + }, }, - wantGroups: []string{}, + path: "a..b", + want: nil, }, { - name: "non_string_interface_elements_skipped", - claims: map[string]any{ - "sub": "user1", - "groups": []interface{}{"valid", 42, true, "also-valid"}, + 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"}, + }, }, - wantGroups: []string{"valid", "also-valid"}, + path: "realm_access.roles", + want: []interface{}{"literal-match"}, + }, + } + + 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: "groups_claim_wrong_type_returns_nil", - claims: map[string]any{ - "sub": "user1", - "groups": "not-a-slice", + 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: "wrong_type_does_not_fall_through_to_roles", - claims: map[string]any{ - "sub": "user1", - "groups": "not-a-slice", - "roles": []string{"should-not-match"}, - }, + name: "missing_claim_returns_nil", + claims: jwt.MapClaims{"sub": "user1"}, + claimName: "groups", wantGroups: nil, }, { - name: "empty_claims_map", - claims: map[string]any{}, + name: "non_array_claim_returns_nil", + claims: jwt.MapClaims{"groups": "not-a-slice"}, + claimName: "groups", wantGroups: nil, }, { - name: "groups_claim_prioritised_over_roles", - claims: map[string]any{ - "sub": "user1", - "groups": []string{"grp-a"}, - "roles": []string{"role-b"}, + 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: "auth0_url_claim_name", + claims: jwt.MapClaims{ + "https://example.com/groups": []interface{}{"platform"}, }, - // defaultGroupClaimNames checks "groups" first; "groups" is found, so - // "roles" should not be returned. - wantGroups: []string{"grp-a"}, + claimName: "https://example.com/groups", + wantGroups: []string{"platform"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := extractGroupsFromClaims(tt.claims, tt.customClaimName) + 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{}{"platform", "devs"}, + }, + wantAuthorize: true, + }, + { + name: "role_claim_only", + roleClaim: "roles", + claims: jwt.MapClaims{ + "sub": "user1", + "roles": []interface{}{"platform"}, + }, + wantAuthorize: true, + }, + { + name: "both_claims_merged", + groupClaim: "groups", + roleClaim: "roles", + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"devs"}, + "roles": []interface{}{"platform"}, + }, + wantAuthorize: true, + }, + { + name: "duplicates_across_claims_are_deduplicated", + groupClaim: "groups", + roleClaim: "roles", + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"platform", "devs"}, + "roles": []interface{}{"platform", "ops"}, + }, + wantAuthorize: true, + }, + { + name: "neither_claim_has_matching_group", + groupClaim: "groups", + roleClaim: "roles", + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"marketing"}, + "roles": []interface{}{"sales"}, + }, + wantAuthorize: false, + }, + { + name: "both_claims_empty_falls_back_to_well_known", + groupClaim: "", + roleClaim: "", + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"platform"}, + }, + wantAuthorize: true, // well-known "groups" claim is checked when GroupClaimName is empty + }, + { + 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: "nested_role_claim_with_dot_notation", + groupClaim: "groups", + roleClaim: "realm_access.roles", + claims: jwt.MapClaims{ + "sub": "user1", + "groups": []interface{}{"devs"}, + "realm_access": map[string]interface{}{ + "roles": []interface{}{"platform"}, + }, + }, + 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 { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + 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) + }) + } +} + +// 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) { + 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") + } + }) + } +} diff --git a/pkg/authz/authorizers/cedar/entity.go b/pkg/authz/authorizers/cedar/entity.go index baeddd1eab..2439b5d807 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,24 +69,31 @@ 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)) - // 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) entity := cedar.Entity{ UID: uid, - Parents: cedar.NewEntityUIDSet(), + Parents: cedar.NewEntityUIDSet(parents...), Attributes: attrs, Tags: cedar.NewRecord(cedar.RecordMap{}), } @@ -109,14 +102,21 @@ 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. +// +// 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) @@ -137,21 +137,33 @@ 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 + // 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 ff20483d3d..d67927b380 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,197 @@ 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() + + // 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) + + 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() - // 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()) + mcpUID := cedar.NewEntityUID("MCP", cedar.String("server-a")) + orgUID := cedar.NewEntityUID("Org", cedar.String("stacklok")) - // 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) + 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))) + }) + } +} + +// 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_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, }, } @@ -135,23 +270,26 @@ func TestCreateEntitiesForRequest_WithGroups(t *testing.T) { map[string]interface{}{"sub": "user1"}, map[string]interface{}{"name": "weather"}, tt.groups, + "", ) 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) } @@ -250,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 { @@ -326,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) + } + }) + } +} diff --git a/pkg/authz/integration_test.go b/pkg/authz/integration_test.go index 990a298b45..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) { @@ -48,7 +58,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 +345,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 { @@ -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) + } + }) + } +} diff --git a/pkg/authz/middleware_test.go b/pkg/authz/middleware_test.go index b2585a6dbe..8fd1d64fe6 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 @@ -831,7 +831,7 @@ func TestMiddlewareToolsListTestkit(t *testing.T) { cedar.ConfigOptions{ Policies: tc.policies, EntitiesJSON: `[]`, - }, + }, "", ) require.NoError(t, err, "Failed to create Cedar authorizer") @@ -1001,7 +1001,7 @@ func TestMiddlewareToolsCallTestkit(t *testing.T) { cedar.ConfigOptions{ Policies: tc.policies, EntitiesJSON: `[]`, - }, + }, "", ) require.NoError(t, err, "Failed to create Cedar authorizer") @@ -1072,7 +1072,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{}{ @@ -1173,7 +1173,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 c0ff89b84f..2d29df9a71 100644 --- a/pkg/authz/response_filter_test.go +++ b/pkg/authz/response_filter_test.go @@ -90,7 +90,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{ @@ -241,7 +241,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 { @@ -434,7 +434,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 @@ -483,7 +483,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 @@ -617,7 +617,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. @@ -810,7 +810,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 d1dabac739..c6c1bfef28 100644 --- a/pkg/authz/tool_filter_test.go +++ b/pkg/authz/tool_filter_test.go @@ -190,7 +190,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) { @@ -233,7 +233,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) { @@ -263,7 +263,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) { 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]) + } } // --------------------------------------------------------------------------- diff --git a/pkg/client/config.go b/pkg/client/config.go index ce95ba6cbc..d9781913c1 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -82,6 +82,10 @@ const ( MistralVibe ClientApp = "mistral-vibe" // Codex represents the OpenAI Codex CLI. 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. @@ -214,6 +218,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "url", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".roo", "skills"}, + SkillsProjectPath: []string{".roo", "skills"}, }, { ClientType: Cline, @@ -240,6 +247,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "url", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".cline", "skills"}, + SkillsProjectPath: []string{".cline", "skills"}, }, { ClientType: VSCodeInsider, @@ -266,6 +276,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "url", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".copilot", "skills"}, + SkillsProjectPath: []string{".github", "skills"}, }, { ClientType: VSCode, @@ -292,6 +305,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "url", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".copilot", "skills"}, + SkillsProjectPath: []string{".github", "skills"}, }, { ClientType: Cursor, @@ -357,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, @@ -400,6 +419,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "url", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, }, { ClientType: AmpVSCode, @@ -546,6 +568,9 @@ var supportedClientIntegrations = []clientAppConfig{ "timeout": 60, "description": "", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, }, { ClientType: Trae, @@ -570,6 +595,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "url", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, }, { ClientType: Continue, @@ -637,6 +665,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "url", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".kiro", "skills"}, + SkillsProjectPath: []string{".kiro", "skills"}, }, { ClientType: Antigravity, @@ -651,6 +682,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "serverUrl", types.TransportTypeStreamableHTTP: "serverUrl", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, }, { ClientType: Zed, @@ -687,6 +721,9 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: "url", types.TransportTypeStreamableHTTP: "httpUrl", }, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", "skills"}, + SkillsProjectPath: []string{".agents", "skills"}, }, { ClientType: VSCodeServer, @@ -722,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, @@ -744,6 +784,46 @@ 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"}, + }, + { + 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 90b8dd9268..aa07e71475 100644 --- a/pkg/client/config_test.go +++ b/pkg/client/config_test.go @@ -209,6 +209,22 @@ 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, + }, + { + ClientType: Factory, + Description: "Factory.ai Droid CLI (Mock)", + RelPath: []string{"mock_factory"}, + SettingsFile: "mcp.json", + MCPServersPathPrefix: "/mcpServers", + Extension: JSON, + }, } } @@ -233,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) } @@ -394,6 +410,8 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { string(VSCodeServer), string(MistralVibe), string(Codex), + string(KimiCli), + string(Factory), }, }, } @@ -486,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: + case LMStudio, Trae, Kiro, Antigravity, GeminiCli, KimiCli, Factory: assert.Contains(t, string(content), `"mcpServers":`, "Config should contain mcpServers key") case VSCodeServer: @@ -545,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: + MistralVibe, Codex, KimiCli, Factory: assert.Contains(t, string(content), testURL, "Config should contain the server URL") } @@ -1274,8 +1292,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 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++ { @@ -1463,5 +1481,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, 27, "Expected 27 clients in CSV list") } 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 3fae6de698..dc6b37dd9e 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) } @@ -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) { @@ -197,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/client/skills_test.go b/pkg/client/skills_test.go index 6176197cd4..08f9c3569d 100644 --- a/pkg/client/skills_test.go +++ b/pkg/client/skills_test.go @@ -47,7 +47,91 @@ func testSkillClientIntegrations() []clientAppConfig { SkillsProjectPath: []string{".cursor", "skills"}, }, { - ClientType: VSCode, + ClientType: KimiCli, + SupportsSkills: true, + SkillsGlobalPath: []string{".kimi", "skills"}, + SkillsProjectPath: []string{".kimi", "skills"}, + }, + { + ClientType: Factory, + SupportsSkills: true, + SkillsGlobalPath: []string{".factory", "skills"}, + SkillsProjectPath: []string{".factory", "skills"}, + }, + { + 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: 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: Kiro, + SupportsSkills: true, + SkillsGlobalPath: []string{".kiro", "skills"}, + SkillsProjectPath: []string{".kiro", "skills"}, + }, + { + 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 }, { @@ -77,6 +161,21 @@ 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: "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: "Kiro supports skills", client: Kiro, expected: true}, + {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}, } @@ -93,8 +192,9 @@ 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 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++ { @@ -207,10 +307,220 @@ func TestGetSkillPath(t *testing.T) { wantPath: filepath.Join("/tmp/myproject", ".cursor", "skills", "my-skill"), }, { - name: "client that does not support skills", + 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: "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, 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: "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: "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: "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, }, { 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..01222d8e77 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 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..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" @@ -160,8 +161,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 +173,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 +198,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 +230,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 +242,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) @@ -255,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() 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/container/kubernetes/client_test.go b/pkg/container/kubernetes/client_test.go index a9c09f48de..62324b36a1 100644 --- a/pkg/container/kubernetes/client_test.go +++ b/pkg/container/kubernetes/client_test.go @@ -194,7 +194,7 @@ func TestCreateContainerWithPodTemplatePatch(t *testing.T) { // Deploy the workload _, err := client.DeployWorkload( - context.Background(), + t.Context(), "test-image", "test-container", []string{"test-command"}, @@ -216,7 +216,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{}, ) @@ -730,7 +730,7 @@ func TestCreateContainerWithMCP(t *testing.T) { // Deploy the workload _, err := client.DeployWorkload( - context.Background(), + t.Context(), tc.image, "test-container", tc.command, @@ -752,7 +752,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{}, ) @@ -960,7 +960,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) @@ -1421,7 +1421,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, @@ -1429,7 +1429,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 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/container/templates/runtime_config.go b/pkg/container/templates/runtime_config.go index a61477ecce..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.25-alpine", "node:22-alpine", "python:3.13-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 @@ -75,15 +75,15 @@ 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: { - BuilderImage: "node:22-alpine", + BuilderImage: "node:24-alpine", 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 590ed3e8e4..5e69ffc0d7 100644 --- a/pkg/container/templates/runtime_config_test.go +++ b/pkg/container/templates/runtime_config_test.go @@ -23,19 +23,19 @@ 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"}, }, { name: "NPX default config", transportType: TransportTypeNPX, - wantImage: "node:22-alpine", + wantImage: "node:24-alpine", wantPackages: []string{"git", "ca-certificates"}, }, { name: "UVX default config", transportType: TransportTypeUVX, - wantImage: "python:3.13-slim", + wantImage: "python:3.14-slim", 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() @@ -216,7 +216,7 @@ func TestRuntimeConfigValidate_ValidBuilderImages(t *testing.T) { "docker.io/library/node:20-alpine", "ghcr.io/stacklok/builder:latest", "python:3.13-slim", - "node:22-alpine", + "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 0e72fe8cd8..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", @@ -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", @@ -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", @@ -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{}, }, }, @@ -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{}, }, }, @@ -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{}, }, }, @@ -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, }, }, @@ -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, }, }, @@ -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/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/export/k8s.go b/pkg/export/k8s.go index e57cfda9aa..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,26 +119,15 @@ 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 } } - // 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 { @@ -146,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, }, } @@ -161,35 +150,14 @@ 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, } } - // 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 @@ -197,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 b5d8d26017..a3070c3f16 100644 --- a/pkg/export/k8s_test.go +++ b/pkg/export/k8s_test.go @@ -13,13 +13,11 @@ 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/auth" "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" ) @@ -42,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", @@ -56,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) @@ -78,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()) @@ -97,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) @@ -120,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) @@ -143,36 +141,13 @@ 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) }, }, - { - 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{ @@ -187,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]) @@ -208,53 +183,12 @@ 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) }, }, - { - 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{ @@ -264,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") @@ -322,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) @@ -341,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", @@ -359,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/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/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/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/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) + } + } + }) + } +} 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/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/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/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/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/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/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/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/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/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 { 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)") +} diff --git a/pkg/runner/config.go b/pkg/runner/config.go index 827909a3fb..eaeb3a4877 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"` @@ -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..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" @@ -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 { @@ -538,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/runner/protocol_test.go b/pkg/runner/protocol_test.go index e9493c02d2..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, }, @@ -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, }, @@ -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"}, }, { @@ -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"}, }, { @@ -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"}, }, } @@ -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") } } diff --git a/pkg/runner/retriever/retriever.go b/pkg/runner/retriever/retriever.go index acfd7ae788..0235236a37 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) @@ -121,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 } @@ -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, @@ -403,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") 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) { diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 07e5a1b413..ca220e5794 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -458,6 +458,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/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": <actual value>}. + 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 +} 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, "") 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 +} 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/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 != "" { 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/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/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/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..507cf6a13d --- /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.StatusBadGateway, + ) + } + } + + 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..2ee52b512a --- /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 502", 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.StatusBadGateway, 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..bdd7bad17f --- /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.StatusBadGateway, + ) + } + + 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..9a51da7ac5 --- /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.StatusBadGateway, + 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.StatusBadGateway, + 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.StatusBadGateway, + 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.StatusBadGateway, + 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 c3623d659f..0000000000 --- a/pkg/skills/skillsvc/skillsvc.go +++ /dev/null @@ -1,1636 +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, err := s.installFromOCI(ctx, opts, scope, ref) - if err != nil { - return nil, err - } - return s.installAndRegister(ctx, result, opts.Group, opts.Name, scope, opts.ProjectRoot) - } - - // 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 { - 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 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, - ) -} - -// 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 - } - - // 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)) - } - } - } - - 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 -} - -// 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 -} - -// 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 - } - return s.gitWriteMultiAndPersist(ctx, opts, scope, allClients, allDirs, files, - allClients, 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 - } - for _, ct := range toWrite { - 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, - toWrite, 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) { - for _, ct := range clientTypes { - 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, - clientTypes, 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 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. -func (s *service) resolveFromRegistry(name string) (*registryResolveResult, error) { - if s.skillLookup == nil { - return nil, nil - } - - results, err := s.skillLookup.SearchSkills(name) - 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 - // 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 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) -} - -// 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, - ) - } - 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 skill-supporting clients configured"), - http.StatusInternalServerError, - ) - } - 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 - } - var written []string - for _, ct := range toWrite { - 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 - } - var written []string - for _, ct := range allClients { - 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, allClients) - 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) { - for _, ct := range clientTypes { - 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 clientTypes { - 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 { - for _, ct := range clientTypes { - _ = s.installer.Remove(filepath.Clean(clientDirs[ct])) - } - 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 d642c6a1ba..0000000000 --- a/pkg/skills/skillsvc/skillsvc_test.go +++ /dev/null @@ -1,3336 +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) - } - }) - } -} - -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 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/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) + } + }) + } +} 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{ 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 2a13429513..9914547537 100644 --- a/pkg/transport/proxy/transparent/transparent_proxy.go +++ b/pkg/transport/proxy/transparent/transparent_proxy.go @@ -91,6 +91,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 @@ -244,6 +247,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. @@ -452,11 +464,11 @@ 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, proxy.healthCheckPingTimeout) + } else { + mcpPinger = NewMCPPingerWithTimeout(targetURI, proxy.healthCheckPingTimeout) } - mcpPinger = NewMCPPingerWithTimeout(targetURI, pingTimeout) } proxy.healthChecker = healthcheck.NewHealthChecker(transportType, mcpPinger) @@ -1146,7 +1158,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 @@ -1287,11 +1303,7 @@ func shutdownOnHealthCheckFailure() bool { } 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 @@ -1429,3 +1441,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/pkg/transport/session/session_data_storage.go b/pkg/transport/session/session_data_storage.go index 9093fcdf5d..a7b951db2f 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. @@ -42,24 +43,23 @@ 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_redis.go b/pkg/transport/session/session_data_storage_redis.go index 02d04c2027..82ca1767d5 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) @@ -120,8 +120,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/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/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/cli/auth_server_config_test.go b/pkg/vmcp/cli/auth_server_config_test.go new file mode 100644 index 0000000000..885635a1b0 --- /dev/null +++ b/pkg/vmcp/cli/auth_server_config_test.go @@ -0,0 +1,78 @@ +// 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" + "gopkg.in/yaml.v3" + + authserverconfig "github.com/stacklok/toolhive/pkg/authserver" +) + +func TestLoadAuthServerConfig(t *testing.T) { + t.Parallel() + + t.Run("returns nil when file does not exist", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + configPath := filepath.Join(dir, "vmcp-config.yaml") + + rc, err := loadAuthServerConfig(configPath) + + require.NoError(t, err) + assert.Nil(t, rc) + }) + + t.Run("returns populated RunConfig for valid YAML", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + configPath := filepath.Join(dir, "vmcp-config.yaml") + + want := &authserverconfig.RunConfig{ + Issuer: "https://test-issuer.example.com", + SchemaVersion: "1", + } + + data, err := yaml.Marshal(want) + require.NoError(t, err) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "authserver-config.yaml"), + data, + 0o600, + )) + + rc, err := loadAuthServerConfig(configPath) + + require.NoError(t, err) + require.NotNil(t, rc) + assert.Equal(t, "https://test-issuer.example.com", rc.Issuer) + assert.Equal(t, "1", rc.SchemaVersion) + }) + + t.Run("returns error for invalid YAML", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + configPath := filepath.Join(dir, "vmcp-config.yaml") + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "authserver-config.yaml"), + []byte(":::not valid yaml"), + 0o600, + )) + + rc, err := loadAuthServerConfig(configPath) + + require.Error(t, err) + assert.Nil(t, rc) + assert.Contains(t, err.Error(), "failed to parse") + }) +} diff --git a/pkg/vmcp/cli/embedding_manager.go b/pkg/vmcp/cli/embedding_manager.go new file mode 100644 index 0000000000..293b488c2e --- /dev/null +++ b/pkg/vmcp/cli/embedding_manager.go @@ -0,0 +1,375 @@ +// 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" + "strings" + "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 ( + // 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" + + // 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 DefaultEmbeddingImage 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 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 + 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 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") + } + if cfg.Image == "" { + cfg.Image = DefaultEmbeddingImage + } + + 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://127.0.0.1:%d", port) + } + mgr.healthURLFor = func(port int) string { + return fmt.Sprintf("http://127.0.0.1:%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..2cc1e7a629 --- /dev/null +++ b/pkg/vmcp/cli/embedding_manager_test.go @@ -0,0 +1,395 @@ +// 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_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) + mockFactory := mocks.NewMockContainerFactory(ctrl) + + _, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{Model: ""}) + 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) + mockFactory := mocks.NewMockContainerFactory(ctrl) + + mgr, err := NewEmbeddingServiceManager(mockFactory, EmbeddingServiceManagerConfig{ + Model: "BAAI/bge-small-en-v1.5", + }) + require.NoError(t, err) + assert.Equal(t, DefaultEmbeddingImage, 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(), + DefaultEmbeddingImage, + 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://127.0.0.1:") +} + +// 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(), + DefaultEmbeddingImage, + 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/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") +} 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) +} 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 new file mode 100644 index 0000000000..7714e35163 --- /dev/null +++ b/pkg/vmcp/cli/serve.go @@ -0,0 +1,686 @@ +// 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" + "net" + "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" + "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" + "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. +// 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. + Port int + // 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 +// (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 err := cfg.validateQuickModeHost(); err != nil { + return err + } + + // 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 + } + + // 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. + // 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 + // 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) + } + + // 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 + } + image := cfg.EmbeddingImage + if image == "" { + image = DefaultEmbeddingImage + } + m, err := NewEmbeddingServiceManager(container.NewFactory(), EmbeddingServiceManagerConfig{ + Model: model, + Image: image, + }) + 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) + } + + envReader := &env.OSReader{} + sessionFactory, err := createSessionFactory( + envReader.Getenv("VMCP_SESSION_HMAC_SECRET"), + runtime.IsKubernetesRuntimeWithEnv(envReader), + outgoingRegistry, + agg, + backendTimeoutSessionOptions(vmcpCfg)..., + ) + 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, + SessionTTL: time.Duration(vmcpCfg.SessionTTL), + WriteTimeout: serverWriteTimeoutFromConfig(vmcpCfg), + SessionOwnerAdvertiseURL: resolveSessionOwnerAdvertiseURL( + envReader.Getenv(envVMCPSessionOwnerURL), + envReader.Getenv(envPodIP), + cfg.Port, + ), + } + + // 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) +} + +// 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 { + // 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 + 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 { + 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 +} + +// 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). +// 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 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) + } + + discoverer, err = aggregator.NewBackendDiscoverer(ctx, groupsManager, cfg.OutgoingAuth) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create backend discoverer: %w", err) + } + } + + 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 { + // 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) + } + + if len(backends) == 0 { + 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 + } + + 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 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 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, + extraOpts ...vmcpsession.MultiSessionFactoryOption, +) (vmcpsession.MultiSessionFactory, error) { + const minRecommendedSecretLen = 32 + + opts := []vmcpsession.MultiSessionFactoryOption{} + if agg != nil { + opts = append(opts, vmcpsession.WithAggregator(agg)) + } + opts = append(opts, extraOpts...) + + 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 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 isKubernetes { + return nil, fmt.Errorf( + "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("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..667b285779 --- /dev/null +++ b/pkg/vmcp/cli/serve_test.go @@ -0,0 +1,364 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "fmt" + "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/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" + "github.com/stacklok/toolhive/pkg/vmcp/config" + 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_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) +} + +// 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 +// 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/serve_timeouts.go b/pkg/vmcp/cli/serve_timeouts.go new file mode 100644 index 0000000000..be6dd4c4da --- /dev/null +++ b/pkg/vmcp/cli/serve_timeouts.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "fmt" + "net" + "strings" + "time" + + "github.com/stacklok/toolhive/pkg/vmcp/config" + vmcpsession "github.com/stacklok/toolhive/pkg/vmcp/session" +) + +const ( + envVMCPSessionOwnerURL = "THV_VMCP_SESSION_OWNER_URL" + envPodIP = "POD_IP" + backendResponseMargin = 5 * time.Second +) + +// backendTimeoutSessionOptions translates the operational timeout settings into +// session factory options so backend init and request deadlines follow config. +func backendTimeoutSessionOptions(cfg *config.Config) []vmcpsession.MultiSessionFactoryOption { + var opts []vmcpsession.MultiSessionFactoryOption + if initTimeout := backendInitTimeoutFromConfig(cfg); initTimeout > 0 { + opts = append(opts, vmcpsession.WithBackendInitTimeout(initTimeout)) + } + requestTimeout, perWorkload := backendRequestTimeoutsFromConfig(cfg) + if requestTimeout > 0 || len(perWorkload) > 0 { + opts = append(opts, vmcpsession.WithBackendRequestTimeouts(requestTimeout, perWorkload)) + } + return opts +} + +func backendInitTimeoutFromConfig(cfg *config.Config) time.Duration { + if cfg == nil || cfg.Operational == nil { + return 0 + } + + if cfg.Operational.FailureHandling != nil && cfg.Operational.FailureHandling.HealthCheckTimeout > 0 { + return time.Duration(cfg.Operational.FailureHandling.HealthCheckTimeout) + } + + if cfg.Operational.Timeouts != nil && cfg.Operational.Timeouts.Default > 0 { + return time.Duration(cfg.Operational.Timeouts.Default) + } + + return 0 +} + +func backendRequestTimeoutsFromConfig(cfg *config.Config) (time.Duration, map[string]time.Duration) { + if cfg == nil || cfg.Operational == nil || cfg.Operational.Timeouts == nil { + return 0, nil + } + + timeouts := cfg.Operational.Timeouts + perWorkload := make(map[string]time.Duration, len(timeouts.PerWorkload)) + for workloadID, timeout := range timeouts.PerWorkload { + perWorkload[workloadID] = time.Duration(timeout) + } + + return time.Duration(timeouts.Default), perWorkload +} + +func serverWriteTimeoutFromConfig(cfg *config.Config) time.Duration { + defaultTimeout, perWorkload := backendRequestTimeoutsFromConfig(cfg) + maxTimeout := defaultTimeout + for _, timeout := range perWorkload { + if timeout > maxTimeout { + maxTimeout = timeout + } + } + if maxTimeout <= 0 { + return 0 + } + return maxTimeout + backendResponseMargin +} + +func resolveSessionOwnerAdvertiseURL(explicit, podIP string, port int) string { + if explicit = strings.TrimSpace(explicit); explicit != "" { + return explicit + } + if podIP == "" || port <= 0 { + return "" + } + return fmt.Sprintf("http://%s/mcp", net.JoinHostPort(podIP, fmt.Sprintf("%d", port))) +} diff --git a/cmd/vmcp/app/commands_test.go b/pkg/vmcp/cli/serve_timeouts_test.go similarity index 69% rename from cmd/vmcp/app/commands_test.go rename to pkg/vmcp/cli/serve_timeouts_test.go index 3750f9e35e..bc4ad1936b 100644 --- a/cmd/vmcp/app/commands_test.go +++ b/pkg/vmcp/cli/serve_timeouts_test.go @@ -1,85 +1,17 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -package app +package cli import ( - "os" - "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" - authserverconfig "github.com/stacklok/toolhive/pkg/authserver" "github.com/stacklok/toolhive/pkg/vmcp/config" ) -func TestLoadAuthServerConfig(t *testing.T) { - t.Parallel() - - t.Run("returns nil when file does not exist", func(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - configPath := filepath.Join(dir, "vmcp-config.yaml") - - rc, err := loadAuthServerConfig(configPath) - - require.NoError(t, err) - assert.Nil(t, rc) - }) - - t.Run("returns populated RunConfig for valid YAML", func(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - configPath := filepath.Join(dir, "vmcp-config.yaml") - - want := &authserverconfig.RunConfig{ - Issuer: "https://test-issuer.example.com", - SchemaVersion: "1", - } - - data, err := yaml.Marshal(want) - require.NoError(t, err) - require.NoError(t, os.WriteFile( - filepath.Join(dir, "authserver-config.yaml"), - data, - 0o600, - )) - - rc, err := loadAuthServerConfig(configPath) - - require.NoError(t, err) - require.NotNil(t, rc) - assert.Equal(t, "https://test-issuer.example.com", rc.Issuer) - assert.Equal(t, "1", rc.SchemaVersion) - }) - - t.Run("returns error for invalid YAML", func(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - configPath := filepath.Join(dir, "vmcp-config.yaml") - - require.NoError(t, os.WriteFile( - filepath.Join(dir, "authserver-config.yaml"), - []byte(":::not valid yaml"), - 0o600, - )) - - rc, err := loadAuthServerConfig(configPath) - - require.Error(t, err) - assert.Nil(t, rc) - assert.Contains(t, err.Error(), "failed to parse") - }) - -} - func TestResolveSessionOwnerAdvertiseURL(t *testing.T) { t.Parallel() 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 +} 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) + } + }) + } +} 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") +} diff --git a/pkg/vmcp/config/config.go b/pkg/vmcp/config/config.go index 4aed44cd4b..4fcd75e8f8 100644 --- a/pkg/vmcp/config/config.go +++ b/pkg/vmcp/config/config.go @@ -104,9 +104,10 @@ 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. - // +kubebuilder:validation:Required - Group string `json:"groupRef" yaml:"groupRef"` + // 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"` // Backends defines pre-configured backend servers for static mode. // When OutgoingAuth.Source is "inline", this field contains the full list of backend @@ -153,6 +154,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"` diff --git a/pkg/vmcp/config/validator_test.go b/pkg/vmcp/config/validator_test.go index eb4093a338..fa4a8e2af0 100644 --- a/pkg/vmcp/config/validator_test.go +++ b/pkg/vmcp/config/validator_test.go @@ -708,7 +708,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, }, diff --git a/pkg/vmcp/discovery/middleware.go b/pkg/vmcp/discovery/middleware.go index 68f1f6a66d..684bb345d1 100644 --- a/pkg/vmcp/discovery/middleware.go +++ b/pkg/vmcp/discovery/middleware.go @@ -146,6 +146,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 5b217634fb..651fac55ea 100644 --- a/pkg/vmcp/health/monitor.go +++ b/pkg/vmcp/health/monitor.go @@ -591,6 +591,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", @@ -601,11 +608,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. // @@ -636,16 +644,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 @@ -654,10 +664,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 @@ -673,18 +684,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. @@ -756,6 +776,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. @@ -865,19 +909,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/k8s/backend_reconciler.go b/pkg/vmcp/k8s/backend_reconciler.go index 6a7795c353..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,37 +150,37 @@ 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 { return &backendResourceInfo{ Name: mcpServer.Name, - GroupRef: mcpServer.Spec.GroupRef, + GroupRef: mcpServer.Spec.GroupRef.GetName(), Type: workloads.WorkloadTypeMCPServer, }, nil } // Try to fetch as MCPRemoteProxy - mcpRemoteProxy := &mcpv1alpha1.MCPRemoteProxy{} + mcpRemoteProxy := &mcpv1beta1.MCPRemoteProxy{} errProxy := r.Get(ctx, namespacedName, mcpRemoteProxy) if errProxy == nil { return &backendResourceInfo{ Name: mcpRemoteProxy.Name, - GroupRef: mcpRemoteProxy.Spec.GroupRef, + GroupRef: mcpRemoteProxy.Spec.GroupRef.GetName(), Type: workloads.WorkloadTypeMCPRemoteProxy, }, nil } // Try to fetch as MCPServerEntry - mcpServerEntry := &mcpv1alpha1.MCPServerEntry{} + mcpServerEntry := &mcpv1beta1.MCPServerEntry{} errEntry := r.Get(ctx, namespacedName, mcpServerEntry) if errEntry == nil { return &backendResourceInfo{ Name: mcpServerEntry.Name, - GroupRef: mcpServerEntry.Spec.GroupRef, + GroupRef: mcpServerEntry.Spec.GroupRef.GetName(), Type: workloads.WorkloadTypeMCPServerEntry, }, 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 @@ -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 && @@ -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 @@ -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 } @@ -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 @@ -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 } @@ -396,13 +396,13 @@ 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 } // Only reconcile if matches groupRef (security + performance) - if server.Spec.GroupRef != r.GroupRef { + if server.Spec.GroupRef.GetName() != r.GroupRef { return nil } @@ -421,13 +421,13 @@ 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 } // Only reconcile if matches groupRef (security + performance) - if proxy.Spec.GroupRef != r.GroupRef { + if proxy.Spec.GroupRef.GetName() != r.GroupRef { return nil } @@ -446,13 +446,13 @@ 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 } // Only reconcile if matches groupRef (security + performance) - if entry.Spec.GroupRef != r.GroupRef { + if entry.Spec.GroupRef.GetName() != r.GroupRef { 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}, @@ -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{ @@ -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 7f63935e22..2221e69437 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}) @@ -93,10 +93,10 @@ var _ = Describe("BackendReconciler Integration Tests", func() { ) var ( - registry vmcp.DynamicRegistry - reconcilerMgr ctrl.Manager - reconcilerCtx context.Context - reconcilerStop context.CancelFunc + registry vmcp.DynamicRegistry + reconcilerMgr ctrl.Manager + reconcilerCtx context.Context + reconcilerStop context.CancelFunc reconcilerStopped chan struct{} ) @@ -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: 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: "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: 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: 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: "other-group", + Spec: mcpv1beta1.MCPRemoteProxySpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: "other-group"}, RemoteURL: "https://example.com/mcp", }, } @@ -307,14 +307,14 @@ 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: testGroupRef, - Image: "test-image:latest", + 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: 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: "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: 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: 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 ddaceddd07..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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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 61e8068a58..97c3df1bda 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) } @@ -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,11 +302,13 @@ func (w *BackendWatcher) addBackendWatchController() error { Discoverer: discoverer, } - if err := reconciler.SetupIndexes(context.Background(), w.ctrlManager); err != nil { + // 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. + // Register reconciler with manager // This sets up watches on MCPServer, MCPRemoteProxy, MCPServerEntry, ExternalAuthConfig, and ConfigMap. if err := reconciler.SetupWithManager(w.ctrlManager); err != nil { return fmt.Errorf("failed to setup backend reconciler: %w", err) 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..64be1698e5 --- /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, nil) + 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) + + // 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 4b7077da7c..6c96b24b12 100644 --- a/pkg/vmcp/server/sessionmanager/session_manager.go +++ b/pkg/vmcp/server/sessionmanager/session_manager.go @@ -185,11 +185,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 @@ -813,6 +820,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_restore_writeback_test.go b/pkg/vmcp/server/sessionmanager/session_manager_restore_writeback_test.go new file mode 100644 index 0000000000..3e66c19e0d --- /dev/null +++ b/pkg/vmcp/server/sessionmanager/session_manager_restore_writeback_test.go @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package sessionmanager + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + transportsession "github.com/stacklok/toolhive/pkg/transport/session" + vmcpsession "github.com/stacklok/toolhive/pkg/vmcp/session" + sessionfactorymocks "github.com/stacklok/toolhive/pkg/vmcp/session/mocks" + sessiontypes "github.com/stacklok/toolhive/pkg/vmcp/session/types" + sessionmocks "github.com/stacklok/toolhive/pkg/vmcp/session/types/mocks" +) + +// deleteBeforeUpdateStorage deletes the key on the first Update call, +// simulating a Terminate / TTL expiry racing with loadSession's write-back. +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 fails the first Update call, simulating a transient +// Redis write failure during loadSession's write-back. +type errorOnUpdateStorage struct { + transportsession.DataStorage + errored bool +} + +func (s *errorOnUpdateStorage) Update(ctx context.Context, id string, metadata map[string]string) (bool, error) { + if !s.errored { + s.errored = true + return false, errors.New("injected Update failure") + } + return s.DataStorage.Update(ctx, id, metadata) +} + +func newRestoredMockSession( + ctrl *gomock.Controller, + sessionID string, + metadata map[string]string, +) *sessionmocks.MockMultiSession { + sess := sessionmocks.NewMockMultiSession(ctrl) + sess.EXPECT().ID().Return(sessionID).AnyTimes() + sess.EXPECT().Type().Return(transportsession.SessionType("")).AnyTimes() + sess.EXPECT().CreatedAt().Return(time.Time{}).AnyTimes() + sess.EXPECT().UpdatedAt().Return(time.Time{}).AnyTimes() + sess.EXPECT().GetData().Return(nil).AnyTimes() + sess.EXPECT().SetData(gomock.Any()).AnyTimes() + sess.EXPECT().GetMetadata().Return(metadata).AnyTimes() + sess.EXPECT().SetMetadata(gomock.Any(), gomock.Any()).AnyTimes() + sess.EXPECT().BackendSessions().Return(nil).AnyTimes() + sess.EXPECT().GetRoutingTable().Return(nil).AnyTimes() + sess.EXPECT().Prompts().Return(nil).AnyTimes() + sess.EXPECT().Tools().Return(nil).AnyTimes() + return sess +} + +func TestSessionManager_LoadSessionPersistsRestoredMetadata(t *testing.T) { + t.Parallel() + + t.Run("restored metadata is written back to storage", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) + sessionID := "restore-metadata-persist-session" + freshMeta := map[string]string{ + sessiontypes.MetadataKeyTokenHash: "", + vmcpsession.MetadataKeyBackendIDs: "backend-a", + vmcpsession.MetadataKeyBackendSessionPrefix + "backend-a": "fresh-session-id", + } + factory.EXPECT(). + RestoreSession(gomock.Any(), sessionID, gomock.Any(), gomock.Any()). + Return(newRestoredMockSession(ctrl, sessionID, freshMeta), nil).Times(1) + + sm, storage := newTestSessionManager(t, factory, newFakeRegistry()) + _, err := storage.Create(context.Background(), sessionID, map[string]string{ + sessiontypes.MetadataKeyTokenHash: "", + vmcpsession.MetadataKeyBackendIDs: "backend-a", + vmcpsession.MetadataKeyBackendSessionPrefix + "backend-a": "stale-session-id", + }) + require.NoError(t, err) + + multiSess, ok := sm.GetMultiSession(sessionID) + require.True(t, ok) + require.NotNil(t, multiSess) + + stored, err := storage.Load(context.Background(), sessionID) + require.NoError(t, err) + assert.Equal(t, freshMeta, stored) + }) + + t.Run("session deleted before write-back is not served", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) + sessionID := "restore-concurrent-delete-session" + restored := newRestoredMockSession(ctrl, sessionID, map[string]string{sessiontypes.MetadataKeyTokenHash: ""}) + restored.EXPECT().Close().Return(nil).Times(1) + factory.EXPECT(). + RestoreSession(gomock.Any(), sessionID, gomock.Any(), gomock.Any()). + Return(restored, nil).Times(1) + + inner := newTestSessionDataStorage(t) + sm, cleanup, err := New(&deleteBeforeUpdateStorage{DataStorage: inner}, &FactoryConfig{Base: factory}, newFakeRegistry(), nil) + require.NoError(t, err) + t.Cleanup(func() { _ = cleanup(context.Background()) }) + + _, err = inner.Create(context.Background(), sessionID, map[string]string{sessiontypes.MetadataKeyTokenHash: ""}) + require.NoError(t, err) + + multiSess, ok := sm.GetMultiSession(sessionID) + assert.False(t, ok) + assert.Nil(t, multiSess) + }) + + t.Run("transient write-back error still serves the session", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) + sessionID := "restore-update-error-session" + factory.EXPECT(). + RestoreSession(gomock.Any(), sessionID, gomock.Any(), gomock.Any()). + Return(newRestoredMockSession(ctrl, sessionID, map[string]string{sessiontypes.MetadataKeyTokenHash: ""}), nil). + Times(1) + + inner := newTestSessionDataStorage(t) + sm, cleanup, err := New(&errorOnUpdateStorage{DataStorage: inner}, &FactoryConfig{Base: factory}, newFakeRegistry(), nil) + require.NoError(t, err) + t.Cleanup(func() { _ = cleanup(context.Background()) }) + + _, err = inner.Create(context.Background(), sessionID, map[string]string{sessiontypes.MetadataKeyTokenHash: ""}) + require.NoError(t, err) + + multiSess, ok := sm.GetMultiSession(sessionID) + assert.True(t, ok) + require.NotNil(t, multiSess) + assert.Equal(t, sessionID, multiSess.ID()) + }) +} 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/session/connector_integration_test.go b/pkg/vmcp/session/connector_integration_test.go index c8d271f901..1a3731e6fb 100644 --- a/pkg/vmcp/session/connector_integration_test.go +++ b/pkg/vmcp/session/connector_integration_test.go @@ -12,6 +12,7 @@ import ( "io" "net/http" "net/http/httptest" + "sync" "sync/atomic" "testing" "time" @@ -713,3 +714,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 3cb8cd649c..dfa9cb126e 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") } @@ -1152,7 +1152,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 8dc0af5d84..c8b35722d8 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) // CapabilityChangeRegistrar is an optional interface implemented by session @@ -287,12 +293,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() @@ -482,6 +489,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 { @@ -502,7 +510,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() @@ -575,7 +583,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 } @@ -626,9 +634,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 97bb5ceda4..4234b73989 100644 --- a/pkg/vmcp/session/internal/backend/mcp_session.go +++ b/pkg/vmcp/session/internal/backend/mcp_session.go @@ -384,11 +384,16 @@ func NewHTTPConnector( ctx context.Context, target *vmcp.BackendTarget, identity *auth.Identity, + sessionHint string, ) (Session, *vmcp.CapabilityList, error) { + // The restore-time session hint is intentionally not forwarded: backends + // that reject unknown Mcp-Session-Id values would fail the restore, and + // lost backend sessions are already recovered through reconnect. return func( ctx context.Context, target *vmcp.BackendTarget, identity *auth.Identity, + _ string, ) (Session, *vmcp.CapabilityList, error) { requestTimeout := requestTimeoutForWorkload( target.WorkloadID, diff --git a/pkg/vmcp/session/token_binding_test.go b/pkg/vmcp/session/token_binding_test.go index 6a1df61f6c..d8c79b1406 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/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 4d7340b857..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", @@ -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", }, }, @@ -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", @@ -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 @@ -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", @@ -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", }, }, @@ -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/types.go b/pkg/vmcp/types.go index 8c55fdf28b..be310819d0 100644 --- a/pkg/vmcp/types.go +++ b/pkg/vmcp/types.go @@ -158,7 +158,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 { @@ -166,8 +166,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: @@ -216,7 +218,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/pkg/vmcp/workloads/k8s.go b/pkg/vmcp/workloads/k8s.go index b73a1bf6e3..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), } @@ -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, @@ -107,14 +107,14 @@ 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) } 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, @@ -123,14 +123,14 @@ 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) } 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, @@ -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 e279014708..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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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/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) 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 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 a40afff0ee..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,21 +34,22 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-audit-enabled namespace: toolhive-system spec: + groupRef: + name: test-audit-group config: - groupRef: test-audit-group audit: enabled: true incomingAuth: type: anonymous - assert: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-audit-enabled @@ -82,19 +83,19 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-no-audit namespace: toolhive-system spec: - config: - groupRef: test-audit-group + groupRef: + name: test-audit-group incomingAuth: type: anonymous - assert: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-no-audit @@ -139,21 +140,22 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-audit-disabled namespace: toolhive-system spec: + groupRef: + name: test-audit-group config: - groupRef: test-audit-group audit: enabled: false incomingAuth: 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 7dfce47b30..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,38 +9,50 @@ spec: try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPGroup metadata: name: test-group spec: description: "Test group for VirtualMCPServer" + - name: create-mcpoidcconfig + try: + - apply: + resource: + apiVersion: toolhive.stacklok.dev/v1beta1 + kind: MCPOIDCConfig + metadata: + name: test-vmcp-oidc + spec: + type: inline + inline: + issuer: https://auth.example.com + - name: create-virtualmcpserver try: - apply: resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 + apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp spec: + groupRef: + name: test-group config: - groupRef: test-group aggregation: 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: 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 d312fc2103..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 @@ -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/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 c229d9713a..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,11 +1,12 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer 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-oidc-config.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-oidc-config.yaml new file mode 100644 index 0000000000..ac4cd0fbb4 --- /dev/null +++ b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/vmcp/vmcp-oidc-config.yaml @@ -0,0 +1,13 @@ +apiVersion: toolhive.stacklok.dev/v1beta1 +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 84c5ee5bbf..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,27 +1,20 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 +apiVersion: toolhive.stacklok.dev/v1beta1 kind: VirtualMCPServer metadata: name: test-vmcp-oidc namespace: toolhive-system spec: + groupRef: + name: test-group-controller config: - groupRef: test-group-controller aggregation: conflictResolution: prefix conflictResolutionConfig: 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/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 72b4ef6879..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 @@ -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 @@ -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 @@ -47,11 +47,11 @@ spec: incomingAuth: type: anonymous serviceType: ClusterIP - config: - groupRef: test-group + groupRef: + 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/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()) }) 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/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() +} diff --git a/test/e2e/thv-operator/acceptance_tests/ratelimit_test.go b/test/e2e/thv-operator/acceptance_tests/ratelimit_test.go index 09a4284c67..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}, }, @@ -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,35 +200,47 @@ 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") - server := &mcpv1alpha1.MCPServer{ + By("Creating MCPOIDCConfig for inline OIDC auth") + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: oidcConfigName, + Namespace: testNamespace, + }, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.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 := &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), }, - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: issuerURL, - Audience: "vmcp-audience", - JWKSAllowPrivateIP: true, - InsecureAllowHTTP: true, - }, + 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}, }, @@ -266,9 +279,13 @@ 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, &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: oidcConfigName, Namespace: testNamespace}, + }) By("Cleaning up OIDC server") if oidcCleanup != nil { oidcCleanup() 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 d68614afda..d91e314b72 100644 --- a/test/e2e/thv-operator/virtualmcp/helpers.go +++ b/test/e2e/thv-operator/virtualmcp/helpers.go @@ -29,11 +29,18 @@ 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" ) +// 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). @@ -44,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{ @@ -55,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{ @@ -169,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, @@ -179,16 +186,16 @@ 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 == groupName { + if mcpServer.Spec.GroupRef.GetName() == groupName { backends = append(backends, mcpServer) } } @@ -201,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, @@ -213,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 @@ -233,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, @@ -627,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, }, } @@ -647,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 @@ -660,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: 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"}, }, }, @@ -681,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, @@ -689,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) @@ -705,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", }, @@ -751,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: backends[idx].GroupRef, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: backends[idx].GroupRef}, Image: backends[idx].Image, Transport: backendTransport, ProxyPort: 8080, @@ -765,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...), }, @@ -776,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, @@ -785,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) } } @@ -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..620ef7760f 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" @@ -24,19 +27,25 @@ 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" +) + +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,16 +255,16 @@ 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) 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, @@ -226,15 +272,15 @@ 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, }, }, })).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{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.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{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: mcpServerName, Namespace: defaultNamespace}, &mcpv1beta1.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,30 +385,30 @@ 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) 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, }, }, })).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,29 +417,29 @@ 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{ + _ = k8sClient.Delete(ctx, &mcpv1beta1.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{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: mcpServerName, Namespace: defaultNamespace}, &mcpv1beta1.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() { 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 } @@ -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/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 534ce5ad2e..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,12 +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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -66,7 +67,7 @@ var _ = Describe("VirtualMCPServer Aggregation Filtering", Ordered, func() { }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -85,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, @@ -95,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, @@ -105,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, @@ -164,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 19bd5d27d5..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,12 +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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -71,7 +72,7 @@ var _ = Describe("VirtualMCPServer Tool Overrides", Ordered, func() { }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -90,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, @@ -99,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, @@ -108,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, @@ -177,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 e933c8ee68..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" ) @@ -692,22 +692,42 @@ with socketserver.TCPServer(("", PORT), OIDCHandler) as httpd: } Expect(k8sClient.Create(ctx, oidcClientSecret)).To(Succeed()) + By("Creating MCPOIDCConfig for OIDC authentication") + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "discovery-oidc-config", + Namespace: testNamespace, + }, + 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: &mcpv1beta1.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) 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", }, @@ -720,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", }, @@ -739,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", }, } @@ -759,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") @@ -769,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, }, }, @@ -778,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, }, }, @@ -825,12 +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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, // No TokenCache configured - tokens should be fetched on each request @@ -840,27 +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", - 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: &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", @@ -957,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, @@ -967,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, @@ -978,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, @@ -999,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, @@ -1011,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, @@ -1032,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")) @@ -1060,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)) @@ -1084,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:") @@ -1423,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()) @@ -1440,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: 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"}, }, }, @@ -1462,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: 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"}, }, }, @@ -1491,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, @@ -1499,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{ - Config: vmcpconfig.Config{Group: 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", @@ -1531,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}, }) }) @@ -1577,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 { @@ -1634,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 { @@ -1673,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, @@ -1681,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, @@ -1710,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 { @@ -1722,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 84b3b0a1ea..87f8f681a4 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_config_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_config_test.go @@ -4,14 +4,14 @@ package virtualmcp import ( + "fmt" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" 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" + 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}, }) }) @@ -39,30 +39,41 @@ 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, &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "authserver-oidc-config", Namespace: testNamespace}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{ + Issuer: "http://localhost:9090", + InsecureAllowHTTP: true, + }, + }, + })).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", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: "http://localhost:9090", - }, + 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), }, }, - Config: vmcpconfig.Config{Group: 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", }, @@ -75,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 5f10ab327f..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: 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: 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,16 +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{ - 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", @@ -218,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, @@ -233,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) } @@ -247,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, @@ -283,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, @@ -292,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] @@ -305,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) } @@ -324,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, @@ -333,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) } @@ -450,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, @@ -480,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 @@ -501,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, @@ -510,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] @@ -523,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) } @@ -543,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, @@ -552,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) } @@ -578,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] @@ -597,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 22ee2ebcc8..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,12 +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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -103,7 +104,7 @@ var _ = Describe("VirtualMCPServer Composite Tool DefaultResults", Ordered, func }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -123,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, @@ -132,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, @@ -141,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 72ed1be84a..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,12 +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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -124,7 +125,7 @@ var _ = Describe("VirtualMCPServer Composite with Hidden Backend Tools", Ordered }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -143,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, @@ -152,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, @@ -161,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, @@ -170,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, @@ -257,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 fe5c275c37..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,12 +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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -107,7 +108,7 @@ var _ = Describe("VirtualMCPServer Composite Parallel Workflow", Ordered, func() }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -126,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, @@ -136,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, @@ -146,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, @@ -223,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, @@ -257,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 fed6093a33..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,12 +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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -118,7 +119,7 @@ var _ = Describe("VirtualMCPServer Composite Referenced Workflow", Ordered, func }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -137,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, @@ -146,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, @@ -155,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, @@ -164,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, @@ -241,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, @@ -257,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, @@ -291,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, @@ -299,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 6eed911d9c..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,12 +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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -92,7 +93,7 @@ var _ = Describe("VirtualMCPServer Composite Sequential Workflow", Ordered, func }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -111,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, @@ -120,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, @@ -129,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, @@ -206,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 573824eab7..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,12 +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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -118,7 +119,7 @@ var _ = Describe("VirtualMCPServer Composite Tool Template Functions", Ordered, }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -137,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, @@ -146,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, @@ -155,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, @@ -164,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, @@ -196,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 d28b60f018..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,17 +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{ + 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", @@ -78,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, @@ -88,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, @@ -98,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, @@ -377,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, @@ -466,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 68c3978951..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: 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: 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,12 +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{ + 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 @@ -133,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", @@ -158,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, @@ -169,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, @@ -179,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, @@ -190,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, @@ -391,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, @@ -423,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, @@ -472,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: mcpGroupName, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.GofetchServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -489,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, @@ -497,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 @@ -594,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, @@ -604,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 2e20c7f675..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,12 +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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -56,7 +57,7 @@ var _ = Describe("VirtualMCPServer Global ExcludeAllTools", Ordered, func() { ExcludeAllTools: true, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -75,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, @@ -85,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, @@ -95,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, @@ -141,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, @@ -161,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 564ac7f04a..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: 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{ - Config: vmcpconfig.Config{Group: 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: 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{ - Config: vmcpconfig.Config{Group: 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: 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{ - Config: vmcpconfig.Config{Group: 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: 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{ - Config: vmcpconfig.Config{Group: 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: 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,26 +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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Operational: &vmcpconfig.OperationalConfig{ @@ -878,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", @@ -901,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{ @@ -928,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, @@ -937,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 @@ -1015,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", }, @@ -1040,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: 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, }, }, @@ -1063,26 +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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Operational: &vmcpconfig.OperationalConfig{ @@ -1093,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", @@ -1113,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{ @@ -1155,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, @@ -1164,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 d8eed32917..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,20 +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{ + 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{ @@ -109,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, @@ -117,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, @@ -125,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, @@ -133,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, @@ -141,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, @@ -201,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, @@ -230,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, @@ -287,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, @@ -312,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 @@ -327,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, @@ -339,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 @@ -391,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] @@ -408,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 5bd875cdd9..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,17 +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{ + 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 @@ -158,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, @@ -167,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, @@ -176,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 5b2f72a09f..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,20 +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{ + 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{ @@ -217,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, @@ -226,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, @@ -236,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, @@ -246,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 26469b1e7e..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,22 +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{ + 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, }, @@ -159,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, @@ -168,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, @@ -177,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, @@ -186,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 new file mode 100644 index 0000000000..51d24964c3 --- /dev/null +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go @@ -0,0 +1,717 @@ +// 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" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "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, &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: 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 := &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 != mcpv1beta1.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, &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, + Replicas: &replicas, + SessionAffinity: "None", + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.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, &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + }) + _ = 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}, &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, + mcpv1beta1.ConditionSessionStorageWarning, "False", + timeout, pollInterval) + }) + + ginkgo.It("Should report Ready=True when Redis is configured", func() { + WaitForCondition(ctx, k8sClient, vmcpName, defaultNamespace, + mcpv1beta1.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, &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: 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 := &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 != mcpv1beta1.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, &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, + Replicas: &replicas, + SessionAffinity: "None", + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.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, &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + }) + _ = 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}, &mcpv1beta1.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, &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: 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 := &mcpv1beta1.MCPServer{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: backendName, Namespace: defaultNamespace}, server); err != nil { + return err + } + if server.Status.Phase != mcpv1beta1.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, &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, + Replicas: &replicas, + ServiceType: "NodePort", + SessionAffinity: "None", + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.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, &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + }) + _ = 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}, &mcpv1beta1.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, &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: 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 := &mcpv1beta1.MCPServer{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: backendName, Namespace: defaultNamespace}, server); err != nil { + return err + } + if server.Status.Phase != mcpv1beta1.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, &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, + Replicas: &replicas, + SessionAffinity: "None", + SessionStorage: &mcpv1beta1.SessionStorageConfig{ + Provider: mcpv1beta1.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, &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, + }) + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: backendName, Namespace: defaultNamespace}, + }) + _ = 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}, &mcpv1beta1.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/virtualmcp_session_management_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_session_management_test.go index 07d47fe7df..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: mcpGroupName, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -76,13 +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{ + 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()) @@ -95,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()) @@ -299,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{ - Config: vmcpconfig.Config{Group: 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}, }) }) @@ -432,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: mcpGroupName, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -449,26 +450,35 @@ 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, &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "session-oidc-config", Namespace: defaultNamespace}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.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{ + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: vmcpName, Namespace: defaultNamespace}, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "oidc", - OIDCConfig: &mcpv1alpha1.OIDCConfigRef{ - Type: "inline", - Inline: &mcpv1alpha1.InlineOIDCConfig{ - Issuer: oidcIssuer, - Audience: "vmcp-audience", - InsecureAllowHTTP: true, - JWKSAllowPrivateIP: true, - ProtectedResourceAllowPrivateIP: true, - }, + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ + Name: "session-oidc-config", + Audience: "vmcp-audience", }, }, ServiceType: "NodePort", @@ -483,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 61f01c6904..c5024b8957 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_telemetry_test.go @@ -14,8 +14,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/yaml" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" - "github.com/stacklok/toolhive/pkg/telemetry" + 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" ) @@ -36,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: 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"}, }, }, @@ -56,46 +55,70 @@ 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 VirtualMCPServer with telemetry config") - vmcp := &mcpv1alpha1.VirtualMCPServer{ + By("Creating MCPTelemetryConfig for shared telemetry") + telCfg := &mcpv1beta1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-telemetry-config", + Namespace: testNamespace, + }, + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + OpenTelemetry: &mcpv1beta1.MCPTelemetryOTelConfig{ + Enabled: true, + Endpoint: "localhost:4317", + 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: &mcpv1beta1.PrometheusConfig{Enabled: true}, + }, + } + Expect(k8sClient.Create(ctx, telCfg)).To(Succeed()) + + // Wait for MCPTelemetryConfig to be reconciled (hash set) + Eventually(func() bool { + fetched := &mcpv1beta1.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 := &mcpv1beta1.VirtualMCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: vmcpServerName, Namespace: testNamespace, }, - Spec: mcpv1alpha1.VirtualMCPServerSpec{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, ServiceType: "NodePort", - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, + TelemetryConfigRef: &mcpv1beta1.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"}, - }, }, }, } @@ -103,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 @@ -119,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, @@ -128,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, @@ -136,8 +159,16 @@ var _ = Describe("VirtualMCPServer Telemetry Config", Ordered, func() { } Expect(k8sClient.Delete(ctx, backend)).To(Succeed()) + By("Cleaning up MCPTelemetryConfig") + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-telemetry-config", + Namespace: testNamespace, + }, + }) + By("Cleaning up MCPGroup") - group := &mcpv1alpha1.MCPGroup{ + group := &mcpv1beta1.MCPGroup{ ObjectMeta: metav1.ObjectMeta{ Name: mcpGroupName, Namespace: testNamespace, @@ -167,18 +198,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 +222,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") }) }) diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_toolconfig_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_toolconfig_test.go index 107d922f74..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,12 +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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -91,7 +92,7 @@ var _ = Describe("VirtualMCPServer Tool Filtering via MCPToolConfig", Ordered, f }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -110,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, @@ -119,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, @@ -129,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, @@ -139,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, @@ -275,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, @@ -300,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, @@ -342,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"}, }, @@ -355,12 +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{ + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Config: vmcpconfig.Config{ Group: mcpGroupName, Aggregation: &vmcpconfig.AggregationConfig{ @@ -375,7 +377,7 @@ var _ = Describe("VirtualMCPServer MCPToolConfig Dynamic Updates", Ordered, func }, }, }, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ Type: "anonymous", }, ServiceType: "NodePort", @@ -394,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, @@ -403,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, @@ -412,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, @@ -421,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, @@ -457,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, @@ -467,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 4273a67cac..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: 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: 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,19 +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{ + 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", @@ -136,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, @@ -146,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, @@ -156,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, @@ -325,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, @@ -349,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)) }) }) @@ -404,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) @@ -422,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 41a56f8872..3549b1e3b8 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcpserver_scaling_test.go @@ -16,14 +16,13 @@ import ( 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" + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/test/e2e/images" ) // 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 +42,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 ) // ------------------------------------------------------------------------- @@ -66,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: mcpGroupName, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -85,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{ - Config: vmcpconfig.Config{Group: 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()) }) @@ -124,27 +122,27 @@ 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)) }) 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)) }) }) @@ -166,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: mcpGroupName, + Spec: mcpv1beta1.MCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, Image: images.YardstickServerImage, Transport: "streamable-http", ProxyPort: 8080, @@ -185,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{ - Config: vmcpconfig.Config{Group: mcpGroupName}, - IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{Type: "anonymous"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + GroupRef: &mcpv1beta1.MCPGroupRef{Name: mcpGroupName}, + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, Replicas: &replicas, ServiceType: "NodePort", }, @@ -200,35 +198,35 @@ 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()) }) 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, - 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 } @@ -251,19 +249,19 @@ 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") 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 } @@ -274,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) }) }) 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") + }) + }) +})