Skip to content
Draft
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
2 changes: 1 addition & 1 deletion MODULE.bazel.lock

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

2 changes: 2 additions & 0 deletions devtools/client/flipper/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ js_pipeline(
deps = [
":node_modules/@player-devtools/types",
"//:node_modules/@types/ws",
"//:node_modules/flipper-common",
"//:node_modules/flipper-server",
"//:node_modules/flipper-server-client",
"//:node_modules/open",
"//:node_modules/ws",
],
)
150 changes: 149 additions & 1 deletion devtools/client/flipper/src/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
FlipperServerState,
type FlipperServer,
} from "flipper-server-client";
import type { InstalledPluginDetails } from "flipper-common";
import { spawn } from "child_process";
import open from "open";
import * as net from "net";
import * as fs from "fs";
import * as os from "os";
Expand All @@ -28,6 +30,7 @@ type MessageCallback = (
) => void;

const PLUGIN_API = "player-ui-devtools";
const PLUGIN_NPM_NAME = "flipper-plugin-player-ui-devtools";

/**
* All diagnostics go to stderr.
Expand All @@ -51,6 +54,12 @@ type FlipperExecuteMessage = {
};
};

/** Wire shape of the `init`/`deinit` plugin-activation handshake (`js-flipper`'s `FlipperClient.onMessageReceived`) */
type FlipperPluginLifecycleMessage = {
method: "init" | "deinit";
params: { plugin: string };
};

/**
* Flipper headless transport
*
Expand Down Expand Up @@ -218,12 +227,41 @@ export class FlipperServerTransport implements Transport {
*/
private activeClientIds = new Set<string>();

/**
* Client IDs currently connected to flipper-server, regardless of whether
* the devtools plugin has been activated for them. Used by `enablePlugin`'s
* "all connected clients" convenience.
*/
private connectedClientIds = new Set<string>();

constructor(
private options: {
/** Flipper server host; defaults to "localhost" */
host?: string;
/** Flipper server WebSocket port; defaults to 52342 */
port?: number;
/**
* Whether to open a browser/PWA UI once the server is up. Defaults to
* `false` — an agent driving this transport has no use for a browser
* tab, unlike a human running `flipper-server` directly.
*/
open?: boolean;
/**
* URL to open in the browser when `open` is true. Defaults to
* `http://localhost:{port}`. `flipper-server` itself always binds to
* localhost regardless of this value — this only controls what URL
* gets opened, so a custom host (e.g. a branded domain) must already
* resolve to this machine (e.g. via `/etc/hosts`) for it to work.
*/
url?: string;
/**
* Activate the devtools plugin automatically for every client that
* connects, instead of requiring an explicit `enablePlugin()` call per
* client. Off by default so a caller can choose when/whether to
* activate a given device; turn this on for a fully hands-off session
* where every connecting device should be watched immediately.
*/
autoEnablePlugin?: boolean;
} = {},
) {}

Expand All @@ -239,14 +277,18 @@ export class FlipperServerTransport implements Transport {
if (shouldStart) {
log("[FlipperServerTransport] Starting flipper-server...");
const serverScript = require.resolve("flipper-server/server.js");
// We always pass --open=false and drive any browser-open ourselves
// (below) so we control both whether it happens and what URL is used —
// flipper-server's own --open only ever opens http://localhost:{port}.
//
// Detached + unref'd: the daemon must survive this process exiting so
// other instances keep their connections. We never kill it directly —
// shutdown is driven by the refcount in close().
//
// The daemon must never inherit our fd 1: it outlives this process and
// would write into a later session's JSON-RPC stream. Its stderr stays
// inherited so daemon diagnostics remain visible.
const child = spawn(process.execPath, [serverScript, "--open=true"], {
const child = spawn(process.execPath, [serverScript, "--open=false"], {
stdio: ["ignore", "ignore", "inherit"],
detached: true,
});
Expand All @@ -267,6 +309,16 @@ export class FlipperServerTransport implements Transport {
log("[FlipperServerTransport] Attached to flipper-server.");
}

if (this.options.open) {
const url = this.options.url ?? `http://localhost:${port}`;
log(`[FlipperServerTransport] Opening ${url}`);
try {
await open(url);
} catch (err) {
console.warn("[FlipperServerTransport] Failed to open UI:", err);
}
}

// Read the auth token the flipper-server wrote during startup
const { getAuthToken } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
Expand Down Expand Up @@ -297,10 +349,20 @@ export class FlipperServerTransport implements Transport {
// Track client connects/disconnects
this.server.on("client-connected", (info) => {
log("[FlipperServerTransport] client-connected:", JSON.stringify(info));
this.connectedClientIds.add(info.id);
if (this.options.autoEnablePlugin) {
this.enablePlugin(info.id).catch((err) => {
console.warn(
`[FlipperServerTransport] Failed to auto-enable plugin for client ${info.id}:`,
err,
);
});
}
});
this.server.on("client-disconnected", ({ id }) => {
log("[FlipperServerTransport] client-disconnected:", id);
this.activeClientIds.delete(id);
this.connectedClientIds.delete(id);
});

// Route inbound device messages to our Messenger listeners
Expand Down Expand Up @@ -364,6 +426,91 @@ export class FlipperServerTransport implements Transport {
);
};

/**
* Ensures `flipper-plugin-player-ui-devtools` is installed on the attached
* flipper-server, installing the published npm package if it's missing.
*
* This is Flipper's own documented plugin-install RPC (the same
* `exec(...)` commands the desktop UI's "Install Plugin" button calls) —
* not a filesystem workaround. It's opt-in: callers that manage plugin
* installation themselves (or run against a flipper-server that already
* has it) can skip calling this.
*/
async ensurePluginInstalled(): Promise<InstalledPluginDetails> {
if (!this.server) {
throw new Error("FlipperServerTransport is not connected");
}

const installed = await this.server.exec("plugins-get-installed-plugins");
const existing = installed.find((p) => p.name === PLUGIN_NPM_NAME);
if (existing) {
log(
`[FlipperServerTransport] ${PLUGIN_NPM_NAME}@${existing.version} already installed.`,
);
return existing;
}

log(`[FlipperServerTransport] Installing ${PLUGIN_NPM_NAME} from npm...`);
const details = await this.server.exec(
"plugins-install-from-npm",
PLUGIN_NPM_NAME,
);
log(
`[FlipperServerTransport] Installed ${PLUGIN_NPM_NAME}@${details.version}.`,
);
return details;
}

/**
* Activates the devtools plugin for a connected client by sending the
* `init` handshake `js-flipper`'s device SDK requires before it will open
* a live plugin connection (`onConnect`/`FlipperConnection`) and start
* relaying `client-message`s for our api. Nothing in flipper-server itself
* sends this automatically for a non-background plugin like ours unless a
* full Flipper desktop app is attached and its tab is selected — this
* method lets a caller trigger the same handshake directly, without
* needing a desktop UI at all.
*
* Pass a specific `clientId`, or omit it to enable for every client
* currently connected to flipper-server.
*/
async enablePlugin(clientId?: string): Promise<void> {
await this.sendLifecycleMessage("init", clientId);
}

/** Symmetric counterpart to `enablePlugin` — releases the plugin connection without disconnecting the client from flipper-server. */
async disablePlugin(clientId?: string): Promise<void> {
await this.sendLifecycleMessage("deinit", clientId);
}

private async sendLifecycleMessage(
method: "init" | "deinit",
clientId?: string,
): Promise<void> {
if (!this.server) {
throw new Error("FlipperServerTransport is not connected");
}

const targets = clientId ? [clientId] : [...this.connectedClientIds];
const payload: FlipperPluginLifecycleMessage = {
method,
params: { plugin: PLUGIN_API },
};

await Promise.all(
targets.map((id) =>
this.server!.exec("client-request-response", id, payload).catch(
(err) => {
console.warn(
`[FlipperServerTransport] Failed to ${method} plugin for client ${id}:`,
err,
);
},
),
),
);
}

addListener: CommunicationLayerMethods["addListener"] = (callback) => {
this.listeners.add(callback);
};
Expand All @@ -375,6 +522,7 @@ export class FlipperServerTransport implements Transport {
async close(): Promise<void> {
this.listeners.clear();
this.activeClientIds.clear();
this.connectedClientIds.clear();
this.server?.close();
this.server = null;

Expand Down
45 changes: 45 additions & 0 deletions devtools/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,51 @@ await server.start();
| --- | --- | --- |
| `host` | `"localhost"` | Flipper server host. |
| `port` | `52342` | Flipper server WebSocket port. |
| `open` | `false` | Open a browser/PWA UI once the server is up. Off by default — an agent has no use for a browser tab. |
| `url` | `http://localhost:{port}` | URL to open when `open` is true. `flipper-server` itself always binds to `localhost`, regardless of this value — it only controls what gets opened in the browser, so a custom host must already resolve to this machine (e.g. via `/etc/hosts`). |
| `autoEnablePlugin` | `false` | Automatically call `enablePlugin()` for every client that connects (see [Plugin activation](#plugin-activation) below). |

The CLI (`player-devtools-mcp` / `bin/run`) reads `open`/`url` from
`PLAYER_DEVTOOLS_FLIPPER_OPEN` / `PLAYER_DEVTOOLS_FLIPPER_URL`, and always
installs the plugin and enables it for connected/connecting clients on
startup (see below) — no manual Flipper UI interaction is required.

### Plugin installation

`flipper-server` has no built-in way to auto-fetch a plugin — but it does
expose the same install RPCs its own desktop UI's "Install Plugin" button
uses, over the same `exec(...)` mechanism this transport already relies on.
`FlipperServerTransport.ensurePluginInstalled()` calls
`plugins-get-installed-plugins` to check whether
`flipper-plugin-player-ui-devtools` is already installed, and if not, installs
it from npm via `plugins-install-from-npm` — no filesystem access, no
dependency on this repo's Bazel/justfile tooling, just Flipper's documented
plugin-management API:

```ts
const transport = new FlipperServerTransport();
await transport.connect();
await transport.ensurePluginInstalled();
```

### Plugin activation

Flipper only opens a live connection for a plugin (and starts relaying its
messages) after sending it an `init` handshake — normally something only the
Flipper *desktop app* does, either automatically for a small class of
"background" plugins, or when a human selects that plugin's tab. Neither
applies to `flipper-plugin-player-ui-devtools` or to a headless MCP session,
so `FlipperServerTransport` exposes the handshake directly:

```ts
await transport.enablePlugin(); // activate for every connected client
await transport.enablePlugin(id); // or just one
await transport.disablePlugin(id); // release it again, without disconnecting
```

Pass `autoEnablePlugin: true` to the constructor to have this happen
automatically for every client as it connects, instead of calling
`enablePlugin()` yourself.

### Shared `flipper-server` daemon

Expand Down
37 changes: 32 additions & 5 deletions devtools/mcp/bin/run
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,40 @@ const {
FlipperServerTransport,
} = require("@player-devtools/client-flipper");

const transport = new FlipperServerTransport();
// PLAYER_DEVTOOLS_FLIPPER_OPEN=true opens a browser/PWA UI once flipper-server
// is up (off by default — an agent has no use for a browser tab).
// PLAYER_DEVTOOLS_FLIPPER_URL overrides the URL that gets opened (defaults to
// http://localhost:{port}); the host still needs to resolve to this machine.
const transport = new FlipperServerTransport({
open: process.env.PLAYER_DEVTOOLS_FLIPPER_OPEN === "true",
url: process.env.PLAYER_DEVTOOLS_FLIPPER_URL,
// flipper-server never activates a non-background plugin like ours on its
// own unless a full Flipper desktop app is attached with our tab selected —
// we don't want to depend on that, so every connecting device gets the
// plugin activated for it automatically.
autoEnablePlugin: true,
});
const server = new MCPServer(transport);

server.start().catch((err) => {
console.error("Failed to start MCP server:", err);
process.exit(1);
});
server
.start()
.then(async () => {
try {
await transport.ensurePluginInstalled();
// Catch up any client that connected before this resolved — going
// forward, autoEnablePlugin covers newly-connecting clients.
await transport.enablePlugin();
} catch (err) {
console.warn(
"[bin/run] Failed to install/activate the devtools plugin:",
err,
);
}
})
.catch((err) => {
console.error("Failed to start MCP server:", err);
process.exit(1);
});

process.on("SIGINT", () => {
server.stop().then(() => process.exit(0));
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.37.4",
"figures": "^3.0.0",
"flipper-common": "^0.273.0",
"flipper-pkg": "^0.273.0",
"flipper-server": "^0.273.0",
"flipper-server-client": "^0.273.0",
Expand All @@ -118,6 +119,7 @@
"log-update": "^4.0.0",
"mkdirp": "^1.0.4",
"oclif": "^4.4.2",
"open": "^8.4.2",
"posthog-node": "^5.0.0",
"prettier": "^3.5.3",
"react": "^18.2.0",
Expand Down
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

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