Skip to content

fix(telemetry): record real mcp_client label instead of 'unknown' (extension WS dispatcher leak)#57

Merged
LakshmanTurlapati merged 1 commit into
mainfrom
fix/telemetry-mcp-client-label-leak
May 15, 2026
Merged

fix(telemetry): record real mcp_client label instead of 'unknown' (extension WS dispatcher leak)#57
LakshmanTurlapati merged 1 commit into
mainfrom
fix/telemetry-mcp-client-label-leak

Conversation

@LakshmanTurlapati

Copy link
Copy Markdown
Collaborator

Summary

Telemetry was recording mcp_client = 'unknown' on 100% of production events (61/61 rows / 2 install_uuids verified via live DB query). Same root cause was propagating to model = 'unknown'. This PR fixes the leak.

Bug

extension/ws/mcp-bridge-client.js:523 and :851 invoke dispatchMcpToolRoute({ ..., client: this }) — passing the MCPBridgeClient instance object as client. The dispatcher (extension/ws/mcp-tool-dispatcher.js:340, :424) forwarded that object straight into recordDispatch(). The recorder (extension/utils/mcp-metrics-recorder.js:273) gates on typeof input.client === 'string', which the object fails → 'unknown'.

The MCP server has been correctly attaching the canonical normalised label to payload.visualSession.client all along (mcp/src/tools/manual.ts:180, normalised against the 13-label allowlist at mcp/src/tools/visual-session.ts:19). The extension reads it for visual-session lifecycle (mcp-bridge-client.js:605-611) but the telemetry path never consumed it.

Live evidence (pre-fix)

fsb-server> sqlite3 /data/fsb-data.db
> SELECT mcp_client, COUNT(*) AS n, COUNT(DISTINCT install_uuid) AS uniq
  FROM telemetry_events GROUP BY mcp_client ORDER BY n DESC;
[{ "mcp_client": "unknown", "n": 61, "uniq_installs": 2 }]

/api/public-stats/global correctly returned popular_mcp_clients: [] because every row aggregated into the same 'unknown' bucket which fails the k>=5 anonymity floor.

Fix

Added helper extractMcpClientLabel(payload) in mcp-tool-dispatcher.js that returns payload.visualSession.client when it's a non-empty trimmed string, else 'unknown'. Swapped both recordDispatch call sites to use it. Bridge-client object semantics for route handlers, restricted-response builders, and helper invocations are unchanged.

Test coverage

New tests/mcp-dispatcher-client-label.test.js — 24 assertions across 6 sections:

  1. Allowlist labels pass through verbatim (Claude / Codex / OpenCode / Cursor / ChatGPT / Gemini)
  2. Whitespace trimmed
  3. Missing payload / missing sidecar → 'unknown'
  4. Non-string client (object / number / null / undefined) → 'unknown' — the object case directly reproduces the original bridge-instance leak shape
  5. Empty / whitespace-only string → 'unknown'
  6. Regression guard: source-string asserts both recordDispatch({ blocks contain extractMcpClientLabel(payload) AND neither contains the bare client, arg. Catches a future partial revert.

Wired into the root npm test chain right after mcp-metrics-recorder.test.js.

Existing tests all green: mcp-metrics-recorder (88/88), mcp-tool-routing-contract (151/151), mcp-bridge-client-lifecycle (55/55), mcp-metrics-no-pii-leak PASS, extension-record-dispatch-serializes (7/7), visual-session-agent-scoped (16/16), mcp-visual-tick-lifecycle (79/79).

Test plan

  • node tests/mcp-dispatcher-client-label.test.js → 24/24 PASS locally
  • Related recorder / dispatcher / bridge / visual-session tests all green locally
  • CI green on this PR
  • After deploy: verify telemetry_events.mcp_client shows real labels (Claude/Codex/OpenCode/...) rather than 'unknown' in live DB
  • After deploy + 24h: verify /api/public-stats/global popular_mcp_clients populates once k>=5 floor is crossed

Non-goals

  • MCP server code is already correct — not touched
  • agents / agent_runs empty tables — separate scheduled-agent feature, unrelated
  • model = 'unknown' — downstream of the same root cause; will self-correct once client label is real

Fixes telemetry data quality starting next deploy.

Quick task ID: 260515-i1j — see .planning/quick/260515-i1j-fix-telemetry-mcp-client-recording-as-un/ for PLAN.md + SUMMARY.md.

…tension WS dispatcher leak)

The extension's MCP dispatcher was passing the MCPBridgeClient INSTANCE
OBJECT (`this`) into recordDispatch() where a STRING label was expected.
The recorder's typeof gate failed on the object and stamped every
telemetry_events row with mcp_client='unknown'. Live production DB:
61/61 events with mcp_client='unknown' across 2 install_uuids. Same
root cause cascaded to model='unknown' because pricing receives the
same broken value.

The MCP server has been sending the canonical normalised label correctly
the whole time on `payload.visualSession.client` (set by
mcp/src/tools/manual.ts buildVisualSessionSidecar after the 13-label
allowlist gate in mcp/src/tools/visual-session.ts). The extension reads
it for visual-session lifecycle but the telemetry path never consumed it.

Fix: add `extractMcpClientLabel(payload)` helper in
extension/ws/mcp-tool-dispatcher.js that pulls the string from
payload.visualSession.client (with trim + non-empty + type guards,
fallback 'unknown'). Use it at both recordDispatch call sites
(dispatchMcpToolRoute, dispatchMcpMessageRoute). Bridge-client object
semantics for route handlers, restricted-response builders, and helper
invocations are unchanged.

Tests: new tests/mcp-dispatcher-client-label.test.js (24 assertions
across 6 sections: allowlist passthrough, trim, missing payload,
non-string client incl. the original object-leak shape, empty string,
+ source-string regression guard that BOTH recordDispatch sites use
the helper). Wired into the root npm test chain right after
mcp-metrics-recorder.test.js. Existing recorder + dispatcher + bridge +
visual-session tests all stay green.

Live impact starting next deploy: telemetry_events.mcp_client records
the actual client name (Claude/Codex/OpenCode/...). Pricing-derived
model populates correctly. popular_mcp_clients in
/api/public-stats/global will populate as clients cross the k>=5
anonymity floor.

Quick task 260515-i1j.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@LakshmanTurlapati LakshmanTurlapati merged commit cd07d9b into main May 15, 2026
8 checks passed
@LakshmanTurlapati LakshmanTurlapati deleted the fix/telemetry-mcp-client-label-leak branch May 15, 2026 19:01
LakshmanTurlapati added a commit that referenced this pull request May 16, 2026
…59)

* chore(release): bump version 0.9.66 -> 0.9.67

Bumps the public version string across manifest, package files,
READMEs, store listing, and the showcase APP_VERSION constant
(the latter was stale at 0.9.65 from the prior bump landing miss).
Also captures the regenerated crawler artifacts (llms-full.txt +
sitemap.xml) with today's lastmod stamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telemetry): populate mcp_client on non-action message routes

PR #57 fixed the bridge-object-as-client leak for action tools, but
the dispatcher's `dispatchMcpMessageRoute` still recorded `unknown`
for every non-action route (agent:register, mcp:get-tabs, mcp:get-dom,
mcp:get-diagnostics, mcp:read-page) because those payloads never carry
a `visualSession.client` sidecar -- it is built only by manual-tool
dispatch in mcp/src/tools/manual.ts.

Live prod DB after PR #57 deploy: 84 of 109 telemetry_events rows
still landed as mcp_client='unknown', with `unknown` last_seen at the
same wall-clock minute as the real-labelled rows (OpenClaw / Codex /
ChatGPT). Confirms the leak is ongoing, not residual pre-fix data.

Fix: add a per-bridge-connect cache in mcp-tool-dispatcher.js. The
new `resolveMcpClientLabel(payload)` helper:
  - reads payload.visualSession.client first via the existing
    extractMcpClientLabel helper
  - on a real label, caches it in a module-level slot
  - on a payload-less / sidecar-less dispatch, falls back to the
    cached label before defaulting to 'unknown'

Lifecycle: mcp-bridge-client.js calls clearLastKnownMcpClientLabel()
from every fresh _ws.onopen so a different MCP client attaching on
the same port (only one process can hold localhost:7225 at a time)
cannot inherit the prior client's label. The reset is feature-gated
on `typeof globalThis.clearLastKnownMcpClientLabel === 'function'`
to defend against a load-order race.

Both recordDispatch call sites in dispatchMcpToolRoute and
dispatchMcpMessageRoute now go through resolveMcpClientLabel.

Tests: tests/mcp-dispatcher-client-label.test.js extended from 24 to
38 assertions across 9 sections, covering: cache seeding on first
real label, fallback fill-in for subsequent payload-less dispatches,
reset on bridge reconnect (no inherit), update-not-stick on legitimate
client switch within a connection, and a source-grep regression guard
that both recordDispatch sites use resolveMcpClientLabel (not the
narrower extractMcpClientLabel that lacks the fallback).

Validation: full `npm test` and `node scripts/validate-extension.mjs`
green. The first non-action message-route dispatch in a fresh
connection still lands as 'unknown' (no telemetry signal exists pre-
first-action-call), but every subsequent non-action call in the same
connection now records the real client label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(telemetry): scope client-label fallback by agentId (Codex PR #59 P2)

Codex review on PR #59 flagged that the module-wide
_lastKnownMcpClientLabel slot misattributes labels in multi-relay-client
bridge topologies. mcp/src/bridge.ts runs in hub mode and maintains a
relayClients Map serving multiple concurrent MCP-client processes
(Claude, Codex, Cursor, ...) over the same extension WebSocket. With
a single global slot, an action from agent A would seed the cache and
then a payload-less mcp:get-tabs from agent B would be recorded as
agent A's label until agent B issued its own first action.

Fix: replace the string slot with a Map keyed by `payload.agentId`
(injected on every post-register message by buildAgentPayload in
mcp/src/agent-bridge.ts). resolveMcpClientLabel now:
  - reads agentId off the payload (null if absent)
  - on a real visualSession.client label + agentId, sets map[agentId]
  - on payload 'unknown' WITH agentId, falls back to map[agentId]
  - on payload 'unknown' WITHOUT agentId, returns 'unknown' (no cross-
    agent leak)

The first agent:register message in a fresh agent scope still records
'unknown' because the MCP server learns its agent_id from the response
-- one such record per relay-client session, unavoidable.

clearLastKnownMcpClientLabel() clears the whole Map. The mcp-bridge-
client.js onopen reset path is unchanged in shape; renaming would be
churn for no behaviour change.

Tests: extended from 38 to 45 assertions. New Test 6b asserts a
payload-without-agentId returns 'unknown' even when another agent has
a cached label. New Test 6c is the direct Codex P2 regression: agent
A seeds 'Claude', agent B's payload-less get-tabs must return
'unknown' (not 'Claude'), and after agent B's own action seeds 'Codex'
the two slots remain independent.

Validation: full `npm test` and `node scripts/validate-extension.mjs`
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LakshmanTurlapati added a commit that referenced this pull request May 18, 2026
* docs: start milestone v0.9.70 Showcase Dashboard Reliability (Streaming + Sync + Viewport)

Three target features: (1) diagnose-first dashboard DOM-streaming fix
continuing STREAM-07 attempt 2 of 5 from v0.9.69 Phase 276; (2) Sync-tab
remote-control restoration on full-selfbrowsing.com; (3) 16:10 desktop
viewport lock on the dashboard preview pane (mobile out of scope).

PROJECT.md "Current Milestone" rewritten to v0.9.70 with goals + target
features + key constraints + deferred candidates. Footer "Last updated"
bumped to 2026-05-16. "Last shipped" updated to reflect v0.9.69 SHIP
+ four post-milestone fixes (PR #57/59/61/62/63/64) and the published
artifacts (extension v0.9.67 zip, mcp-v0.9.2 npm, Fly auto-deploy).

STATE.md frontmatter reset (milestone v0.9.70, status in_progress,
progress 0%). Current Position set to "Not started (defining requirements)".
Active Milestone Risk Register replaced with Active Milestone
Carry-Forward (STREAM-07 attempt 2 + CWS click-through). Quick Tasks
Completed table extended with PRs #59 / #61 / #62 / #63 / #64. Pending
User-Gated Actions: v0.9.0 npm publish marked superseded by v0.9.2;
added v0.9.67 -> extension-v0.9.67 retag decision.

Phases dir cleared (8 v0.9.69 phase directories removed by
gsd-tools phases clear). Carries over: 999.1-mcp-tool-gaps,
INTEGRATION-CHECK.md, v0.9.63-INTEGRATION-CHECK.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(showcase): lock dashboard preview pane to 16:10 on desktop

Phase 280 VIEWPORT requirement: the live DOM preview frame in the
showcase dashboard had no fixed aspect ratio and stretched freely to
container height, producing inconsistent letterboxing and the bug
visible in milestone 0.9.70. Pin it to 16/10 on desktop, unset on
mobile (<=768px and <=480px) where vertical layout takes over, and
keep Maximized (100vh) and PiP layouts coherent (auto for maximized,
explicit 16/10 for PiP).

Adds tests/dashboard-preview-aspect-ratio.test.js — parses the SCSS
and asserts the rule lives in the right selectors (desktop, both
mobile media queries, Maximized, PiP). Wired into the root `npm test`
chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant