Repository navigation
Conversation
Usage metrics only reported a daily tool-call count, which cannot answer which transports, runtimes, registries, clients or vMCP features people actually use. Each report now carries a low-cardinality profile of the server it came from, and the call count is split by normalized MCP client. - Proxies report transport, server kind, registry source, runtime, auth mode and authz. Built-in catalog servers report their entry name; any other server is reported as "custom". Raw names, images, URLs and client strings are never sent. - vMCP reports under its own component with backend and tool counts and enabled features, and sends the version check, including in Kubernetes where the file-backed checker cannot write. - The operator passes its instance ID to the workloads it deploys, so their reports can be grouped per installation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #7145 +/- ##
==========================================
+ Coverage 81.26% 81.30% +0.03%
==========================================
Files 881 886 +5
Lines 89995 90354 +359
==========================================
+ Hits 73136 73458 +322
- Misses 16854 16891 +37
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Test that startUsageMetrics and checkForUpdates do nothing when update checks are skipped, and that the vMCP middleware counts only tool calls. The enabled paths send to the production update service and stay untested here; they were validated against a local update service. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vMCP read the usage metrics opt-out through DefaultProvider.GetConfig, which exits the process when the ToolHive config cannot be loaded, so a broken or unwritable config stopped vMCP outside Kubernetes. It now loads the config with an error return and sends nothing when it cannot tell whether the user opted out. The operator gave up on its instance ID after one failed read. Workload pod templates carry that ID, so a transient API error at startup rolled out every workload without it, and the next healthy restart rolled them out again. The lookup now retries for about 15 seconds, and a ConfigMap holding no usable ID gets one stored, instead of the leader's update check generating a different ID later. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vMCP's version check in Kubernetes used a new random ID per process, so every pod restart looked like a new installation. It now uses the operator's TOOLHIVE_ANONYMOUS_ID when set; the update service records the component, so vMCP checks stay separate from the operator's. Run locally, vMCP printed "A new version of ToolHive is available" into its server logs. The update checker gains a WithoutNotice option, which vMCP uses; the check is still reported and throttled. tool_count was described as the advertised tool count. It is the number of backend tools in the most recent aggregation, excluding composite and optimizer tools and not filtered per user; the docs now say so. The usage middleware in vMCP's handler chain gets its own comment instead of sitting under the parsing middleware's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pkg/vmcp is an importable library, but only ToolHive's own entry points start the usage metrics collector and the update check. Embedders had no way to tell whether that was deliberate or whether they were expected to set UsageMetricsMiddleware. The library docs and the field comments now say an embedded vMCP reports nothing and that embedders should leave the field unset, since reports would reach ToolHive under opt-outs the embedding product's users have not been told about. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A check within the update interval reused the cached latest version and announced it directly, so a vMCP restart within 30 minutes of the last check still printed the new-version notice. Route that path through the same notify method as a fresh check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vMCP loaded the thv config through LoadOrCreateConfig to read the usage metrics opt-out. That creates a missing config file and writes backward compatibility migrations back, so a vMCP binary newer than the thv sharing the file could rewrite that thv's config just to read one flag. Read the file directly instead: a missing file means no opt-out, and a file that cannot be read or parsed still means nothing is sent. In Kubernetes nothing is read, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each local proxy is its own thv process and looked up its registry entry by parsing the whole embedded catalog, about 20 MB of allocations per process. Decode only the server names instead (about 7 KB), with a test pinning the result to the full parser's name set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The session-to-client map was cleared completely when it filled up, so long-lived Legacy sessions, which never send initialize again, were reported as unknown for the rest of their life. Use an LRU cache so only the least recently used session is forgotten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The usage metrics client read the anonymous ID once. A standalone vMCP on a fresh machine, or in a container without persistent data, creates the ID in its update check afterwards and so never reported. Outside Kubernetes, read the ID again while it is empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chart documents TOOLHIVE_SKIP_UPDATE_CHECK on the operator as turning off update checks and usage metrics, but workloads never received it, so newly reporting vMCP pods would send data for operators that opted out. An opted-out operator now sets TOOLHIVE_SKIP_UPDATE_CHECK=true on the workloads it deploys, unless the workload sets it already. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Anonymous usage metrics today report one number per proxy per day: the tool-call count. That cannot answer the product questions we keep asking: which transports and runtimes people use, how much of the built-in registry is actually run, which MCP clients drive traffic, and whether anyone runs vMCP at all (vMCP sends nothing today). This PR adds a small, fixed-vocabulary profile to each report so those questions become answerable, while keeping cardinality low and never sending names, URLs or images of private servers.
/api/v1/toolcountreport now carriestransport,server_kind(container/remote),registry_source(builtin,custom_remote,custom_api,custom_local,none),catalog_server,runtime,auth_modeandauthz. Every value is checked against an allowed list; anything else becomesother.catalog_serveris the entry name only when the server came from the registry embedded inthv; every other server reportscustom. The check lives incmd/thv, sothv-proxyrunnerdoes not embed the 2.8 MB catalog. In Kubernetes it is therefore alwayscustomor absent. Each local proxy is its ownthvprocess, so the check decodes only the catalog's server names (about 7 KB of allocations instead of about 20 MB for a full parse); a test pins the result to the full parser's names.calls_by_clientsplits the existing count by MCP client, normalized to a fixed set (claude-code,cursor,vscode, …,other,unknown). Legacy sessions are attributed throughinitializeand the session ID (header or SSE query parameter, an LRU cache of 4096 sessions, so a burst of new sessions forgets only the least recently used one). Modern stateless requests use their per-requestclientInfo. The raw client name never leaves the process.vmcp servenow runs the same collector under componentvmcpwithbackend_count,tool_count(backend tools in the most recent aggregation; excludes composite and optimizer tools, not filtered per user) andfeatures(optimizer,code_mode,composite_tools,outgoing_auth), and sends the version check. In Kubernetes the pod has a read-only root filesystem, so the version ping is sent directly instead of through the file-backed checker, with the operator'sTOOLHIVE_ANONYMOUS_IDwhen set (else a per-process ID). Run locally, vMCP does not print the new-version notice. For the opt-out, vMCP only reads the thv config file: it never creates or rewrites it, so a vMCP newer than the thv sharing the file cannot migrate that thv's config. A missing file means no opt-out; a file it cannot read or parse means it sends nothing instead of exiting. Onlythv vmcp serveand thevmcpbinary report; a vMCP embedded as a Go library (server.New/server.Serve, as brood-box does) sends no usage metrics and no update checks, and the library docs tell embedders to leaveUsageMetricsMiddlewareunset.TOOLHIVE_ANONYMOUS_IDinto MCPServer, MCPRemoteProxy and VirtualMCPServer workloads, so reports from one installation can be grouped. Usage metrics prefer that variable over the local ID. An operator opted out withTOOLHIVE_SKIP_UPDATE_CHECK=truepasses that variable to its workloads instead, so their proxies and vMCP send no update checks or usage reports, as the chart already documents; a value set on the workload wins. Outside Kubernetes, a client whose anonymous ID did not exist yet at startup (a fresh machine, or a vMCP container without persistent data) reads it again on later flushes instead of never reporting.The update-service decodes without
DisallowUnknownFields, so the new fields are accepted and dropped until it stores them; that follows in update-service and infra.Type of change
Test plan
task test)task lint-fixis broken on Go 1.27)Ran the real update-service locally behind a logging proxy and pointed patched builds at it. The only patch was the endpoint URL and a 20-second flush interval.
First run, during development:
thv: built-intimeserver with three clients →registry_source: builtin,catalog_server: io.github.stacklok/time, calls splitclaude-code3 /cursor2 /other1. SSE proxy attribution through the session query parameter. Remote URL →remote, runtimenone. Direct image → registrynone. Authz config →authz: on.acme/private-timefrom a local registry file →custom_local/custom; zero occurrences ofacmein requests or stored rows.TOOLHIVE_USAGE_METRICS_ENABLED=falseandthv config usage-metrics disableboth send nothing.thv vmcp serve: with optimizer → 8 backends, 17 tools,["optimizer"]; per-client split including a hand-built Modern (2026-07-28) request.runtime: kubernetes; vMCP reportscode_modeandoutgoing_auth, 2 backends, 4 tools, and its version ping. Restarting the operator caused no workload rollout (generation stayed at 1).200from the current update-service.Live re-run on
1b2582b33, a fresh build, against update-service with stacklok/update-service#476. Every report went through a logging proxy, and each stored record was compared with its request:time, streamable HTTPbuiltin,io.github.stacklok/time,docker;claude-code 3, cursor 2, other 1, toolhive 3time, SSE proxy modevscode 2, goose 1timewith authzauthz: on;codex 2custom_local,custom;windsurf 2none,custom;zed 1remote, runtimenone;claude-desktop 1thv vmcp serve --optimizervmcp, 6 backends, 13 tools,["optimizer"];claude-code 3, gemini-cli 2, cline 1, jetbrains 2thv vmcp serveroo-code 2, jetbrains 1(thejetbrainscall was a stateless 2026-07-28 request)200, 0 mismatches between sent and stored fields. Version checks from the CLI and from vMCP both returned200.acme,private-time, the free-text client names, the remote host, tool names and the image name appear 0 times in requests and stored records.toolhive 3on the built-in server is the backend side of calls routed through vMCP.tools/callcounts when it arrives, before the server answers, as the proxy middleware onmainalready does. vMCP rejected the twojetbrainsrequests on the optimizer instance, and they still count.Downstream: the 486 records stored across the replay and the live run went through Fluent Bit 4.0.3 with the production config into a local S3 server, unchanged except the
datefield Fluent Bit already adds. Trino'sOPENX_JSONreader read all 486 with the column types from stacklok/infra#6286. Athena itself was not tested. The infra PR has the read-only check to run after release.Live re-run on
066155a5d, the startup hardening and version-check changes. The build was stampedv0.1.0, so update-service reported a newer release.e9753869c,1e99f5098and2de5a3010came after this run. The first changes only comments and docs. The second is the fix this run found. The third makes vMCP read the thv config without creating or migrating it; a table test against real files checks each file is left byte-for-byte unchanged, including a legacy config the old loader rewrote.vmcp, unparseable ToolHiveconfig.yamlvmcp,disable_usage_metrics: truevmcp, working configthvitselfthv vmcp serverestarted within 30 minutes of the last check1e99f5098; after the fix onlythv's own notice prints.kind cluster on
066155a5d(operator, proxy runner and vMCP images built from the branch):fetch,osvand the VirtualMCPServer carry it at generation 1operator-proxy. The next healthy start rolled them back with the same ID, which is the documented double rollout.claude-code: 2withcode_mode,outgoing_auth, 2 backends and 4 tools; thefetchbackend counted the same calls undertoolhiveScenario run on
7522e44b9, against update-service #476 and a v0.65.0 baseline built from the tag with the same endpoint patch. All eight scenarios passed:config.yamlbyte-identical; updates file validthv vmcp serveor standalonevmcpvmcp; no new-version notice on fresh or cached checks;config.yamlnever created or touchedCI=true311834661and the three commits before it came after this run. They change the opt-out passed by an opted-out operator, the catalog name lookup, session eviction and late anonymous IDs. All four are covered by unit tests run with the race detector, but not by a live run.API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.Changes
pkg/usagemetricsProfiletype with allowed values, per-client counts, client normalization, catalog lookup hook, collector options,TOOLHIVE_ANONYMOUS_IDoverridepkg/runner/usage_metrics.goRunConfig.UsageMetricsProfile()pkg/registry/catalog_names.go,cmd/thv/main.gothvpkg/container/docker/client.goRuntimeType()accessor for the runtime fieldpkg/vmcp/cli/usage_metrics.go,serve.gopkg/vmcp/serverUsageMetricsMiddlewareonConfig/ServerConfig, applied after MCP parsingpkg/operator/telemetry,cmd/thv-operatordocs/middleware.md,docs/arch/02-core-concepts.md,docs/arch/vmcp-library.mdDoes this introduce a user-facing change?
Yes, for anyone with usage metrics enabled (the default; opt-out unchanged).
{count, timestamp}once per 15 minutes and at midnight; vMCP sent nothing.catalog_server(the built-in registry entry name, otherwisecustom) andcalls_by_client(normalized client names with counts). vMCP sends version checks and usage reports under componentvmcp. Workloads deployed by the operator get aTOOLHIVE_ANONYMOUS_IDenv var.Never sent: tool names, private server names, URLs, images, namespaces, raw client names, error messages. Documented in
docs/middleware.md.Special notes for reviewers
remote-proxyorigin. Remote proxies run both locally and under the operator, so a new User-Agent component would break the existing local/operator split.server_kind: remotecarries that instead.anonymous_id = "operator-proxy". Reusing the operator's existing ID (stored in its telemetry ConfigMap and already sent with its version check) splits that single global bucket into one per cluster. That makes it possible to count Kubernetes installations, see how many servers each runs, and join usage to the operator's version.v_toolcount_dailygroups the cumulativecountbyproxy_id, which is the per-processX-Instance-IDand is unchanged here, so sharinganonymous_idacross a cluster's workloads cannot merge their counts. A dashboard panel that filters onanonymous_id = 'operator-proxy'would stop matching; none exists in the infra repo, but panels created only in the Grafana UI should be checked.origin = 'vmcp'(confirmed against update-service). Proxy reports stay the measure of tool calls that actually run on a server. A call vMCP forwards is counted by vMCP under the real client and again by the backend proxy under clienttoolhive, and the two do not map one to one (a composite tool is several backend calls, the optimizer'sfind_toolis none). The main usage dashboard filtersorigin IN ('local','operator'), so its totals are unchanged. The raw viewsv_toolcount_dailyandv_proxy_lifetime*do not filter on origin: summing them double counts vMCP traffic and lists each vMCP process as a proxy. The infra follow-up filters those views by origin.operator-proxy, while workloads that already carry the ID are rolled out without it and rolled again on the next successful start. A ConfigMap without a usable ID gets one stored at startup, so the leader's update check never replaces it with a different ID later. TurningTOOLHIVE_SKIP_UPDATE_CHECKon or off on the operator also rolls those workloads once. For operators already opted out, the first upgrade addsTOOLHIVE_SKIP_UPDATE_CHECK=trueto their workloads, which is one rollout and stops the usage reports their proxies sent before despite the opt-out.server.NewrebuildsConfigthroughServerConfig, so the new middleware field is mapped in bothbuildServeConfigandderiveServerConfig; without that it was silently dropped (caught in manual testing).originwill not see vMCP rows until vMCP panels are added. update-service stores the new fields in stacklok/update-service#476; the Athena columns, the origin filter on the proxy views and the dashboards follow in infra.🤖 Generated with Claude Code