From c8ac481741a43cd624161f2ee687ee11384db09a Mon Sep 17 00:00:00 2001 From: namvippro Date: Sun, 4 Oct 2026 20:21:41 +0700 Subject: [PATCH] feat(launcher): fixed DevTools port for browser open A client configured ahead of time, such as Playwright MCP with --cdp-endpoint, needs the port before the browser starts. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ docs/cli.md | 1 + docs/library.md | 1 + docs/mcp.md | 2 +- docs/playwright-mcp.md | 12 ++++++++++++ llms.txt | 3 ++- src/commands/browser-open.ts | 11 +++++++++++ src/launcher/launch.test.ts | 14 ++++++++++++-- src/launcher/launch.ts | 2 ++ src/profile-manager.ts | 1 + src/types.ts | 5 +++++ 11 files changed, 51 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb8bd7e..434d7ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Real launches keep Chrome's sandbox, Safe Browsing, popup blocking and updates on (no `--no-sandbox`). - `browser status` reports each browser's `engine`, and `engine` can now be `real` in launch results and lock files. `profile list` shows a Fingerprint column. +- `port` on `launch` and `browser open` (`--port`, MCP `port`): a fixed DevTools port, so a client + configured ahead of time, such as Playwright MCP with `--cdp-endpoint http://127.0.0.1:`, + can reach the profile's browser. A browser that is already running keeps its port. ### Changed diff --git a/docs/cli.md b/docs/cli.md index a9d8abe..79a98dc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -149,6 +149,7 @@ profile returns the running browser with `reused=true`. Close it with `browser c | `--headless` | Run Chrome without a visible window. Default false (a real window is far less detectable). Set true only on machines with no display. | | `--start-url ` | Absolute URL to open in the first tab, e.g. `"https://example.com"`. Omit for a blank tab. | | `--detached` | Let Chrome outlive the process that opened it. Default false: the opener keeps the browser protected (per-tab fingerprint injection, authenticated proxy relay) and closes it on exit. Set true only for scripts that must exit immediately; then only flag-level protections remain and an authenticated proxy will not work. Kernel mode is the exception: its spoof is in the Chrome flags, so later tabs stay spoofed after the opener exits. | +| `--port ` | Fixed DevTools port, e.g. `9301`, so a client configured ahead of time can connect to `http://127.0.0.1:` (Playwright MCP `--cdp-endpoint`). Omit for a free port. A browser that is already running keeps its port. | | `--engine ` | Where the fingerprint is applied. `kernel` needs a fingerprint-chromium binary and spoofs inside Chromium with no JavaScript hooks. `inject` patches stock Chrome over CDP. `auto` (default) uses kernel when the executable contains `--fingerprint-platform`, otherwise inject. A real profile only accepts `auto`; `kernel` or `inject` fails with `INVALID_CONFIG`. | Output: `{ profileId, wsEndpoint, port, pid, reused, detached, engine }`. `engine` is `kernel`, diff --git a/docs/library.md b/docs/library.md index a4e9d4e..f10dedf 100644 --- a/docs/library.md +++ b/docs/library.md @@ -126,6 +126,7 @@ interface LaunchOptions { timeout?: number; detached?: boolean; // let Chrome outlive this process (reduced protection) engine?: 'auto' | 'kernel' | 'inject'; // default auto; a real profile accepts only auto + port?: number; // fixed DevTools port; default a free one } ``` diff --git a/docs/mcp.md b/docs/mcp.md index 513bcfb..40acb46 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -100,7 +100,7 @@ Tool names are the command names with `_` instead of the dot: `profile.list` bec | `profile_update` | Change fields of an existing profile. Omitted fields are left untouched. | `idOrName`, `name?`, `proxy?` (null removes it), `timezone?`, `fingerprint?`, `language?`, `platform?`, `tags?`, `notes?` | | `profile_delete` | Delete a profile and all of its browser data. Irreversible. | `idOrName`, `force?` | | `profile_duplicate` | Copy a profile settings (proxy, timezone, fingerprint) into a new profile with a new id. | `idOrName`, `name?` | -| `browser_open` | Open Chrome with a stored profile and return its CDP wsEndpoint. | `idOrName`, `headless?`, `startUrl?`, `detached?`, `engine?` | +| `browser_open` | Open Chrome with a stored profile and return its CDP wsEndpoint. | `idOrName`, `headless?`, `startUrl?`, `detached?`, `engine?`, `port?` | | `browser_launch` | Launch a throwaway Chrome with a random fingerprint and no saved profile. | `proxy?`, `headless?`, `randomFingerprint?` (default true), `detached?` | | `browser_close` | Close the browser running for a profile, from any process. | `idOrName` | | `browser_close_all` | Close every browser started from this storage path, including temporary sessions. | none | diff --git a/docs/playwright-mcp.md b/docs/playwright-mcp.md index 49b6c0b..9c0e5fa 100644 --- a/docs/playwright-mcp.md +++ b/docs/playwright-mcp.md @@ -30,6 +30,18 @@ stay in effect. Both MCP servers can be registered at once: one to manage profiles, one to drive pages. The agent calls `browser_open`, then starts or reconfigures Playwright MCP with the returned endpoint. +## A fixed port: configure Playwright MCP once + +With `--port`, the endpoint is known before the browser starts, so Playwright MCP can sit in an +MCP config file instead of being started per session: + +```bash +browser-profiles browser open acme-main --port 9301 +npx -y @playwright/mcp@latest --cdp-endpoint http://127.0.0.1:9301 +``` + +Pick one port per profile. A browser that is already running keeps the port it started with. + ## From code The same endpoint works with any CDP client. diff --git a/llms.txt b/llms.txt index bed1215..a0ef311 100644 --- a/llms.txt +++ b/llms.txt @@ -102,7 +102,8 @@ profile_duplicate Copy a profile's settings into a new profile with a new id. browser_open Open Chrome with a stored profile and return its CDP endpoint. args: idOrName (required), headless? (default false), startUrl? (absolute URL), detached? (default false; true = Chrome outlives the caller, reduced protection), - engine? ("auto" | "kernel" | "inject"; a real profile accepts only "auto") + engine? ("auto" | "kernel" | "inject"; a real profile accepts only "auto"), + port? (fixed DevTools port; a running browser keeps its own) out: {profileId, wsEndpoint, port, pid, reused, detached, engine} engine is "kernel", "inject", or "real" (Chrome's own fingerprint) browser_launch Throwaway Chrome with a random fingerprint and no saved profile. Nothing persists. diff --git a/src/commands/browser-open.ts b/src/commands/browser-open.ts index e006529..0fc1d37 100644 --- a/src/commands/browser-open.ts +++ b/src/commands/browser-open.ts @@ -49,6 +49,16 @@ export const browserOpen = defineCommand({ .describe('Absolute URL to open in the first tab, e.g. "https://example.com". Omit for a blank tab.'), detached: detachedField, engine: engineField, + port: z + .number() + .int() + .min(1024) + .max(65535) + .optional() + .describe( + 'Fixed DevTools port, e.g. 9301, so a client set up ahead of time can connect to ' + + 'http://127.0.0.1:. Omit for a free port. A browser that is already running keeps its port.' + ), }), async run(ctx, input): Promise> { const found = await resolveProfile(ctx, input.idOrName); @@ -60,6 +70,7 @@ export const browserOpen = defineCommand({ headless: input.headless ?? false, detached: input.detached ?? false, engine: input.engine, + port: input.port, args: input.startUrl ? [input.startUrl] : [], }); diff --git a/src/launcher/launch.test.ts b/src/launcher/launch.test.ts index 5445c1c..b12c293 100644 --- a/src/launcher/launch.test.ts +++ b/src/launcher/launch.test.ts @@ -11,13 +11,13 @@ import { launchChrome } from './launch'; // Chrome and its DevTools socket are faked; everything between them is the real launcher. const fake = vi.hoisted(() => ({ port: 0, - launches: [] as Array<{ chromeFlags: string[]; envVars?: Record }>, + launches: [] as Array<{ chromeFlags: string[]; envVars?: Record; port?: number }>, cdpCalls: [] as string[], })); vi.mock('./deps', () => ({ loadChromeLauncher: async () => ({ - launch: async (options: { chromeFlags: string[]; envVars?: Record }) => { + launch: async (options: { chromeFlags: string[]; envVars?: Record; port?: number }) => { fake.launches.push(options); return { port: fake.port, pid: 2_000_000_000, kill: async () => undefined }; }, @@ -121,3 +121,13 @@ describe('launchChrome with a generated profile', () => { expect(fake.cdpCalls).toEqual(expect.arrayContaining(['Browser.getVersion', 'Target.setAutoAttach'])); }); }); + +describe('launchChrome DevTools port', () => { + it('asks Chrome for the fixed port, and for none by default', async () => { + launched = await launchChrome({ profile: realProfile, userDataDir: path.join(dir, 'data'), chromePath, port: 9301 }); + await launched.close(); + launched = await launch(realProfile); + + expect(fake.launches.map((options) => options.port)).toEqual([9301, undefined]); + }); +}); diff --git a/src/launcher/launch.ts b/src/launcher/launch.ts index 2699640..9aa6bbb 100644 --- a/src/launcher/launch.ts +++ b/src/launcher/launch.ts @@ -143,6 +143,7 @@ async function launchClaimed(options: ChromeLaunchOptions, userDataDir: string): args = [], extensions = [], detached = false, + port, } = options; // Safe here only because the claim above proved no browser of ours is running. @@ -186,6 +187,7 @@ async function launchClaimed(options: ChromeLaunchOptions, userDataDir: string): kernelFlags: identity.engine === 'kernel' ? buildKernelFlags(profile, identity.timezone) : undefined, }), ...(startingUrl ? { startingUrl } : {}), + ...(port ? { port } : {}), userDataDir, ignoreDefaultFlags: true, // Our own SIGINT handlers must run: chrome-launcher's kills and exits first. diff --git a/src/profile-manager.ts b/src/profile-manager.ts index 6bebf42..7842969 100644 --- a/src/profile-manager.ts +++ b/src/profile-manager.ts @@ -371,6 +371,7 @@ export class BrowserProfiles { timeout: options?.timeout, detached: options?.detached, engine: options?.engine, + port: options?.port, }); log.debug(`Launched profile ${profileId} on port ${result.port}`); diff --git a/src/types.ts b/src/types.ts index 25e5870..c087907 100644 --- a/src/types.ts +++ b/src/types.ts @@ -247,6 +247,11 @@ export interface LaunchOptions { * A real profile only accepts `auto`; it then launches with no engine. */ engine?: FingerprintEngine; + /** + * Fixed DevTools port, so a client configured ahead of time (e.g. Playwright MCP + * `--cdp-endpoint http://127.0.0.1:`) can reach the browser. Default: a free port. + */ + port?: number; } /**