diff --git a/MODULE.bazel b/MODULE.bazel
index 51489dd..b6b775d 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -18,10 +18,13 @@ bazel_dep(name = "rules_pkg", version = "1.1.0")
bazel_dep(name = "aspect_rules_ts", version = "3.6.3")
####### Node.js version #########
-bazel_dep(name = "rules_nodejs", version = "6.4.0")
+bazel_dep(name = "rules_nodejs", version = "6.7.5")
node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
-node.toolchain(node_version = "22.15.1")
+
+# @posthog/mcp requires "^20.20.0 || >=22.22.0"; 22.22.0 is the first satisfying
+# release, and it is only known to rules_nodejs >= 6.7.x.
+node.toolchain(node_version = "22.22.0")
#################################
npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm")
diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock
index 042d14c..255d441 100644
--- a/MODULE.bazel.lock
+++ b/MODULE.bazel.lock
@@ -52,10 +52,13 @@
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
"https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87",
- "https://bcr.bazel.build/modules/bazel_features/1.30.0/source.json": "b07e17f067fe4f69f90b03b36ef1e08fe0d1f3cac254c1241a1818773e3423bc",
+ "https://bcr.bazel.build/modules/bazel_features/1.39.0/MODULE.bazel": "28739425c1fc283c91931619749c832b555e60bcd1010b40d8441ce0a5cf726d",
+ "https://bcr.bazel.build/modules/bazel_features/1.39.0/source.json": "f63cbeb4c602098484d57001e5a07d31cb02bbccde9b5e2c9bf0b29d05283e93",
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
"https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
+ "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d",
+ "https://bcr.bazel.build/modules/bazel_lib/3.0.0/source.json": "895f21909c6fba01d7c17914bb6c8e135982275a1b18cdaa4e62272217ef1751",
"https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8",
"https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e",
"https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686",
@@ -110,13 +113,14 @@
"https://bcr.bazel.build/modules/pathkit/1.0.1/source.json": "3215e6b4b08f96f34024eaf186d247744ca255925d7ee3f50cf94f7cf885696b",
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
"https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f",
- "https://bcr.bazel.build/modules/platforms/0.0.11/source.json": "f7e188b79ebedebfe75e9e1d098b8845226c7992b307e28e1496f23112e8fc29",
"https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee",
"https://bcr.bazel.build/modules/platforms/0.0.5/MODULE.bazel": "5733b54ea419d5eaf7997054bb55f6a1d0b5ff8aedf0176fef9eea44f3acda37",
"https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615",
"https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814",
"https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d",
"https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc",
+ "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580",
+ "https://bcr.bazel.build/modules/platforms/1.0.0/source.json": "f4ff1fd412e0246fd38c82328eb209130ead81d62dcd5a9e40910f867f733d96",
"https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7",
"https://bcr.bazel.build/modules/protobuf/23.1/MODULE.bazel": "88b393b3eb4101d18129e5db51847cd40a5517a53e81216144a8c32dfeeca52a",
"https://bcr.bazel.build/modules/protobuf/24.4/MODULE.bazel": "7bc7ce5f2abf36b3b7b7c8218d3acdebb9426aeb35c2257c96445756f970eb12",
@@ -155,7 +159,8 @@
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
"https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
"https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0",
- "https://bcr.bazel.build/modules/rules_cc/0.1.2/source.json": "53fcb09b5816c83ca60d9d7493faf3bfaf410dfc2f15deb52d6ddd146b8d43f0",
+ "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4",
+ "https://bcr.bazel.build/modules/rules_cc/0.2.16/source.json": "d03d5cde49376d87e14ec14b666c56075e5e3926930327fd5d0484a1ff2ac1cc",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6",
"https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8",
"https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/source.json": "c8b1e2c717646f1702290959a3302a178fb639d987ab61d548105019f11e527e",
@@ -192,8 +197,8 @@
"https://bcr.bazel.build/modules/rules_multitool/1.0.0/source.json": "5b92dc9b267b024bb2174bdf5d8e32821abb53fd84545e00699c4e35826a1c78",
"https://bcr.bazel.build/modules/rules_nodejs/6.2.0/MODULE.bazel": "ec27907f55eb34705adb4e8257952162a2d4c3ed0f0b3b4c3c1aad1fac7be35e",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.0/MODULE.bazel": "45345e4aba35dd6e4701c1eebf5a4e67af4ed708def9ebcdc6027585b34ee52d",
- "https://bcr.bazel.build/modules/rules_nodejs/6.4.0/MODULE.bazel": "ce1fe1795222521d4b4494df58583a06f3bd6dd5d0d9409ea481fcab0e86f852",
- "https://bcr.bazel.build/modules/rules_nodejs/6.4.0/source.json": "3e3e8ad75b171b0b67c84fbe624c6d5b0a87b11f0bda17b4050eca95297ae040",
+ "https://bcr.bazel.build/modules/rules_nodejs/6.7.5/MODULE.bazel": "97e6794043821d23c013baa4a50fd1c599f2e6ae92b06e2c5f1cd7074fd83e7c",
+ "https://bcr.bazel.build/modules/rules_nodejs/6.7.5/source.json": "d60ee5a76258b1c8f99545ed24172b44d43ba64ca1a2dfc04371ef203df19fdf",
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/MODULE.bazel": "9db8031e71b6ef32d1846106e10dd0ee2deac042bd9a2de22b4761b0c3036453",
@@ -279,7 +284,7 @@
"moduleExtensions": {
"@@apple_support+//crosstool:setup.bzl%apple_cc_configure_extension": {
"general": {
- "bzlTransitiveDigest": "gv4nokEMGNye4Jvoh7Tw0Lzs63zfklj+n4t0UegI7Ms=",
+ "bzlTransitiveDigest": "3YZIdRYy1ZawZ4nWufHge2uOiqnpOCqqD+zKcdYFqLk=",
"usagesDigest": "kbKsOhDawUJi1ZDYHslECzs1HVGdq2xZPmUZdf9ALqg=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -443,7 +448,7 @@
"bzlTransitiveDigest": "aVqwKoRPrSXO367SJABlye04kmpR/9VM2xiXB3nh3Ls=",
"usagesDigest": "qH5h0y49b/BYrI5SRoLkSpQctbXmqG8KSHgoA8eopCE=",
"recordedFileInputs": {
- "@@//package.json": "5360a8a63e9250fdcbc33787bb40ed19f7cd3051410fd59637863d634c91f628"
+ "@@//package.json": "b6aad65b889b5a2400249868ce69c780c98f4d2d7ede27b8fefff320a909bc42"
},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -973,127 +978,6 @@
]
}
},
- "@@rules_nodejs+//nodejs:extensions.bzl%node": {
- "general": {
- "bzlTransitiveDigest": "MLpfA+65GINLc2EHubnI97nNK9NXPVkAUVpbgIrPV/M=",
- "usagesDigest": "gZKzN3JCTYjVJHccgM22QsagwtDXd7PCSZXav4Und8M=",
- "recordedFileInputs": {},
- "recordedDirentsInputs": {},
- "envVariables": {},
- "generatedRepoSpecs": {
- "nodejs_linux_amd64": {
- "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
- "attributes": {
- "node_download_auth": {},
- "node_repositories": {},
- "node_urls": [
- "https://nodejs.org/dist/v{version}/{filename}"
- ],
- "node_version": "22.15.1",
- "include_headers": false,
- "platform": "linux_amd64"
- }
- },
- "nodejs_linux_arm64": {
- "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
- "attributes": {
- "node_download_auth": {},
- "node_repositories": {},
- "node_urls": [
- "https://nodejs.org/dist/v{version}/{filename}"
- ],
- "node_version": "22.15.1",
- "include_headers": false,
- "platform": "linux_arm64"
- }
- },
- "nodejs_linux_s390x": {
- "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
- "attributes": {
- "node_download_auth": {},
- "node_repositories": {},
- "node_urls": [
- "https://nodejs.org/dist/v{version}/{filename}"
- ],
- "node_version": "22.15.1",
- "include_headers": false,
- "platform": "linux_s390x"
- }
- },
- "nodejs_linux_ppc64le": {
- "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
- "attributes": {
- "node_download_auth": {},
- "node_repositories": {},
- "node_urls": [
- "https://nodejs.org/dist/v{version}/{filename}"
- ],
- "node_version": "22.15.1",
- "include_headers": false,
- "platform": "linux_ppc64le"
- }
- },
- "nodejs_darwin_amd64": {
- "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
- "attributes": {
- "node_download_auth": {},
- "node_repositories": {},
- "node_urls": [
- "https://nodejs.org/dist/v{version}/{filename}"
- ],
- "node_version": "22.15.1",
- "include_headers": false,
- "platform": "darwin_amd64"
- }
- },
- "nodejs_darwin_arm64": {
- "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
- "attributes": {
- "node_download_auth": {},
- "node_repositories": {},
- "node_urls": [
- "https://nodejs.org/dist/v{version}/{filename}"
- ],
- "node_version": "22.15.1",
- "include_headers": false,
- "platform": "darwin_arm64"
- }
- },
- "nodejs_windows_amd64": {
- "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
- "attributes": {
- "node_download_auth": {},
- "node_repositories": {},
- "node_urls": [
- "https://nodejs.org/dist/v{version}/{filename}"
- ],
- "node_version": "22.15.1",
- "include_headers": false,
- "platform": "windows_amd64"
- }
- },
- "nodejs": {
- "repoRuleId": "@@rules_nodejs+//nodejs/private:nodejs_repo_host_os_alias.bzl%nodejs_repo_host_os_alias",
- "attributes": {
- "user_node_repository_name": "nodejs"
- }
- },
- "nodejs_host": {
- "repoRuleId": "@@rules_nodejs+//nodejs/private:nodejs_repo_host_os_alias.bzl%nodejs_repo_host_os_alias",
- "attributes": {
- "user_node_repository_name": "nodejs"
- }
- },
- "nodejs_toolchains": {
- "repoRuleId": "@@rules_nodejs+//nodejs/private:nodejs_toolchains_repo.bzl%nodejs_toolchains_repo",
- "attributes": {
- "user_node_repository_name": "nodejs"
- }
- }
- },
- "recordedRepoMappingEntries": []
- }
- },
"@@rules_player+//shell:defs.bzl%sh_configure": {
"general": {
"bzlTransitiveDigest": "hHBMsvBPNv+aexlCP8obaLYRtsEzjIRKeVm1n2HpZ5s=",
diff --git a/devtools/mcp/BUILD b/devtools/mcp/BUILD
index ff22e07..7349fb4 100644
--- a/devtools/mcp/BUILD
+++ b/devtools/mcp/BUILD
@@ -16,6 +16,9 @@ deps = [
":node_modules/@player-devtools/messenger",
":node_modules/@player-devtools/types",
"//:node_modules/@modelcontextprotocol/sdk",
+ "//:node_modules/@posthog/mcp",
+ "//:node_modules/@types/node",
+ "//:node_modules/posthog-node",
"//:node_modules/zod",
]
diff --git a/devtools/mcp/README.md b/devtools/mcp/README.md
index 1d01287..6710eb3 100644
--- a/devtools/mcp/README.md
+++ b/devtools/mcp/README.md
@@ -26,8 +26,8 @@ The package ships a CLI, `player-devtools-mcp`, which is what an MCP client runs
### Register with Claude Code
-Add it as an MCP server with `claude mcp add` — no env vars or tokens are
-required:
+Add it as an MCP server with `claude mcp add` — no tokens are required (see
+[Telemetry](#telemetry) for the optional opt-out variables):
```bash
claude mcp add player-devtools -- npx -y @player-devtools/mcp@latest
@@ -135,4 +135,58 @@ just mcp-inspect # open the MCP inspector against the server
Register it with an MCP client (e.g. Claude) by pointing the client at the
`player-devtools-mcp` command over stdio.
+## Telemetry
+
+The server reports anonymous usage analytics so we can tell how widely it's used
+and whether it's working in the field. It is **on by default** and sends:
+
+| | |
+| --- | --- |
+| Identity | A random UUID generated on first run and stored at `~/.player-ui-devtools/install.json`. It is not derived from anything about you or your machine — delete the file and a new one is generated. |
+| Events | Session start (`$mcp_initialize`), tool calls (`$mcp_tool_call`), tool listing (`$mcp_tools_list`), and errors (`$exception`). |
+| Properties | Tool **name**, call duration, whether the call errored, the MCP client name/version (e.g. which editor), the devtools version, OS platform, Node major version, and whether the Flipper transport connected. |
+
+**Tool arguments and tool responses are never transmitted.** Those can contain
+Player flow content, so every outgoing event is filtered through an allowlist of
+known-safe property names — anything not explicitly listed is dropped before the
+event leaves the process.
+
+To opt out, set either variable to any value:
+
+```bash
+export PLAYER_DEVTOOLS_TELEMETRY_DISABLED=1
+# or the cross-vendor convention, which we also honor
+export DO_NOT_TRACK=1
+```
+
+`DO_NOT_TRACK=0` and `DO_NOT_TRACK=false` are treated as "tracking is fine", not
+as an opt-out.
+
+Nothing else is needed to make this work — no account, no key, no configuration.
+Builds you make yourself (anything not a tagged release) send nothing at all.
+
+
+Maintainers: how the ingestion key is supplied
+
+The PostHog key is stamped into released builds; it is not in the repo and is
+not something users provide.
+
+Set `POSTHOG_PROJECT_KEY` in the release CI environment.
+[`helpers/release/workspace-status.sh`](../../helpers/release/workspace-status.sh)
+emits it as `STABLE_POSTHOG_KEY`, and
+[`tsup.config.ts`](../../tsup.config.ts) substitutes it into the
+`__POSTHOG_KEY__` global — the same mechanism that stamps `__VERSION__`.
+
+Stamping only happens under `--config=release`, so PR and local builds resolve
+the global to an empty string and stay silent. Only public `phc_` project keys
+are accepted: the value is baked into published artifacts and the shared remote
+cache, so a `phx_` personal or `phs_` secret key is rejected at runtime rather
+than shipped.
+
+To point a build at a different project or region without rebuilding, override
+`PLAYER_DEVTOOLS_TELEMETRY_KEY` / `PLAYER_DEVTOOLS_TELEMETRY_HOST` at runtime —
+useful for verifying against a local listener.
+
+
+
[browser extension]: https://github.com/player-ui/browser-devtools
diff --git a/devtools/mcp/package.json b/devtools/mcp/package.json
index 5fdf729..5319aca 100644
--- a/devtools/mcp/package.json
+++ b/devtools/mcp/package.json
@@ -9,6 +9,9 @@
"dist",
"bin"
],
+ "engines": {
+ "node": "^20.20.0 || >=22.22.0"
+ },
"dependencies": {
"@player-devtools/client": "workspace:*",
"@player-devtools/client-flipper": "workspace:*",
diff --git a/devtools/mcp/src/__tests__/server.test.ts b/devtools/mcp/src/__tests__/server.test.ts
new file mode 100644
index 0000000..10cab34
--- /dev/null
+++ b/devtools/mcp/src/__tests__/server.test.ts
@@ -0,0 +1,78 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { Transport } from "@player-devtools/types";
+
+const shutdown = vi.hoisted(() => vi.fn());
+const createAnalytics = vi.hoisted(() => vi.fn());
+
+vi.mock("../telemetry", () => ({
+ createAnalytics,
+ MCP_VERSION: "test-version",
+}));
+
+// The stdio transport would otherwise take over the real process stdout.
+vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
+ StdioServerTransport: class {
+ async start(): Promise {}
+ async send(): Promise {}
+ async close(): Promise {}
+ },
+}));
+
+const { MCPServer } = await import("../server");
+
+/** A transport whose connect outcome the test controls. */
+function fakeTransport(connect: () => Promise): Transport {
+ return {
+ connect,
+ close: vi.fn(async () => {}),
+ sendMessage: vi.fn(async () => {}),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ } as unknown as Transport;
+}
+
+describe("MCPServer", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ createAnalytics.mockReturnValue({ shutdown });
+ });
+
+ it("starts even when the devtools transport fails to connect", async () => {
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ const server = new MCPServer(
+ fakeTransport(async () => {
+ throw new Error("no flipper");
+ }),
+ );
+
+ await expect(server.start()).resolves.toBeUndefined();
+ });
+
+ it("reports the transport as connected only after a successful connect", async () => {
+ const server = new MCPServer(fakeTransport(async () => {}));
+ const { isTransportConnected } = createAnalytics.mock.calls[0]?.[1] ?? {};
+
+ expect(isTransportConnected()).toBe(false);
+ await server.start();
+ expect(isTransportConnected()).toBe(true);
+ });
+
+ it("flushes buffered telemetry before tearing down", async () => {
+ const transport = fakeTransport(async () => {});
+ const server = new MCPServer(transport);
+
+ await server.stop();
+
+ expect(shutdown).toHaveBeenCalled();
+ expect(transport.close).toHaveBeenCalled();
+ });
+
+ it("still shuts down cleanly when the telemetry flush fails", async () => {
+ shutdown.mockRejectedValueOnce(new Error("network down"));
+ const transport = fakeTransport(async () => {});
+ const server = new MCPServer(transport);
+
+ await expect(server.stop()).resolves.toBeUndefined();
+ expect(transport.close).toHaveBeenCalled();
+ });
+});
diff --git a/devtools/mcp/src/server.ts b/devtools/mcp/src/server.ts
index 1782183..18093f3 100644
--- a/devtools/mcp/src/server.ts
+++ b/devtools/mcp/src/server.ts
@@ -6,16 +6,27 @@ import type { Transport } from "@player-devtools/types";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import type { PostHog } from "posthog-node";
import { TOOL_DEFS, type ToolDef } from "./tools";
+import { createAnalytics, MCP_VERSION } from "./telemetry";
export class MCPServer {
private client: ExtensionClient;
private server: McpServer;
+ private analytics: PostHog | null;
+ private transportConnected = false;
constructor(private transport: Transport) {
this.client = createExtensionClient(transport);
- this.server = new McpServer({ name: "player-devtools", version: "0.0.1" });
+ this.server = new McpServer({
+ name: "player-devtools",
+ version: MCP_VERSION,
+ });
+ // Instrument before registering tools so every handler is wrapped.
+ this.analytics = createAnalytics(this.server, {
+ isTransportConnected: () => this.transportConnected,
+ });
this.registerTools();
}
@@ -44,6 +55,7 @@ export class MCPServer {
await this.server.connect(stdioTransport);
try {
await this.transport.connect();
+ this.transportConnected = true;
} catch (err) {
console.warn(
"[MCPServer] Transport connect failed (will operate in disconnected mode):",
@@ -53,6 +65,14 @@ export class MCPServer {
}
async stop(): Promise {
+ // Flush buffered events first: bin/run calls process.exit(0) as soon as
+ // this resolves, which would otherwise drop them. Never fail shutdown on a
+ // telemetry error.
+ try {
+ await this.analytics?.shutdown();
+ } catch {
+ /* best-effort */
+ }
this.client.destroy();
await this.transport.close();
await this.server.close();
diff --git a/devtools/mcp/src/telemetry/__tests__/config.test.ts b/devtools/mcp/src/telemetry/__tests__/config.test.ts
new file mode 100644
index 0000000..dd16999
--- /dev/null
+++ b/devtools/mcp/src/telemetry/__tests__/config.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect } from "vitest";
+
+import { resolveTelemetryConfig } from "../config";
+
+const KEY = "phc_test_key";
+
+describe("resolveTelemetryConfig", () => {
+ it("is disabled in unstamped builds, where no key is baked in", () => {
+ expect(resolveTelemetryConfig({})).toEqual({ enabled: false });
+ });
+
+ it.each([
+ ["phx_personal_key", "personal API key"],
+ ["phs_project_secret", "project secret key"],
+ ["not-a-posthog-key", "malformed value"],
+ ])("refuses to use %s (%s)", (key) => {
+ // A non-public key is a real credential; shipping one would be worse than
+ // shipping no telemetry at all.
+ expect(
+ resolveTelemetryConfig({ PLAYER_DEVTOOLS_TELEMETRY_KEY: key }),
+ ).toEqual({ enabled: false });
+ });
+
+ it("is enabled with a configured key and defaults to the US host", () => {
+ expect(
+ resolveTelemetryConfig({ PLAYER_DEVTOOLS_TELEMETRY_KEY: KEY }),
+ ).toEqual({
+ enabled: true,
+ apiKey: KEY,
+ host: "https://us.i.posthog.com",
+ });
+ });
+
+ it.each([
+ ["PLAYER_DEVTOOLS_TELEMETRY_DISABLED", "1"],
+ ["PLAYER_DEVTOOLS_TELEMETRY_DISABLED", "true"],
+ ["DO_NOT_TRACK", "1"],
+ ["DO_NOT_TRACK", "yes"],
+ ])("opts out when %s=%s", (name, value) => {
+ expect(
+ resolveTelemetryConfig({
+ PLAYER_DEVTOOLS_TELEMETRY_KEY: KEY,
+ [name]: value,
+ }),
+ ).toEqual({ enabled: false });
+ });
+
+ it.each([
+ ["0", "explicit zero"],
+ ["false", "explicit false"],
+ ["", "empty string"],
+ ])("stays enabled for DO_NOT_TRACK=%s (%s)", (value) => {
+ expect(
+ resolveTelemetryConfig({
+ PLAYER_DEVTOOLS_TELEMETRY_KEY: KEY,
+ DO_NOT_TRACK: value,
+ }),
+ ).toMatchObject({ enabled: true });
+ });
+
+ it("normalizes trailing slashes on the host override", () => {
+ expect(
+ resolveTelemetryConfig({
+ PLAYER_DEVTOOLS_TELEMETRY_KEY: KEY,
+ PLAYER_DEVTOOLS_TELEMETRY_HOST: "https://eu.i.posthog.com///",
+ }),
+ ).toMatchObject({ host: "https://eu.i.posthog.com" });
+ });
+});
diff --git a/devtools/mcp/src/telemetry/__tests__/index.test.ts b/devtools/mcp/src/telemetry/__tests__/index.test.ts
new file mode 100644
index 0000000..732f6aa
--- /dev/null
+++ b/devtools/mcp/src/telemetry/__tests__/index.test.ts
@@ -0,0 +1,113 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+const instrument = vi.hoisted(() => vi.fn());
+const shutdown = vi.hoisted(() => vi.fn());
+const PostHogCtor = vi.hoisted(() => vi.fn());
+const getInstallId = vi.hoisted(() => vi.fn());
+
+const INSTALL_ID = "3f2504e0-4f89-41d3-9a0c-0305e82c3301";
+
+// Keep the identity deterministic — the real implementation touches the home
+// directory, which is not writable under the Bazel test sandbox.
+vi.mock("../installId", () => ({ getInstallId }));
+vi.mock("@posthog/mcp", () => ({ instrument }));
+vi.mock("posthog-node", () => ({
+ PostHog: class {
+ shutdown = shutdown;
+ constructor(...args: Array) {
+ PostHogCtor(...args);
+ }
+ },
+}));
+
+const { createAnalytics } = await import("../index");
+
+const KEY = "phc_test_key";
+const server = {} as object;
+const deps = { isTransportConnected: () => true };
+
+describe("createAnalytics", () => {
+ const original = { ...process.env };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getInstallId.mockReturnValue(INSTALL_ID);
+ process.env.PLAYER_DEVTOOLS_TELEMETRY_KEY = KEY;
+ delete process.env.DO_NOT_TRACK;
+ delete process.env.PLAYER_DEVTOOLS_TELEMETRY_DISABLED;
+ });
+
+ afterEach(() => {
+ process.env = { ...original };
+ });
+
+ it("does not instrument when telemetry is opted out", () => {
+ process.env.DO_NOT_TRACK = "1";
+
+ expect(createAnalytics(server, deps)).toBeNull();
+ expect(instrument).not.toHaveBeenCalled();
+ });
+
+ it("does not instrument without a configured key", () => {
+ delete process.env.PLAYER_DEVTOOLS_TELEMETRY_KEY;
+
+ expect(createAnalytics(server, deps)).toBeNull();
+ expect(instrument).not.toHaveBeenCalled();
+ });
+
+ it("installs the property allowlist on the PostHog client itself", () => {
+ createAnalytics(server, deps);
+
+ const [, options] = PostHogCtor.mock.calls[0] ?? [];
+ expect(options.before_send).toHaveLength(1);
+
+ // The client-level hook must strip payloads regardless of the SDK.
+ const redacted = options.before_send[0]({
+ event: "$mcp_tool_call",
+ properties: { $mcp_tool_name: "get_flow", $mcp_parameters: "SECRET" },
+ });
+ expect(JSON.stringify(redacted)).not.toContain("SECRET");
+ });
+
+ it("disables the SDK's tool-schema-mutating context parameter", () => {
+ createAnalytics(server, deps);
+
+ const [, , options] = instrument.mock.calls[0] ?? [];
+ expect(options.context).toBe(false);
+ });
+
+ it("identifies with the durable install id", () => {
+ createAnalytics(server, deps);
+
+ const [, , options] = instrument.mock.calls[0] ?? [];
+ expect(options.identify.distinctId).toBe(INSTALL_ID);
+ // Person properties would de-anonymize the event stream.
+ expect(options.identify.properties).toBeUndefined();
+ });
+
+ it("does not instrument when no durable identity is available", () => {
+ getInstallId.mockReturnValue(null);
+
+ expect(createAnalytics(server, deps)).toBeNull();
+ expect(instrument).not.toHaveBeenCalled();
+ });
+
+ it("reports the settled transport state at capture time", () => {
+ let connected = false;
+ createAnalytics(server, { isTransportConnected: () => connected });
+
+ const [, , options] = instrument.mock.calls[0] ?? [];
+ expect(options.eventProperties().transport_connected).toBe(false);
+
+ connected = true;
+ expect(options.eventProperties().transport_connected).toBe(true);
+ });
+
+ it("returns null instead of throwing when instrumentation fails", () => {
+ instrument.mockImplementationOnce(() => {
+ throw new Error("boom");
+ });
+
+ expect(() => createAnalytics(server, deps)).not.toThrow();
+ });
+});
diff --git a/devtools/mcp/src/telemetry/__tests__/installId.test.ts b/devtools/mcp/src/telemetry/__tests__/installId.test.ts
new file mode 100644
index 0000000..0ac07f8
--- /dev/null
+++ b/devtools/mcp/src/telemetry/__tests__/installId.test.ts
@@ -0,0 +1,83 @@
+import { describe, it, expect, vi } from "vitest";
+
+import { getInstallId, type InstallIdDeps } from "../installId";
+
+const UUID = "3f2504e0-4f89-41d3-9a0c-0305e82c3301";
+
+const enoent = (): never => {
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
+};
+
+function setup(overrides: Partial = {}) {
+ const deps: InstallIdDeps = {
+ homedir: () => "/home/tester",
+ readFileSync: vi.fn(enoent) as unknown as InstallIdDeps["readFileSync"],
+ writeFileSync: vi.fn() as unknown as InstallIdDeps["writeFileSync"],
+ mkdirSync: vi.fn() as unknown as InstallIdDeps["mkdirSync"],
+ randomUUID: () => UUID,
+ ...overrides,
+ };
+ return deps;
+}
+
+const reads = (contents: string) =>
+ vi.fn(() => contents) as unknown as InstallIdDeps["readFileSync"];
+
+describe("getInstallId", () => {
+ it("mints and persists an id on first run", () => {
+ const deps = setup();
+
+ expect(getInstallId(deps)).toBe(UUID);
+ expect(deps.mkdirSync).toHaveBeenCalledWith(
+ "/home/tester/.player-ui-devtools",
+ { recursive: true },
+ );
+ expect(deps.writeFileSync).toHaveBeenCalledWith(
+ "/home/tester/.player-ui-devtools/install.json",
+ JSON.stringify({ installId: UUID }),
+ { mode: 0o600 },
+ );
+ });
+
+ it("reuses a persisted id without rewriting it", () => {
+ const existing = "11111111-2222-4333-8444-555555555555";
+ const deps = setup({
+ readFileSync: reads(JSON.stringify({ installId: existing })),
+ });
+
+ expect(getInstallId(deps)).toBe(existing);
+ expect(deps.writeFileSync).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["corrupt JSON", "{ not json"],
+ ["non-string id", JSON.stringify({ installId: 42 })],
+ ["empty id", JSON.stringify({ installId: "" })],
+ ["non-uuid id", JSON.stringify({ installId: "nope" })],
+ ])("regenerates on %s", (_label, contents) => {
+ const deps = setup({ readFileSync: reads(contents) });
+
+ expect(getInstallId(deps)).toBe(UUID);
+ expect(deps.writeFileSync).toHaveBeenCalled();
+ });
+
+ it("returns null when the directory cannot be created", () => {
+ const deps = setup({
+ mkdirSync: vi.fn(() => {
+ throw Object.assign(new Error("EACCES"), { code: "EACCES" });
+ }) as unknown as InstallIdDeps["mkdirSync"],
+ });
+
+ expect(getInstallId(deps)).toBeNull();
+ });
+
+ it("returns null when the file cannot be written", () => {
+ const deps = setup({
+ writeFileSync: vi.fn(() => {
+ throw Object.assign(new Error("EROFS"), { code: "EROFS" });
+ }) as unknown as InstallIdDeps["writeFileSync"],
+ });
+
+ expect(getInstallId(deps)).toBeNull();
+ });
+});
diff --git a/devtools/mcp/src/telemetry/__tests__/redaction.test.ts b/devtools/mcp/src/telemetry/__tests__/redaction.test.ts
new file mode 100644
index 0000000..c91e84c
--- /dev/null
+++ b/devtools/mcp/src/telemetry/__tests__/redaction.test.ts
@@ -0,0 +1,87 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ ALLOWED_EVENT_PROPERTIES,
+ allowlistEventProperties,
+} from "../redaction";
+
+const SECRET = "ZZZ-CANARY-ZZZ";
+
+describe("allowlistEventProperties", () => {
+ it("strips captured tool parameters and responses", () => {
+ const result = allowlistEventProperties({
+ event: "$mcp_tool_call",
+ properties: {
+ $mcp_tool_name: "invoke_action",
+ $mcp_duration_ms: 12,
+ $mcp_parameters: {
+ pluginId: "basic",
+ payload: { flowContent: SECRET },
+ },
+ $mcp_response: { data: SECRET },
+ },
+ });
+
+ // Assert on the serialized payload, not key presence — a nested leak would
+ // pass a shallow key check.
+ expect(JSON.stringify(result)).not.toContain(SECRET);
+ expect(result.properties).toEqual({
+ $mcp_tool_name: "invoke_action",
+ $mcp_duration_ms: 12,
+ });
+ });
+
+ it("drops unknown properties a future SDK version might add", () => {
+ const result = allowlistEventProperties({
+ event: "$mcp_tool_call",
+ properties: {
+ $mcp_tool_name: "get_flow",
+ $mcp_raw_request: { body: SECRET },
+ $mcp_arguments_v2: SECRET,
+ },
+ });
+
+ expect(JSON.stringify(result)).not.toContain(SECRET);
+ expect(Object.keys(result.properties ?? {})).toEqual(["$mcp_tool_name"]);
+ });
+
+ it("preserves every allowlisted property", () => {
+ const properties = Object.fromEntries(
+ [...ALLOWED_EVENT_PROPERTIES].map((key) => [key, "kept"]),
+ );
+
+ const result = allowlistEventProperties({
+ event: "$mcp_tool_call",
+ properties,
+ });
+
+ expect(Object.keys(result.properties ?? {}).sort()).toEqual(
+ [...ALLOWED_EVENT_PROPERTIES].sort(),
+ );
+ });
+
+ it("yields empty properties rather than leaking when nothing is allowed", () => {
+ const result = allowlistEventProperties({
+ event: "$mcp_tool_call",
+ properties: { $mcp_parameters: SECRET, $mcp_response: SECRET },
+ });
+
+ expect(result.properties).toEqual({});
+ expect(JSON.stringify(result)).not.toContain(SECRET);
+ });
+
+ it("tolerates events without properties", () => {
+ expect(() =>
+ allowlistEventProperties({ event: "$mcp_initialize" }),
+ ).not.toThrow();
+ });
+
+ it("passes through a null event dropped by an earlier hook", () => {
+ expect(allowlistEventProperties(null)).toBeNull();
+ });
+
+ it("never allows the two payload keys, whatever else changes", () => {
+ expect(ALLOWED_EVENT_PROPERTIES.has("$mcp_parameters")).toBe(false);
+ expect(ALLOWED_EVENT_PROPERTIES.has("$mcp_response")).toBe(false);
+ });
+});
diff --git a/devtools/mcp/src/telemetry/config.ts b/devtools/mcp/src/telemetry/config.ts
new file mode 100644
index 0000000..78c1c44
--- /dev/null
+++ b/devtools/mcp/src/telemetry/config.ts
@@ -0,0 +1,68 @@
+declare global {
+ const __POSTHOG_KEY__: string;
+}
+
+/**
+ * PostHog project API key, stamped at release build time from the
+ * `POSTHOG_PROJECT_KEY` CI environment variable (see
+ * helpers/release/workspace-status.sh and tsup.config.ts), mirroring how
+ * `__VERSION__` is stamped.
+ *
+ * This is a *public, write-only* ingestion token — it can only send events and
+ * cannot read data back, which is why it is safe to ship in an open-source
+ * package.
+ *
+ * The `typeof` guard is required: the global is absent in local and test
+ * builds, where it resolves to an empty string and disables telemetry.
+ */
+const POSTHOG_PROJECT_KEY =
+ typeof __POSTHOG_KEY__ !== "undefined" ? __POSTHOG_KEY__ : "";
+
+const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
+
+export type TelemetryConfig =
+ | { readonly enabled: false }
+ | { readonly enabled: true; readonly apiKey: string; readonly host: string };
+
+/**
+ * Presence of the variable opts out, *except* for explicit negatives —
+ * `DO_NOT_TRACK=0` means "tracking is fine", not "opted out".
+ */
+function isTruthy(value: string | undefined): boolean {
+ if (value === undefined) return false;
+ const normalized = value.trim().toLowerCase();
+ if (normalized === "" || normalized === "0" || normalized === "false") {
+ return false;
+ }
+ return true;
+}
+
+function isOptedOut(env: NodeJS.ProcessEnv): boolean {
+ return (
+ isTruthy(env.PLAYER_DEVTOOLS_TELEMETRY_DISABLED) ||
+ // Cross-vendor convention (consoledonottrack.com) — honored so a user who
+ // sets it once is covered everywhere.
+ isTruthy(env.DO_NOT_TRACK)
+ );
+}
+
+export function resolveTelemetryConfig(
+ env: NodeJS.ProcessEnv = process.env,
+): TelemetryConfig {
+ if (isOptedOut(env)) return { enabled: false };
+
+ const apiKey = env.PLAYER_DEVTOOLS_TELEMETRY_KEY ?? POSTHOG_PROJECT_KEY;
+ // Unstamped (local/dev builds) or unset in CI — stay silent rather than
+ // firing doomed requests at PostHog on every startup.
+ if (!apiKey) return { enabled: false };
+ // Only public write-only project keys may ship. A `phx_` (personal) or
+ // `phs_` (project secret) key is a real credential and must never be baked
+ // into a published artifact, so refuse to use one even if stamped.
+ if (!apiKey.startsWith("phc_")) return { enabled: false };
+
+ const host = (
+ env.PLAYER_DEVTOOLS_TELEMETRY_HOST ?? DEFAULT_POSTHOG_HOST
+ ).replace(/\/+$/, "");
+
+ return { enabled: true, apiKey, host };
+}
diff --git a/devtools/mcp/src/telemetry/index.ts b/devtools/mcp/src/telemetry/index.ts
new file mode 100644
index 0000000..aefddc5
--- /dev/null
+++ b/devtools/mcp/src/telemetry/index.ts
@@ -0,0 +1,85 @@
+import * as os from "os";
+import { instrument } from "@posthog/mcp";
+import { PostHog } from "posthog-node";
+
+import { resolveTelemetryConfig } from "./config";
+import { getInstallId } from "./installId";
+import { allowlistEventProperties } from "./redaction";
+
+export {
+ ALLOWED_EVENT_PROPERTIES,
+ allowlistEventProperties,
+} from "./redaction";
+export { resolveTelemetryConfig, type TelemetryConfig } from "./config";
+export { getInstallId, type InstallIdDeps } from "./installId";
+
+declare global {
+ const __VERSION__: string;
+}
+
+/**
+ * Stamped at release build time by tsup (see devtools/tsup.config.ts). The
+ * guard is required — the global is absent in local and test builds.
+ */
+export const MCP_VERSION: string =
+ typeof __VERSION__ !== "undefined" ? __VERSION__ : "unstamped";
+
+export type AnalyticsDeps = {
+ /** Reports whether the devtools transport is currently connected. */
+ isTransportConnected: () => boolean;
+};
+
+/**
+ * Instruments `server` with anonymous usage analytics.
+ *
+ * Returns the PostHog client so the caller can flush it on shutdown, or `null`
+ * when telemetry is disabled or no durable identity is available. Never throws:
+ * telemetry must not be able to prevent the server from starting.
+ */
+export function createAnalytics(
+ server: object,
+ deps: AnalyticsDeps,
+): PostHog | null {
+ try {
+ const config = resolveTelemetryConfig();
+ if (!config.enabled) return null;
+
+ const installId = getInstallId();
+ if (installId === null) return null;
+
+ const posthog = new PostHog(config.apiKey, {
+ host: config.host,
+ // The load-bearing privacy control. Runs inside posthog-node's capture(),
+ // so it applies to everything the MCP SDK emits regardless of the SDK's
+ // own hooks. See redaction.ts for why this is an allowlist.
+ before_send: [allowlistEventProperties],
+ });
+
+ instrument(server, posthog, {
+ // Defaults to ON, and would inject a *required* `context` parameter into
+ // every tool schema asking the agent to narrate user intent — mutating
+ // our public tool API and adding per-call token cost.
+ context: false,
+ identify: { distinctId: installId },
+ eventProperties: () => ({
+ mcp_version: MCP_VERSION,
+ os_platform: os.platform(),
+ node_major: Number.parseInt(
+ process.versions.node.split(".")[0] ?? "",
+ 10,
+ ),
+ transport_connected: deps.isTransportConnected(),
+ surface: "mcp-server",
+ }),
+ // Redundant with `before_send` above, kept as a second independent layer.
+ // This hook fails closed: the SDK drops the event if it throws.
+ beforeSend: allowlistEventProperties,
+ });
+
+ return posthog;
+ } catch {
+ // Telemetry is strictly best-effort — a misconfiguration must never take
+ // down the server.
+ return null;
+ }
+}
diff --git a/devtools/mcp/src/telemetry/installId.ts b/devtools/mcp/src/telemetry/installId.ts
new file mode 100644
index 0000000..5243372
--- /dev/null
+++ b/devtools/mcp/src/telemetry/installId.ts
@@ -0,0 +1,79 @@
+import { randomUUID } from "crypto";
+import * as fs from "fs";
+import * as os from "os";
+import * as path from "path";
+
+export type InstallIdDeps = {
+ /** Resolves the user's home directory. */
+ homedir: () => string;
+ /** Reads the install file. */
+ readFileSync: typeof fs.readFileSync;
+ /** Writes the install file. */
+ writeFileSync: typeof fs.writeFileSync;
+ /** Creates the install directory. */
+ mkdirSync: typeof fs.mkdirSync;
+ /** Mints a new identifier. */
+ randomUUID: () => string;
+};
+
+const defaultDeps: InstallIdDeps = {
+ homedir: os.homedir,
+ readFileSync: fs.readFileSync,
+ writeFileSync: fs.writeFileSync,
+ mkdirSync: fs.mkdirSync,
+ randomUUID,
+};
+
+type InstallFile = { installId: string };
+
+const UUID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export const INSTALL_DIR_NAME = ".player-ui-devtools";
+export const INSTALL_FILE_NAME = "install.json";
+
+/**
+ * A stable, anonymous per-machine identifier.
+ *
+ * Stored under the user's home directory rather than the OS temp dir the
+ * flipper refcount uses: temp is periodically reaped, which would regenerate
+ * the id on every reboot and turn "unique installs" into "unique boots".
+ *
+ * Returns `null` when the id can neither be read nor persisted (read-only FS,
+ * sandbox, absent HOME). Callers treat that as "telemetry off" — returning an
+ * in-memory id instead would register a brand-new install on every run and
+ * systematically inflate counts for exactly the sandboxed population least
+ * likely to be real users.
+ */
+export function getInstallId(deps: InstallIdDeps = defaultDeps): string | null {
+ const dir = path.join(deps.homedir(), INSTALL_DIR_NAME);
+ const file = path.join(dir, INSTALL_FILE_NAME);
+
+ try {
+ const parsed = JSON.parse(
+ deps.readFileSync(file, "utf8") as string,
+ ) as Partial;
+ // Validate the shape, not just the parse: `{"installId": 42}` parses fine
+ // but is not a usable identity.
+ if (
+ typeof parsed.installId === "string" &&
+ UUID_PATTERN.test(parsed.installId)
+ ) {
+ return parsed.installId;
+ }
+ } catch {
+ /* missing or corrupt — fall through and regenerate */
+ }
+
+ const installId = deps.randomUUID();
+ try {
+ deps.mkdirSync(dir, { recursive: true });
+ const contents: InstallFile = { installId };
+ // 0600: a stable per-user identifier should not be world-readable.
+ deps.writeFileSync(file, JSON.stringify(contents), { mode: 0o600 });
+ } catch {
+ return null;
+ }
+
+ return installId;
+}
diff --git a/devtools/mcp/src/telemetry/redaction.ts b/devtools/mcp/src/telemetry/redaction.ts
new file mode 100644
index 0000000..43fa0b2
--- /dev/null
+++ b/devtools/mcp/src/telemetry/redaction.ts
@@ -0,0 +1,72 @@
+/**
+ * Event property allowlist.
+ *
+ * `@posthog/mcp` captures tool arguments (`$mcp_parameters`) and tool results
+ * (`$mcp_response`) unconditionally — there is no SDK option to disable it, and
+ * its built-in sanitizer only matches sensitive *key names* (`token`,
+ * `password`, ...), so a field named `flowContent` would be transmitted
+ * verbatim. Devtools tool arguments carry player/plugin ids and `invoke_action`
+ * payloads, i.e. potentially customer flow data.
+ *
+ * This is an allowlist rather than a denylist on purpose: the SDK is 0.x and
+ * ships frequently, so a denylist naming today's two payload keys would
+ * silently start leaking the moment a release adds a third. Anything not
+ * explicitly listed here never reaches the wire.
+ */
+export const ALLOWED_EVENT_PROPERTIES: ReadonlySet = new Set([
+ // MCP tool call shape — names and timings only, never payloads.
+ "$mcp_tool_name",
+ "$mcp_duration_ms",
+ "$mcp_is_error",
+ "$mcp_error_type",
+ "$mcp_listed_tool_names",
+ "$mcp_missing_capability",
+ // Which editor/agent is driving the server.
+ "$mcp_client_name",
+ "$mcp_client_version",
+ "$mcp_server_name",
+ "$mcp_server_version",
+ "$mcp_protocol_version",
+ // PostHog bookkeeping.
+ "$session_id",
+ "$process_person_profile",
+ "$lib",
+ "$lib_version",
+ // Our own event properties.
+ "mcp_version",
+ "os_platform",
+ "node_major",
+ "transport_connected",
+ "surface",
+]);
+
+export type CapturedEvent = {
+ /** Event name. */
+ event?: string;
+ /** Event properties. */
+ properties?: Record;
+};
+
+/**
+ * Drops every property not in {@link ALLOWED_EVENT_PROPERTIES}.
+ *
+ * Installed as a `posthog-node` client-level `before_send`, which runs inside
+ * `capture()` — a chokepoint the MCP SDK cannot route around, since we own the
+ * client instance and it does not.
+ *
+ * Accepts `null` because `before_send` hooks compose: an earlier hook may have
+ * already dropped the event.
+ */
+export function allowlistEventProperties(
+ event: Event | null,
+): Event | null {
+ if (!event?.properties) return event;
+
+ event.properties = Object.fromEntries(
+ Object.entries(event.properties).filter(([key]) =>
+ ALLOWED_EVENT_PROPERTIES.has(key),
+ ),
+ );
+
+ return event;
+}
diff --git a/helpers/release/workspace-status.sh b/helpers/release/workspace-status.sh
index 181002c..67960ac 100755
--- a/helpers/release/workspace-status.sh
+++ b/helpers/release/workspace-status.sh
@@ -12,4 +12,17 @@ git_branch=$(git rev-parse --abbrev-ref HEAD)
echo "GIT_BRANCH $git_branch"
git_tree_status=$(git diff-index --quiet HEAD -- && echo 'Clean' || echo 'Modified')
-echo "GIT_TREE_STATUS $git_tree_status"
\ No newline at end of file
+echo "GIT_TREE_STATUS $git_tree_status"
+
+# PostHog telemetry ingestion key, supplied by the CI environment.
+#
+# This is a *public, write-only* project key (`phc_...`): it can only send
+# events and cannot read anything back, which is why it is safe to bake into a
+# published package. Do not put a personal (`phx_`) or project-secret (`phs_`)
+# key here — those are real credentials and this value is stamped into build
+# artifacts and the shared remote cache.
+#
+# Emitted unconditionally so the key is part of the cache key: a changed key
+# must invalidate stamped artifacts rather than leave a stale one cached. When
+# unset the build stamps an empty string and telemetry stays disabled.
+echo "STABLE_POSTHOG_KEY ${POSTHOG_PROJECT_KEY:-}"
\ No newline at end of file
diff --git a/package.json b/package.json
index a4327bd..363fcd0 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,7 @@
"@player-ui/reference-assets-plugin-react": ">=0.15.4",
"@player-ui/storybook": ">=0.15.4",
"@player-ui/types": ">=0.15.4",
+ "@posthog/mcp": "0.10.8",
"@storybook/addon-docs": "^7.6.10",
"@storybook/blocks": "^7.6.10",
"@storybook/builder-vite": "^7.6.10",
@@ -117,6 +118,7 @@
"log-update": "^4.0.0",
"mkdirp": "^1.0.4",
"oclif": "^4.4.2",
+ "posthog-node": "^5.0.0",
"prettier": "^3.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -144,11 +146,11 @@
},
"packageManager": "pnpm@10.12.1",
"engines": {
- "node": "^22.12.0",
+ "node": ">=22.22.0",
"pnpm": "^10.12.1"
},
"volta": {
- "node": "22.12.0",
+ "node": "22.22.0",
"pnpm": "10.12.1"
},
"pnpm": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f7b0cf7..7a656ed 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -115,6 +115,9 @@ importers:
'@player-ui/types':
specifier: '>=0.15.4'
version: 1.0.1
+ '@posthog/mcp':
+ specifier: 0.10.8
+ version: 0.10.8(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(posthog-node@5.48.1(rxjs@7.8.2))
'@storybook/addon-docs':
specifier: ^7.6.10
version: 7.6.20(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -319,6 +322,9 @@ importers:
oclif:
specifier: ^4.4.2
version: 4.22.22(@types/node@24.5.2)
+ posthog-node:
+ specifier: ^5.0.0
+ version: 5.48.1(rxjs@7.8.2)
prettier:
specifier: ^3.5.3
version: 3.6.2
@@ -3109,19 +3115,23 @@ packages:
'@modelcontextprotocol/inspector-cli@0.21.2':
resolution: {integrity: sha512-Om2ApfIbkbfR7+PqJX0bO2Qwg0z55MgxquC+G0YL6x80ElpZMfxITsWQ1238kh3bv1zlwPSePc2kvDnCcU5tXw==, tarball: https://registry.npmjs.org/@modelcontextprotocol/inspector-cli/-/inspector-cli-0.21.2.tgz}
+ deprecated: 'v1 is deprecated. Upgrade to v2: npm i @modelcontextprotocol/inspector@latest. v1 gets security fixes only, published under the v1-latest tag.'
hasBin: true
'@modelcontextprotocol/inspector-client@0.21.2':
resolution: {integrity: sha512-8us3hVXDgMHGb5jF1bBE/a6rRRlEaS+SKjbDFB56oE6+mNcAP9JgL8h6T0fYd3XQ3vL/CYxjVeQE+5yRdvhsVg==, tarball: https://registry.npmjs.org/@modelcontextprotocol/inspector-client/-/inspector-client-0.21.2.tgz}
+ deprecated: 'v1 is deprecated. Upgrade to v2: npm i @modelcontextprotocol/inspector@latest. v1 gets security fixes only, published under the v1-latest tag.'
hasBin: true
'@modelcontextprotocol/inspector-server@0.21.2':
resolution: {integrity: sha512-ASFNPFMnT0Vn5u6aYRwwMrwA/0qpxb1lg3sE5GjWii5bUfPNXuIaLOXuXKSOFIMG4o82RxpuipdqX1ZdsmTPUw==, tarball: https://registry.npmjs.org/@modelcontextprotocol/inspector-server/-/inspector-server-0.21.2.tgz}
+ deprecated: 'v1 is deprecated. Upgrade to v2: npm i @modelcontextprotocol/inspector@latest. v1 gets security fixes only, published under the v1-latest tag.'
hasBin: true
'@modelcontextprotocol/inspector@0.21.2':
resolution: {integrity: sha512-f/zIzl6ccYjIxSTgmol9EKvd8AXsXkIaZX16kLGhrYwxrApvU3IL6IzgOqATKP/2kgyKdFUOVLlHMxX9e6BZZA==, tarball: https://registry.npmjs.org/@modelcontextprotocol/inspector/-/inspector-0.21.2.tgz}
engines: {node: '>=22.7.5'}
+ deprecated: 'v1 is deprecated. Upgrade to v2: npm i @modelcontextprotocol/inspector@latest. v1 gets security fixes only, published under the v1-latest tag.'
hasBin: true
'@modelcontextprotocol/sdk@1.29.0':
@@ -3748,6 +3758,19 @@ packages:
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==, tarball: https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz}
+ '@posthog/core@1.46.9':
+ resolution: {integrity: sha512-EXO6y5ih+jBkTCpUCuYgQmajuDZuvy6vMvflkub6pLQyi0GPlCPWVSvZZkOeQw9e2MxoD5GteeGCt9R8+UJ/yQ==, tarball: https://registry.npmjs.org/@posthog/core/-/core-1.46.9.tgz}
+
+ '@posthog/mcp@0.10.8':
+ resolution: {integrity: sha512-p3beWalPLashncxN1Q5XCrQgEn87JDToGTLBL2ThLMTREgxllz2yNEbnjpAfBlKYCP/RII9EYJ6UEL8IHT6hNQ==, tarball: https://registry.npmjs.org/@posthog/mcp/-/mcp-0.10.8.tgz}
+ engines: {node: ^20.20.0 || >=22.22.0}
+ peerDependencies:
+ '@modelcontextprotocol/sdk': '>=1.26.0'
+ posthog-node: ^5.0.0
+
+ '@posthog/types@1.402.2':
+ resolution: {integrity: sha512-ZZTiS4dLwF4/D0YTzS3gGSLFnhuNOY5yu4d9VGS9trGe5GW6FjIXo20p18K5BPW2RZSYSld8sdnSAY3AUXleUQ==, tarball: https://registry.npmjs.org/@posthog/types/-/types-1.402.2.tgz}
+
'@radix-ui/number@1.0.1':
resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==, tarball: https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz}
@@ -9723,6 +9746,15 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==, tarball: https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz}
engines: {node: ^10 || ^12 || >=14}
+ posthog-node@5.48.1:
+ resolution: {integrity: sha512-BxLX2SqGEQhPqCPTalpyo0RRv1NMbf7UaN7q9d/ED77ksD6XOmE7ko2vIKO8F0zPL1NtKxIi+DYXap9lvR0RaA==, tarball: https://registry.npmjs.org/posthog-node/-/posthog-node-5.48.1.tgz}
+ engines: {node: ^20.20.0 || >=22.22.0}
+ peerDependencies:
+ rxjs: ^7.0.0
+ peerDependenciesMeta:
+ rxjs:
+ optional: true
+
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, tarball: https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz}
engines: {node: '>= 0.8.0'}
@@ -16819,6 +16851,18 @@ snapshots:
'@popperjs/core@2.11.8': {}
+ '@posthog/core@1.46.9':
+ dependencies:
+ '@posthog/types': 1.402.2
+
+ '@posthog/mcp@0.10.8(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(posthog-node@5.48.1(rxjs@7.8.2))':
+ dependencies:
+ '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76)
+ '@posthog/core': 1.46.9
+ posthog-node: 5.48.1(rxjs@7.8.2)
+
+ '@posthog/types@1.402.2': {}
+
'@radix-ui/number@1.0.1':
dependencies:
'@babel/runtime': 7.28.4
@@ -24296,6 +24340,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ posthog-node@5.48.1(rxjs@7.8.2):
+ dependencies:
+ '@posthog/core': 1.46.9
+ optionalDependencies:
+ rxjs: 7.8.2
+
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.0:
diff --git a/tsup.config.ts b/tsup.config.ts
index 3a1c0cf..9dbdfe7 100644
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@ -31,6 +31,7 @@ function getStampedSubstitutions(): Record {
const customSubstitutions = {
__VERSION__: "{STABLE_VERSION}",
__GIT_COMMIT__: "{STABLE_GIT_COMMIT}",
+ __POSTHOG_KEY__: "{STABLE_POSTHOG_KEY}",
};
const substitutions: Record = {};