Skip to content

Release v0.50.0 - #6691

Merged
jhrozek merged 1 commit into
mainfrom
release/v0.50.0
Sep 18, 2026
Merged

jhrozek merged 1 commit into
mainfrom
release/v0.50.0

Conversation

@toolhive-release-app

Copy link
Copy Markdown
Contributor

Release v0.50.0

Version Bump

minor release

Files Updated

  • VERSION
  • deploy/charts/operator-crds/Chart.yaml (path: version)
  • deploy/charts/operator-crds/Chart.yaml (path: appVersion)
  • deploy/charts/operator/Chart.yaml (path: version)
  • deploy/charts/operator/Chart.yaml (path: appVersion)
  • deploy/charts/operator/values.yaml (path: operator.image)
  • deploy/charts/operator/values.yaml (path: operator.toolhiveRunnerImage)
  • deploy/charts/operator/values.yaml (path: operator.vmcpImage)
  • Helm chart docs (via helm-docs)

Next Steps

  1. Review this PR
  2. Merge to main
  3. Release automation will handle the rest

Checklist

  • Version bump is correct
  • All CI checks pass

Release-Triggered-By: jhrozek
@codecov

codecov Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.18%. Comparing base (6eb9398) to head (3cb40ec).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6691      +/-   ##
==========================================
- Coverage   79.24%   79.18%   -0.06%     
==========================================
  Files         792      795       +3     
  Lines       79873    80380     +507     
==========================================
+ Hits        63294    63652     +358     
- Misses      16574    16723     +149     
  Partials        5        5              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jhrozek
jhrozek merged commit 6e873de into main Sep 18, 2026
44 checks passed
@jhrozek
jhrozek deleted the release/v0.50.0 branch September 18, 2026 15:27
@github-actions

Copy link
Copy Markdown
Contributor

📝 Generated release notes for v0.50.0

Auto-generated by the release-notes skill. Review and, if good, apply with:

gh release edit v0.50.0 --notes-file <paste-below>.md
Click to expand release notes

🚀 Toolhive v0.50.0 is live!

A security- and identity-heavy release: two hardening fixes land in the MCP request path and in remote-auth credential persistence, the encrypted secrets store moves to Argon2id, and the embedded authorization server gains a full RFC 8628 device flow, inbound Cross App Access (ID-JAG), and RFC 8707 resource indicators on token requests. Five of these changes need action on upgrade — read the breaking changes below before you roll out.

⚠️ Breaking Changes

  • MCP requests with ambiguous JSON are now rejected — any request body containing duplicate or case-insensitively-equivalent object member names (anywhere, including nested tool arguments) gets 400 / JSON-RPC -32600 before authorization runs; rename colliding keys, there is no opt-out (migration guide below).
  • The encrypted secrets file is irreversibly upgraded on first use — v0.50.0 rewrites it with an Argon2id-derived key and a per-file salt, and a pre-v0.50.0 thv binary cannot read the result; back the file up and stop older local thv processes before upgrading (migration guide below).
  • Remote-auth bearer tokens from the registry now require a secrets provider — thv run for a registry remote server with --remote-auth-bearer-token fails without one, and credentials used on earlier versions may have been persisted in plaintext and should be rotated (migration guide below).
  • First project-scoped plugin installs enforce catalog provenance — an install resolved by registry name now fails closed against the catalog entry's declared signer/issuer/repository/ref/runner constraints, and --allow-unsigned / --public-key no longer override that policy (migration guide below).
  • authserver.Storage gained six required methods — any out-of-tree implementation of pkg/authserver/storage.Storage no longer compiles until the pending device-login and device-confirmation methods are added (migration guide below).
Migration guide: MCP requests with ambiguous JSON are rejected

JSON-RPC bodies are decoded twice on opposite sides of ToolHive's security boundary — once by ToolHive for authorization, audit, telemetry and tool filtering, and again by the backend MCP server, often by a different JSON library. RFC 8259 leaves duplicate-member handling implementation-defined, so the two decoders can disagree about what the request actually says. A client could send {"name":"read_public","Name":"read_secret"} and have ToolHive authorize read_public while the backend executed read_secret, slipping a denied tool call, resource read or method past policy.

ToolHive now re-tokenizes the whole request body in the MCP parser middleware and rejects it if any object at any depth contains two members whose names are equal under Unicode simple case folding. The check runs before authorization, and the backend never sees the request.

Who is affected: MCP clients that legitimately send case-distinct keys — most realistically a tool whose arguments carry a free-form string-keyed map, such as HTTP headers containing both X-Trace-Id and x-trace-id, a Windows environment map with Path and PATH, or label/tag/query-parameter maps. Mutating webhooks that republish a body with colliding members are also affected. Folding is Unicode-wide, so K/K (Kelvin sign), s/ſ, and Σ/σ/ς collide too.

Before

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "fetch",
    "arguments": {
      "headers": { "X-Trace-Id": "abc", "x-trace-id": "def" }
    }
  }
}

Accepted and forwarded to the backend.

After

{ "jsonrpc": "2.0", "error": { "code": -32600, "message": "Invalid Request" } }

Returned with HTTP 400. Note that id is deliberately omitted, so a client correlating strictly by request ID will surface this as a transport error rather than a per-request rejection. A mutating webhook that produces an ambiguous body fails with HTTP 500 instead.

Migration steps

  1. Audit any tool whose input schema accepts a free-form object (headers, env, labels, tags, variables) for call sites that can emit two keys differing only in case.
  2. Normalize those keys client-side before calling — pick one casing per key.
  3. Check mutating webhooks that patch request bodies: a patch that introduces a member already present under a different casing now fails the request closed.
  4. If you hit an unexpected -32600 Invalid Request, log the outbound body and look for a case-fold collision; there is no configuration flag to relax the check.

Merged as a security fix from a private fork — commit 6cec543e

Migration guide: encrypted secrets file upgraded to Argon2id

The encrypted secrets provider previously derived its AES-256-GCM key with a single unsalted sha256.Sum256 of the password, so guessing a password against a stolen secrets_encrypted file cost one hash per attempt. Key derivation now uses Argon2id at the OWASP floor (19 MiB, 2 iterations, 1 lane) over a per-file 16-byte random salt carried in a new file header — roughly 427,000× more work per guess, and 19 MiB of memory per guess, which is what denies cheap GPU parallelism.

Legacy files are detected by the absent magic prefix, read with the old key, and rewritten in the new format when the file is opened — including by read-only commands like thv secret list. There is no downgrade path and ToolHive keeps no backup of the pre-migration file.

Who is affected: anyone using the encrypted secrets provider (the default local recommendation). Not affected: 1password, environment, and every Kubernetes deployment — the operator injects TOOLHIVE_SECRETS_PROVIDER=environment and uses Kubernetes Secrets.

Before

~/.local/share/toolhive/secrets_encrypted
└── <AES-256-GCM ciphertext>          # key = sha256(password), no salt

After

~/.local/share/toolhive/secrets_encrypted
└── THVSEC\x01<16-byte salt><AES-256-GCM ciphertext>   # key = Argon2id(password, salt)

Migration steps

  1. Back up the file before upgrading — this is the only rollback path:
    # Linux
    cp ~/.local/share/toolhive/secrets_encrypted ~/secrets_encrypted.v0.49.bak
    # macOS
    cp ~/Library/Application\ Support/toolhive/secrets_encrypted ~/secrets_encrypted.v0.49.bak
    # Windows
    copy %LOCALAPPDATA%\toolhive\secrets_encrypted secrets_encrypted.v0.49.bak
    Protect the backup as carefully as the original: it retains the weak unsalted derivation, and because the password is unchanged, a password cracked from the backup also opens the migrated file. Delete it once the upgrade is settled.
  2. Stop every local ToolHive process still on the old binary, using the old binary: thv stop --all, plus any running thv serve or detached proxy. Migration happens the first time a v0.50.0 binary opens the store, and older processes lose secret access at their next provider construction.
  3. Upgrade, then trigger the migration deliberately: thv secret list.
  4. Restart your workloads on the new binary: thv restart --all.

If you skip step 2: an older thv serve starts failing secrets API calls, and a detached proxy that persists OAuth refresh tokens can stop reading or writing them. Secrets already injected into running containers are unaffected. Restart those processes on the new binary to recover.

If you roll back to v0.49.0: every secrets operation fails with "the password is incorrect or the secrets file has been corrupted", and the on-screen advice to delete the file and re-run thv secret setup will destroy your secrets — restore the backup from step 1 instead.

Go consumers: NewEncryptedManager(filePath string, password []byte) keeps its signature but now takes the raw password rather than a pre-derived key. Code still passing sha256.Sum256(password) compiles and then silently fails to decrypt. Two new sentinels are exported: secrets.ErrMalformedSecretsFile and secrets.ErrDecryptionFailed.

PR: #6657

Migration guide: remote-auth credentials are no longer persisted in plaintext

RunConfig.WithSecrets resolved RemoteAuthConfig.ClientSecret and .BearerToken from the secrets manager and overwrote the exported, JSON-serialized fields in place. The OAuth refresh-token and DCR-credential persistence callbacks then re-serialized the whole config, so the first persistence event rewrote the on-disk run config with the plaintext credential, defeating the secrets manager. Separately, the registry-metadata path assigned a bearer token straight to authCfg.BearerToken without going through ProcessSecret, so plaintext reached the initial SaveState too.

Resolved credentials are now held in runtime-only unexported fields, and the persisted config retains only the NAME,target=... secret reference. Authentication behavior is unchanged, and the serialization format is unchanged in both directions — old configs read fine on v0.50.0 and v0.50.0 configs read fine on older binaries.

Who is affected by the break: users running a remote MCP server from the registry with bearer-token auth (--remote-auth-bearer-token, --remote-auth-bearer-token-file, or $TOOLHIVE_REMOTE_AUTH_BEARER_TOKEN) and no secrets provider configured. thv run now fails at flag processing where it previously succeeded by inlining the plaintext. This brings the registry path in line with the direct-URL path, which already had the requirement.

Before

# No secrets provider configured — previously worked, inlined the token in plaintext
thv run --remote-auth-bearer-token "$TOKEN" some-registry-server

After

thv secret setup                     # once
thv run --remote-auth-bearer-token "$TOKEN" some-registry-server

# or, without a keyring:
export TOOLHIVE_SECRETS_PROVIDER=environment
export TOOLHIVE_SECRET_MYTOKEN="$TOKEN"
thv run --remote-auth-bearer-token "MYTOKEN,target=bearer_token" some-registry-server

Migration steps

  1. Rotate any OAuth client secret or bearer token used with a remote MCP workload on v0.49.0 or earlier — treat it as having been written to disk in plaintext.
  2. Inspect your state files at $XDG_STATE_HOME/toolhive/runconfigs/<workload>.json (default ~/.local/state/toolhive/runconfigs/) for client_secret / bearer_token values that are not in NAME,target=... form.
  3. Scrub backups, snapshots and support bundles taken before the upgrade — re-running the workload on v0.50.0 rewrites the live file with references, but earlier copies still contain the plaintext.
  4. Configure a secrets provider (thv secret setup, or TOOLHIVE_SECRETS_PROVIDER=environment) if you were relying on the registry bearer-token path without one.
  5. Re-run affected workloads on v0.50.0 to produce a clean state file.

Merged as a security fix from a private fork — commit 440331e9

Migration guide: plugin installs enforce catalog-declared provenance

Plugin trust was weaker than skill trust: catalog provenance was ignored on first install, so trust-on-first-use recorded whatever signature it happened to see. Provenance declared by a catalog entry is now carried from registry search into install-time verification and enforced before TOFU is recorded.

Who is affected: thv ai-plugin install <registry-name> into a project (--scope project) for a plugin with no existing lock entry and whose catalog entry declares provenance. Installs by full OCI reference, user-scoped installs, and any plugin already present in toolhive.lock.yaml (including legacy entries with no trust state) are unaffected — existing lock entries remain authoritative.

Three cases that succeeded on v0.49.0 can now fail:

Case Result
Catalog declares a constraint ToolHive cannot verify (sigstore_url, attestation) 422, fails closed
Unsigned or key-signed artifact where the catalog declares signer_identity / cert_issuer / repository_uri / repository_ref / runner_environment — --allow-unsigned no longer bypasses this 403
--public-key used against a certificate-shaped catalog policy 403

A signature that verifies but whose identity does not match the declared constraint also now fails with 403. Rejected installs leave no database record and no materialized files.

Before

# First project install, catalog declares a signer identity, artifact unsigned
thv ai-plugin install --scope project --allow-unsigned my-plugin
# → installed, TOFU recorded

After

thv ai-plugin install --scope project --allow-unsigned my-plugin
# → 403: plugin is unsigned but its catalog entry requires verified provenance

Migration steps

  1. Fix the artifact so it satisfies the catalog's declared constraint — this is the intended path.
  2. If you need to unblock immediately, install by full OCI reference (thv ai-plugin install ghcr.io/<ns>/<name>:<tag>), which carries no catalog provenance, or install user-scoped, which does not verify.
  3. For an entry declaring an unsupported sigstore_url or attestation constraint there is no client-side bypass — the catalog entry must be corrected.
  4. thv ai-plugin sync in an already-locked project is unaffected and keeps working.

PR: #6653 — Closes #6643

Migration guide: authserver.Storage gained six required methods

The device authorization grant added two new storage capabilities that are embedded into the composite Storage interface in pkg/authserver/storage/types.go, so they are required unconditionally — even with device flow disabled.

Who is affected: anyone outside this repo with a custom pkg/authserver/storage.Storage implementation. In-tree implementations (MemoryStorage, RedisStorage, and the CIMD/SPIFFE decorators, which embed Storage) are unaffected.

Before

type Storage interface {
    // ... existing capabilities
    PendingAuthorizationStorage
}

After

type Storage interface {
    // ... existing capabilities
    PendingAuthorizationStorage
    PendingDeviceLoginStorage        // Store/Load/DeletePendingDeviceLogin
    PendingDeviceConfirmationStorage // Store/Load/DeletePendingDeviceConfirmation
}

Migration steps

  1. Implement the six new methods, using MemoryStorage in pkg/authserver/storage/memory.go as the reference. Honor DefaultDeviceLoginTTL (10 minutes) and the existing ErrNotFound / ErrExpired contracts.
  2. Implement the separate DeviceCodeStorage interface only if you intend to set device_flow_enabled — it is reached by type assertion at construction time, and NewHandler returns a hard error if the flag is set and the backend does not implement it.
  3. Note the new storage.ErrInvalidState, returned by MarkDeviceRequestAuthorized / MarkDeviceRequestDenied when the request is not pending.
  4. Unrelated Go API break in the same area: registration.ValidatePublicResponseTypes changed from (responseTypes []string) to (responseTypes, grantTypes []string).

PRs: #6647, #6682

🆕 New Features

  • The embedded authorization server now supports the RFC 8628 Device Authorization Grant end to end — device/user code issuance, a browser verification page with an explicit approve/deny step, and DCR registration of device-code clients — opt-in via device_flow_enabled, off by default (#6647, #6682).
  • A ToolHive-fronted MCP server can now be the resource side of Cross App Access: the token endpoint accepts IdP-minted ID-JAG assertions (typ: oauth-id-jag+jwt) under the existing per-issuer jwt_bearer policy, with client authentication always required and the assertion's client_id bound to the redeeming client (#6677, closes #6676).
  • Upstream providers accept additionalTokenParams on oidcConfig and oauth2Config, so ToolHive works with authorization servers that enforce RFC 8707 resource indicators on the token request body and not just the authorize URL (#6430).
  • Virtual MCP gains operational.listChanged to exclude specific backends from list_changed subscription, and operational.timeouts.backendInit to cap how long session initialization waits for a single backend — so a backend that accepts the notification stream and never services it can no longer stall the handshake (#6633).
  • thv skill upgrade --allow-signer-change --public-key <path> can re-anchor a key-pinned OCI skill, and thv skill sync --adopt --public-key <path> can adopt an installed key-signed skill, with lock updates now transactional and compare-and-swap protected (#6662, part of #6640).
  • The same key-rotation workflow is available for plugins via thv ai-plugin upgrade and thv ai-plugin sync --adopt (#6663, closes #6640).
  • Plugin installs resolved from the registry now verify catalog-declared signer, issuer, repository, ref and runner provenance before trust is recorded, closing the parity gap with skills — see the breaking-change note above (#6653, closes #6643).
  • The encrypted secrets store derives its key with Argon2id over a per-file salt instead of an unsalted SHA-256, hardening a stolen secrets file against offline guessing — see the breaking-change note above (#6657).

🐛 Bug Fixes

  • A transient JWKS or network failure during ID-token verification after an upstream refresh no longer kills the session: verification is retried in place instead of replaying the already-consumed rotating refresh token, and the previously validated ID token and subject are retained if keys still cannot be fetched — permanent failures such as a bad signature or subject mismatch still fail closed (#6559, fixes #6194).
  • Virtual MCP no longer drops a backend entirely when it advertises the resources or prompts capability but answers the matching list method with -32601 Method not found (seen with Atlassian Jira/Rovo); the backend's remaining tools keep aggregating and a warning is logged instead. Operators who alerted on backend health for this condition should alert on the new warning log (#6660, related to #6339 and #5231).
  • MCPServer and MCPRemoteProxy pods now receive the operator's default Redis password alongside the default address, so they no longer crash-loop with NOAUTH Authentication required against an authenticated Redis/Valkey. Upgrade note: if you set operator.defaultRedis.existingSecret / global.redis.existingSecret, make sure that Secret exists in every namespace where MCPServer or MCPRemoteProxy workloads run before upgrading — the injected secretKeyRef is not optional, and pods will fail to start if it is missing (#6679).
  • Fixed a data race that turned the whole pkg/vmcp/server test binary red on main by moving the version poll interval from a package-level variable onto Server (#6644).

🧹 Misc

  • All 31 in-repo workflow references moved from workspace-relative uses: ./... to GitHub's $/... self-repository syntax, so a step running untrusted PR code can no longer substitute a workflow or composite action that a later step executes (#6654).
  • The operator guide and docs/authz.md now document the opaque-access-token claim fallback alongside the session-less one — the normal state for Google and GitHub upstreams, previously described as a single edge case, which could lead operators to write Cedar policies assuming claims were upstream-asserted when they were not (#6656).
  • Documented that cosign key-pair signing is a weaker trust tier than keyless, covering missing certificate identity, transparency-log evidence, signing time, revocation signals and recovery after key compromise (#6661, closes #6641).
  • Nine stale documentation paths now point at code that exists, mostly packages that moved to toolhive-core (#6650, closes #6387).
  • pkg/authserver/storage adds a presence-gated UpdateDCRCredentialsIfPresent write path for DCR credentials on both the memory and Redis backends, with CAS semantics on Redis (#6674, closes #6673).
  • buildProvider was split into buildJWTBearerFactories and buildDeviceFlowFactory to bring its cyclomatic complexity back under the lint threshold (#6680).
  • Renovate dependency groups simplified into smaller chunks that are easier to review and merge (#6658).
  • The Anthropic gateway base URL in the Claude workflows moved to a repo-level variable so it can be repointed in one place (#6672).

📦 Dependencies

Module Version
k8s.io/api, k8s.io/apimachinery, k8s.io/client-go, k8s.io/apiextensions-apiserver v0.35.x → v0.37.0
sigs.k8s.io/controller-runtime v0.23.3 → v0.25.0
sigs.k8s.io/structured-merge-diff/v6 → v6.4.2
k8s.io/kube-openapi, k8s.io/utils bumped
github.com/lestrrat-go/jwx/v3 → github.com/lestrrat-go/jwx/v4 v3.3.0 → v4.5.0
github.com/jwx-go/jwkfetch/v4 v4.0.4 (new)
github.com/cenkalti/backoff/v5 → github.com/cenkalti/backoff/v7 v5.0.3 → v7.0.0
github.com/getsentry/sentry-go/otel v0.44.1 → v0.49.0
github.com/getsentry/sentry-go/otel/otlp v0.49.0 (new)
github.com/charmbracelet/bubbletea → charm.land/bubbletea/v2 v1.3.10 → v2.0.9
github.com/charmbracelet/bubbles → charm.land/bubbles/v2 v1.0.0 → v2.2.1
github.com/charmbracelet/lipgloss → charm.land/lipgloss/v2 v1.1.0 → v2.0.6
github.com/stacklok/toolhive-core v0.0.47 → v0.0.49
github.com/modelcontextprotocol/go-sdk v1.7.0 → v1.8.0
Helm (CI and chart publishing) v3.22.0 → v4.3.0

Notes for Go consumers: importing ToolHive as a library now requires controller-runtime v0.25.x and client-go v0.37.x. controller-runtime v0.25 dropped sigs.k8s.io/controller-runtime/pkg/scheme, so v1alpha1.SchemeBuilder / v1beta1.SchemeBuilder are no longer of type *scheme.Builder — calling AddToScheme, Register(...) or Build() still compiles, but code that names the type does not. The published Helm charts are unchanged and still install with Helm 3.10+; the Helm 4 bump is CI and publishing only. Finally, session.WithBackendInitTimeout in pkg/vmcp/session is now a hard cap that WithRequestTimeoutResolver can no longer raise — ToolHive's own defaults and YAML behavior are unchanged.

👋 Welcome to our newest contributors: @siddiqueirshad, @hellouz818 🎉

Full commit log

What's Changed

New Contributors

Full Changelog: v0.49.0...v0.50.0

🔗 Full changelog: v0.49.0...v0.50.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release size/XS Extra small PR: < 100 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants