v0.49.0
🚀 Toolhive v0.49.0 is live!
A security- and auth-correctness release: a signer-pin bypass in thv skill upgrade is closed, the embedded auth server's documented zero-downtime key rotation finally works, and AWS STS role claims now fail closed instead of silently handing out the fallback role. This release also ships a dependency-light generated Go client for the management API, and moves the project to Go 1.27.
⚠️ Breaking Changes
pkg/vmcp/session.WithDialControlremoved — vMCP embedders who set a dial-control hook on the session factory get a compile error; wrap the hook in the newWithDialControlResolver(migration guide below).- OAuth2 upstream token-endpoint auth method default reverted — only affects upgrades from v0.48.0: pre-registered
oauth2upstreams with a client secret and no explicittokenEndpointAuthMethodgo back to sending credentials in the POST body instead of HTTP Basic; setclient_secret_basicexplicitly if your IdP requires it (migration guide below). - AWS STS role claims must be a string or a list of strings — object, number, boolean, or null role claims now fail closed with HTTP 403 instead of silently receiving the fallback role, and a bare-string claim now selects its mapped role (migration guide below).
- Root Go module now requires Go 1.27, and
go://workloads default togolang:1.27-alpine— builds pinned to Go 1.26 withGOTOOLCHAIN=localfail, andgo://servers that do not compile under Go 1.27 need an explicit image pin (migration guide below).
Migration guide: session.WithDialControl → session.WithDialControlResolver
Affects Go embedders of vMCP that called session.WithDialControl — the option added in v0.48.0 by #6547. The option was address-blind, so every backend received the same net.Dialer.Control hook and a per-backend dial policy could not be expressed. It is replaced in place rather than deprecated alongside a second option.
On v0.49.0 the old call fails to compile with undefined: session.WithDialControl.
pkg/vmcp/client.WithDialControl is unchanged. Only the pkg/vmcp/session option was renamed — do not migrate client.WithDialControl call sites.
Before
factory := session.NewSessionFactory(registry,
session.WithDialControl(denyPrivateRanges),
)After
factory := session.NewSessionFactory(registry,
// Same hook for every backend — identical to v0.48.0 behavior.
session.WithDialControlResolver(
func(_ string) func(network, address string, c syscall.RawConn) error {
return denyPrivateRanges
},
),
)Per-backend policy — the capability this unlocks. Returning nil for a workload leaves that backend on http.DefaultTransport, byte-for-byte identical to the no-hook path:
session.WithDialControlResolver(
func(workloadID string) func(string, string, syscall.RawConn) error {
if allowsPrivateDialing(workloadID) {
return nil
}
return denyPrivateRanges
},
)Migration steps
- Find every
session.WithDialControl(call site in thepkg/vmcp/sessionpackage — notpkg/vmcp/client, whose identically-named option is unchanged. - Rename it to
session.WithDialControlResolver. - Wrap your existing hook in
func(workloadID string) func(network, address string, c syscall.RawConn) error { return hook }to preserve v0.48.0 semantics exactly. - Optionally branch on
workloadIDto vary policy per backend; returnnilto leave a backend untouched. - Make sure your resolver is goroutine-safe — it is invoked concurrently from the per-backend session-init goroutines. A panicking resolver is recovered per backend and excludes only that backend.
- Rebuild. If you are enforcing SSRF/DNS-rebinding protection, confirm the returned hook still inspects
address— deciding allow/deny fromworkloadIDalone provides no network-level protection.
PR: #6567
Migration guide: OAuth2 upstream tokenEndpointAuthMethod default
Affects anyone on v0.48.0 with a pure oauth2-type upstream provider that uses a pre-registered clientId plus a client secret and leaves tokenEndpointAuthMethod unset.
#6543 (shipped in v0.48.0, and only in v0.48.0) added the token_endpoint_auth_method field, but also made an unset field silently default to client_secret_basic whenever a secret was configured — flipping every existing pre-registered upstream from POST-body credentials to HTTP Basic with no opt-in. v0.49.0 restores the historical default while keeping the new field.
The auth style is strict, not probing: an unset method sends credentials in the token-request POST body and does not retry with Basic. Against a Basic-only IdP the exchange fails with invalid_client — on both initial login and token refresh.
- Upgrading from v0.47.x or earlier → no change; v0.49.0 matches what you already had.
- On v0.48.0 with an IdP that required POST body → v0.48.0 broke you and v0.49.0 fixes it.
- On v0.48.0 with a Basic-only IdP → you must now opt in explicitly.
OIDC-type upstreams and Dynamic Client Registration upstreams are unaffected.
Before
apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPExternalAuthConfig
spec:
type: embeddedAuthServer
embeddedAuthServer:
upstreamProviders:
- name: my-idp
type: oauth2
oauth2Config:
clientId: my-client
clientSecretRef:
name: idp-client-secret
key: client-secret
tokenEndpoint: https://idp.example.com/oauth2/token
# unset -> v0.48.0 silently used client_secret_basicAfter
oauth2Config:
clientId: my-client
clientSecretRef:
name: idp-client-secret
key: client-secret
tokenEndpoint: https://idp.example.com/oauth2/token
tokenEndpointAuthMethod: client_secret_basic # now required to get BasicRaw auth-server run config:
upstreams:
- name: my-idp
type: oauth2
oauth2_config:
client_id: my-client
client_secret_env_var: MY_IDP_CLIENT_SECRET
token_endpoint: https://idp.example.com/oauth2/token
token_endpoint_auth_method: client_secret_basic # add thisMigration steps
- Confirm this applies: you are coming from v0.48.0 and use a pre-registered (non-DCR)
oauth2upstream with a client secret. - Check your IdP's
token_endpoint_auth_methods_supportedin its discovery document, or its client registration. If onlyclient_secret_basicis accepted, act. - Set
tokenEndpointAuthMethod: client_secret_basicon every affectedupstreamProviders[].oauth2Config(spec.embeddedAuthServer.upstreamProviders[]forMCPExternalAuthConfig,spec.authServerConfig.upstreamProviders[]forVirtualMCPServer), ortoken_endpoint_auth_methodunderupstreams[].oauth2_configin a raw run config. - Apply and restart the workload, then verify a full login and a token refresh — refresh uses the same auth style.
- If your IdP accepts either style, or requires the POST body, do nothing.
The CRD schema is unchanged apart from doc text, so there is no CRD upgrade ordering concern.
PR: #6648
Migration guide: AWS STS role claim shapes now fail closed
Affects deployments using an awsSts external auth config with claim-based roleMappings. Matcher-expression-only configurations are unaffected.
Role mappings are evaluated with the CEL expression claim_value in claims[role_claim_key], and CEL's in only has list and map overloads. Two bugs followed: a string role claim raised a swallowed "no such overload" error and silently produced the fallback role even on an exact match, and an object role claim made in test map-key membership, matching spuriously. Both are now corrected, and unsupported shapes fail closed rather than quietly granting a role.
Two behavior changes, both deliberate:
- A bare-string role claim exactly equal to a configured
claimnow selects its mapped role instead offallbackRoleArn. Strings that merely contain the value still do not match. - A role claim that is an object, number, boolean, or null now fails closed — HTTP 403
Failed to determine IAM rolefrom theaws_stsmiddleware, or a failed backend call withfailed to select IAM rolein vMCP outbound auth.
A missing role claim still falls back exactly as before.
Before
{ "sub": "user1", "groups": { "admins": true } }
{ "sub": "user2", "groups": 7 }After
{ "sub": "user1", "groups": ["admins"] }
{ "sub": "user1", "groups": "admins" }Migration steps
- Decode a representative token for each IdP feeding an
awsStsconfig and inspect the claim named byawsSts.roleClaim(defaultgroups). - List of strings → no action, behavior unchanged.
- Bare string → no config change needed, but confirm the outcome is intended: those users now receive the mapped role rather than
fallbackRoleArn. Verify the mapped role's IAM trust policy accepts these subjects and that its permissions suit that population. - Object, number, boolean, or null → change the IdP claim mapping to emit a string or a JSON array of strings (in Keycloak, use a multivalued group/role mapper and flatten nested claims like
realm_access.rolesto a top-level key —roleClaimis a flat lookup, not a dot path). Alternatively pointroleClaimat a correctly-shaped claim, or convert those mappings tomatcherCEL expressions, which are evaluated against the raw claims and are unaffected. - Before rolling out, watch for the new WARN lines
role claim has unsupported shape, failing closedandclaim-based role mapping evaluation failed, failing closed— they name the offendingrole_arn. Note thatCEL expression evaluation failed, skipping mappingwas promoted from Debug to Warn, so pre-existing matcher-expression bugs will now appear at default log level. - In a mixed configuration, re-check priorities: a claim mapping with a lower priority number than a previously-winning matcher mapping now wins for string claims.
Migration guide: Go 1.27 toolchain and go:// builder image
Two separate audiences.
go:// workload users. The default builder image for go:// workloads moved from golang:1.26-alpine to golang:1.27-alpine. Only freshly built go:// workloads with no override are affected. Go's compatibility promise makes a failure unlikely, but a server relying on a removed deprecated API will not compile.
Downstream Go importers of the root module. github.com/stacklok/toolhive now declares go 1.27.0 with no toolchain directive. Under the default GOTOOLCHAIN=auto Go downloads 1.27 transparently; under GOTOOLCHAIN=local, a pinned-toolchain CI, an air-gapped build, or a distro-packaged Go, the build fails hard with go: go.mod requires go >= 1.27. The nested github.com/stacklok/toolhive/sdk/go module deliberately keeps its go 1.26.0 floor and is not affected.
Before
# ~/.toolhive/config.yaml — previously relied on the golang:1.26-alpine default
runtime_configs: {}After
# Pin the previous builder image persistently
runtime_configs:
go:
builder_image: "golang:1.26-alpine"
additional_packages:
- ca-certificates
- gitMigration steps
- For a one-off
go://run, pin per invocation:thv run go://github.com/example/server --runtime-image golang:1.26-alpine. - For a persistent pin, set
runtime_configs.go.builder_imagein~/.toolhive/config.yamlas above.additional_packagesreplaces rather than appends to the built-in["ca-certificates", "git"], so list them explicitly. Only the builder stage is customizable for Go workloads; the runtime stage is alwaysalpine:3.23. - If you import the root module, upgrade your toolchain to Go 1.27+, or keep
GOTOOLCHAIN=autoand allow Go to fetch the toolchain on demand. - If you only need the management API client, depend on
github.com/stacklok/toolhive/sdk/goinstead — it retains thego 1.26.0floor. - In GitHub Actions, point
setup-goat the rootgo-version-file: go.modrather than pinning a version.
PR: #6639
🆕 New Features
- A new
github.com/stacklok/toolhive/sdk/gomodule provides a typed, generated client covering all 77 documented management API operations, with safe default timeout and response-size handling, without pulling in ToolHive's full application dependency graph (#6637). - Cedar policies can now govern the MCP SEP-2640 Skills extension on direct-proxied servers:
skills/getmaps toAction::"get_skill"on the skill's exact URI, andskills/listresponses are filtered to the skills the caller may get — previously both methods were refused outright by default-deny, andskills/listwithout aget_skillpermit now returns an empty list instead of a 403 (#6512). thv ai-plugin push --key <cosign.key>is available again for publishers using automatic local server discovery, now that key-signed plugins can be verified at install time withthv ai-plugin install --public-keyand pinned intoolhive.lock.yamlfor latersync/upgrade; remote or manually configured API URLs must still sign keylessly (#6528).- The embedded auth server and vMCP Redis session storage can now connect to an unauthenticated Redis/Valkey instance by omitting the ACL user configuration, logging a startup
WARNthat names the store so an unintended downgrade stays visible (#6551).
🐛 Bug Fixes
- Security:
thv skill upgrade --allow-signer-changeno longer doubles as unsigned consent — it previously succeeded against an unsigned candidate, silently dropping a signer-pinned skill's recorded identity and rewriting the lock entry asunsigned: true; boththv skill upgradeandthv ai-plugin upgradenow reportfailed [unsigned-rejected]and name theuninstall … --scope projecttheninstall … --scope project --allow-unsignedsequence that records the exception explicitly (#6629). - The auth server's
/.well-known/jwks.jsonnow publishes configured fallback keys alongside the signing key (primary first, de-duplicated bykid), making the documented three-step zero-downtime signing-key rotation actually work instead of a hard cutover that invalidated every outstanding JWT (#6638 — Closes #6451). - Progress notifications that arrived just before a request's final response are no longer silently dropped in the Streamable HTTP proxy — queued
notifications/progressframes are flushed to the SSE stream, in backend order, before the response closes it (#6491 — Closes #6349). - VirtualMCPServer Deployments using
spec.podTemplateSpecno longer get ametadata.generationbump and a spuriousDeploymentUpdatedevent on everystatusReportingIntervaltick, including the 30s default — pod-template drift detection was comparing user-merged label maps for exact equality (#6377 — Fixes #6340). - OAuth error responses from the embedded auth server now preserve Fosite's RFC 6749 error codes and hints (
invalid_client,invalid_grant, …) where a wrapped error could previously degrade to a genericserver_error(#6639).
🧹 Misc
- vMCP session dial control is now resolved per backend workload rather than through a single address-blind hook, so a deployment can enforce a different dial policy for each backend at session initialization (#6567).
- Fixed a missing
miniredisimport that broke typecheck — and therefore every test — inpkg/authserver/runneronmain(#6636). - Fixed the Go SDK verification job, which was installing Go 1.26 for root-module generator tooling that now requires 1.27, and refreshed the stale generated SDK artifacts (#6645).
📦 Dependencies
| Module | Version |
|---|---|
github.com/stacklok/toolhive-core |
v0.0.47 |
Also migrates all Redis call sites from the now-deprecated toolhive-core/redis compatibility facade to redisconn directly (#6646).
👋 Welcome to our newest contributor: @isaacgao4396 🎉
Full commit log
What's Changed
- Generalize vMCP session dial-control into a per-workload resolver by @tgrunnagle in #6567
- Support no-auth Redis for the embedded auth server and vMCP sessions by @tgrunnagle in #6551
- fix(operator): skip no-op VirtualMCPServer Deployment updates by @RaviTharuma in #6377
- fix(authserver): import miniredis in the runner test by @aron-muon in #6636
- Authorize MCP Skills extension requests by @JAORMX in #6512
- Restore capability-gated --key signing on thv ai-plugin push by @samuv in #6528
- Reject unsigned upgrade candidates in both modes by @samuv in #6629
- Upgrade project to Go 1.27 by @JAORMX in #6639
- Add generated Go management API client by @JAORMX in #6637
- Fix dropped progress frames in POST-SSE responses by @isaacgao4396 in #6491
- Normalize string role claims before claim-based role mapping evaluation by @Yanhaoxi in #6306
- fix(sdk): unbreak sdk-verify after the Go 1.27 bump by @aron-muon in #6645
- Publish fallback keys in the authserver JWKS endpoint by @reyortiz3 in #6638
- Restore legacy default for OAuth2 upstream auth method by @jhrozek in #6648
- Bump toolhive-core to v0.0.47 by @reyortiz3 in #6646
- Release v0.49.0 by @toolhive-release-app[bot] in #6649
New Contributors
- @isaacgao4396 made their first contribution in #6491
Full Changelog: v0.48.0...v0.49.0
🔗 Full changelog: v0.48.0...v0.49.0