Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
140 changes: 12 additions & 128 deletions MODULE.bazel.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions devtools/mcp/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
58 changes: 56 additions & 2 deletions devtools/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

<details>
<summary>Maintainers: how the ingestion key is supplied</summary>

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.

</details>

[browser extension]: https://github.com/player-ui/browser-devtools
3 changes: 3 additions & 0 deletions devtools/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"dist",
"bin"
],
"engines": {
"node": "^20.20.0 || >=22.22.0"
},
"dependencies": {
"@player-devtools/client": "workspace:*",
"@player-devtools/client-flipper": "workspace:*",
Expand Down
78 changes: 78 additions & 0 deletions devtools/mcp/src/__tests__/server.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {}
async send(): Promise<void> {}
async close(): Promise<void> {}
},
}));

const { MCPServer } = await import("../server");

/** A transport whose connect outcome the test controls. */
function fakeTransport(connect: () => Promise<void>): 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();
});
});
Loading