cdp-toolkit 1.9.0 → 1.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/manifest.ts CHANGED
@@ -204,7 +204,7 @@ export const MANIFEST: ToolSpec[] = [
204
204
  },
205
205
  {
206
206
  "name": "evaluate_script",
207
- "description": "Run arbitrary JavaScript in the target page's main-world context over raw CDP and return the evaluated value (returnByValue). With no 'args' the 'expression' is evaluated as a raw expression; when 'args' is provided 'expression' must be a function literal (arrow or classic) invoked on globalThis with the args passed positionally. A thrown exception surfaces as an error; non-serializable returns (DOM nodes, functions) come back as their CDP description string. Pass 'savePath' to write the evaluated value to a JSON file instead: the response then carries only {path,bytes,type,target} and the value itself never appears in it, in any form. That is the way to read a credential (a JWT or session token out of localStorage, for example) without putting the secret into the caller's transcript. This is also the one tool that can evaluate inside an MV3 extension's background SERVICE WORKER rather than a page: pass target 'worker:<extension-id>' (Chrome only) and read chrome.storage.local, chrome.runtime and the worker's own globals directly, with an idle-evicted worker started for you first (see 'wake').",
207
+ "description": "Run arbitrary JavaScript in the target page's main-world context over raw CDP and return the evaluated value (returnByValue). With no 'args' the 'expression' is evaluated as a raw expression; when 'args' is provided 'expression' must be a function literal (arrow or classic) invoked on globalThis with the args passed positionally. A thrown exception surfaces as an error; non-serializable returns (DOM nodes, functions) come back as their CDP description string. Pass 'savePath' to write the evaluated value to a JSON file instead: the response then carries only {path,bytes,type,target} and the value itself never appears in it, in any form. That is the way to read a credential (a JWT or session token out of localStorage, for example) without putting the secret into the caller's transcript. This is the one tool that can EVALUATE inside an MV3 extension's background SERVICE WORKER rather than a page (list_network_requests/get_network_request/list_console_messages OBSERVE one): pass target 'worker:<extension-id>' (Chrome only) and read chrome.storage.local, chrome.runtime and the worker's own globals directly, with an idle-evicted worker started for you first (see 'wake').",
208
208
  "inputSchema": {
209
209
  "type": "object",
210
210
  "properties": {
@@ -214,7 +214,7 @@ export const MANIFEST: ToolSpec[] = [
214
214
  },
215
215
  "target": {
216
216
  "type": "string",
217
- "description": "Page selector: 'active' (or omit) -> first page-type target | '<32-hex targetId>' -> exact target by id | 'index:N' -> Nth page-type target (0-based) | 'url:<substring>' -> first page whose url contains substring | 'title:<substring>' -> first page whose title contains substring | 'label:<name>' -> the page-type target with exactly this label (checked against the origin ledger and live leases) | 'worker:<substring>' -> a service/shared worker whose url contains the substring, which is how you reach an MV3 extension's background service worker (worker:<extension-id> or worker:background.js) and read chrome.storage.local from it. The worker: arm is CHROME-ONLY (Capability 'worker.targets') and is accepted by this tool only; an MV3 worker that has been idle-evicted is started automatically first, see 'wake'."
217
+ "description": "Page selector: 'active' (or omit) -> first page-type target | '<32-hex targetId>' -> exact target by id | 'index:N' -> Nth page-type target (0-based) | 'url:<substring>' -> first page whose url contains substring | 'title:<substring>' -> first page whose title contains substring | 'label:<name>' -> the page-type target with exactly this label (checked against the origin ledger and live leases) | 'worker:<substring>' -> a service/shared worker whose url contains the substring, which is how you reach an MV3 extension's background service worker (worker:<extension-id> or worker:background.js) and read chrome.storage.local from it. The worker: arm is CHROME-ONLY (Capability 'worker.targets') and is accepted by four tools only — evaluate_script, list_network_requests, get_network_request and list_console_messages — never as universal grammar; an MV3 worker that has been idle-evicted is started automatically first, see 'wake'."
218
218
  },
219
219
  "wake": {
220
220
  "type": "boolean",
@@ -909,7 +909,7 @@ export const MANIFEST: ToolSpec[] = [
909
909
  },
910
910
  {
911
911
  "name": "take_screenshot",
912
- "description": "Capture the viewport (default), the full scrollable page (fullPage), or a single element (uid or selector, exactly one and mutually exclusive) via raw CDP Page.captureScreenshot. Writes a PNG/JPEG under /tmp/cdp-toolkit (override with savePath) and returns {path,bytes,format,target}; raw base64 is only included when returnBase64 is set. quality applies to jpeg only.",
912
+ "description": "Capture the viewport (default), the full scrollable page (fullPage), or a single element (uid or selector, exactly one and mutually exclusive) via raw CDP Page.captureScreenshot — as one image when it fits, or as vertical bands stitched losslessly into one PNG when it does not (see tile). Writes a PNG/JPEG under /tmp/cdp-toolkit (override with savePath; its directory is created for you) and returns {path,bytes,format,target,scale} plus width/height MEASURED from the encoded bytes, which are omitted rather than guessed when the bytes cannot be decoded; renderSize/renderRestored (+renderRestoreError) accompany a renderWidth/renderHeight capture, tiled/bands a banded one. Raw base64 is only included when returnBase64 is set, and is refused on a banded capture. quality applies to jpeg only. Every capture costs one Page.getLayoutMetrics round trip, because Chrome does not refuse an oversized capture politely: past 16384 device px per side Page.captureScreenshot can simply never answer, and captureBeyondViewport leaves the tab resized to the clip, so the size is checked before the capture rather than after.",
913
913
  "inputSchema": {
914
914
  "type": "object",
915
915
  "properties": {
@@ -937,6 +937,22 @@ export const MANIFEST: ToolSpec[] = [
937
937
  "type": "boolean",
938
938
  "description": "Capture the full scrollable content height computed from Page.getLayoutMetrics, not just the viewport."
939
939
  },
940
+ "scale": {
941
+ "type": "number",
942
+ "description": "Output-pixel multiplier for this one capture: greater than 0, at most 8, default 1. Output px = ceil(css px x scale x devicePixelRatio), so on a ratio-2 display scale 3 is 6x the CSS size; the page is never told anything changed (devicePixelRatio and innerWidth/innerHeight read identically before and after). Chrome only (capability screenshot.scale) — WebDriver BiDi's browsingContext.captureScreenshot has no scale parameter, so under Firefox call emulate with deviceScaleFactor first and capture at scale 1. A projection past Chrome's 16384 device px per side is refused before the browser is dialled, naming the largest scale that fits."
943
+ },
944
+ "renderWidth": {
945
+ "type": "number",
946
+ "description": "Emulate this viewport width, in CSS px (integer, 1-16384), for the length of ONE capture, then put the previous viewport back. REQUIRED together with renderHeight. Not the same knob as scale: scale re-renders the same layout at more pixels, this changes what the page BELIEVES, so media queries flip and the document re-lays-out — which is how you capture a responsive page at 1920x1080 from a tab that is not that size. Declared by BOTH backends (capability screenshot.renderSize). The restore can only put back an override this toolkit itself applied: one set by the DevTools UI, another client, or a pre-1.9.2 build is invisible and gets reset to the real device. The result reports renderSize and renderRestored; renderRestored:false means the tab is STILL emulated and you have to fix it with emulate."
947
+ },
948
+ "renderHeight": {
949
+ "type": "number",
950
+ "description": "Emulate this viewport height, in CSS px (integer, 1-16384), for the length of one capture. Required together with renderWidth — a viewport is two numbers, and honouring one while inventing the other would render at a size you never named and never see in the result. Note a narrow renderWidth reflows most documents TALLER, which is the usual way a render-size capture trips the 16384-device-px encode limit."
951
+ },
952
+ "tile": {
953
+ "type": "boolean",
954
+ "description": "Whether this capture may be taken as vertical bands and stitched into one lossless PNG. Three-valued: omitted = AUTO (one shot when the projected output fits under Chrome's 16384 device px per side, bands when it does not — this is why a whole-page capture of a very long page now returns a file instead of hanging and wedging the tab); false = never band, refuse instead (the pre-1.9.2 behaviour); true = always band, even when one shot would have fitted. Only true costs a capability (screenshot.tile, Chrome only; Firefox was never driven for the band arithmetic). A banded capture is PNG-only and cannot also returnBase64, and only HEIGHT is tiled — a projected WIDTH past the limit is refused, not split, because bands are stitched by concatenating scanlines. Bands are a faithful crop of one document render: fixed and sticky elements do not repeat per band and seams are pixel-exact, but content the page only loads on real scroll (lazy images, virtualized lists) renders blank in bands the real viewport never reached, which is a property of the page and not of the stitch."
955
+ },
940
956
  "uid": {
941
957
  "type": "number",
942
958
  "description": "Element to clip to: a CDP backendDOMNodeId obtained from take_snapshot. Mutually exclusive with selector."
@@ -947,11 +963,11 @@ export const MANIFEST: ToolSpec[] = [
947
963
  },
948
964
  "savePath": {
949
965
  "type": "string",
950
- "description": "Override the output file path. Default: /tmp/cdp-toolkit/screenshot-<id>-<stamp>.<ext>."
966
+ "description": "Override the output file path. Default: /tmp/cdp-toolkit/screenshot-<id>-<stamp>.<ext>, where the stamp is the capture's START (a banded capture writes as it goes and has no finished image to name a file after). The destination directory is created from this path's own dirname, so a savePath outside /tmp/cdp-toolkit works."
951
967
  },
952
968
  "returnBase64": {
953
969
  "type": "boolean",
954
- "description": "Also return the raw base64 image bytes in the result, in addition to writing the file."
970
+ "description": "Also return the raw base64 image bytes in the result, in addition to writing the file. Refused on a banded capture (see tile): a stitched image runs to hundreds of megabytes and base64 inflates it by a third, into the caller's response — read the file at the returned path instead."
955
971
  }
956
972
  },
957
973
  "required": [],
@@ -960,7 +976,7 @@ export const MANIFEST: ToolSpec[] = [
960
976
  },
961
977
  {
962
978
  "name": "emulate",
963
- "description": "Apply any subset of Chrome emulation overrides in one call: device metrics (width/height together, plus deviceScaleFactor/mobile), userAgent, cpuThrottlingRate (>=1), emulated media type + mediaFeatures, and networkConditions. Pass clearOverrides:true to reset every override to the browser default (ignores all other fields). Non-metrics overrides are session-scoped and reset when the per-call connection closes; device-metrics overrides persist on the target until cleared or the renderer navigates/reloads.",
979
+ "description": "Apply any subset of Chrome emulation overrides in one call: device metrics (width/height together, plus deviceScaleFactor/mobile), userAgent, cpuThrottlingRate (>=1), emulated media type + mediaFeatures, and networkConditions. Pass clearOverrides:true to reset every override to the browser default (ignores all other fields); as of 1.9.2 that genuinely clears a device-metrics override set by a DIFFERENT process (Emulation.clearDeviceMetricsOverride is a no-op unless the clearing connection also set the override, so the driver re-asserts the tab's current size on its own connection first, then clears — and throws rather than answering cleared:true if it cannot verify that). Non-metrics overrides are session-scoped and reset when the per-call connection closes; device-metrics overrides persist on the target until cleared or the renderer navigates/reloads.",
964
980
  "inputSchema": {
965
981
  "type": "object",
966
982
  "properties": {
@@ -1135,7 +1151,7 @@ export const MANIFEST: ToolSpec[] = [
1135
1151
  },
1136
1152
  {
1137
1153
  "name": "list_console_messages",
1138
- "description": "Read console output (logs, warnings, exceptions) captured for the target page. By default reads the target's existing shared buffer and returns parsed console entries (empty if no capture has run); with reload:true it reloads the page and records a fresh capture window (both console+network) so a network reload never wipes console history.",
1154
+ "description": "Read console output (logs, warnings, exceptions) captured for the target page. By default reads the target's existing shared buffer and returns parsed console entries (empty if no capture has run); with reload:true it reloads the page and records a fresh capture window (both console+network) so a network reload never wipes console history. SERVICE WORKERS (Chrome only, Capability 'worker.targets'): pass target 'worker:<extension-id>' to watch an MV3 extension's BACKGROUND SERVICE WORKER. The outbound request that worker makes to your backend is recorded from CDP's Network domain on the worker's own session, which observes what the code ACTUALLY SENT and so needs neither of the two tricks a caller reaches for first. Both of those fail for ordinary JavaScript reasons rather than service-worker ones: a value bound at module top level is not visible from evaluate_script's global scope (plain lexical scoping, exactly the same in a page), and assigning self.fetch cannot rebind a fetch reference the module already captured. A worker's console.log arrives as Runtime.consoleAPICalled on its own session. A worker capture has no reload to drive it: reload:true means 'listen for durationMs while the worker runs', so trigger the worker's work (another tool call, an extension message, an alarm) during the window; a worker woken by the call has already run its top-level code by the time the recorder attaches, so a request it makes at startup can be missed. SIDE EFFECT, deliberate and documented: the recording holds a CDP session on the worker, and that KEEPS THE WORKER ALIVE for as long as the capture runs — an MV3 worker is otherwise idle-evicted within seconds — and Chrome resumes evicting it once the capture stops.",
1139
1155
  "inputSchema": {
1140
1156
  "type": "object",
1141
1157
  "properties": {
@@ -1145,15 +1161,19 @@ export const MANIFEST: ToolSpec[] = [
1145
1161
  },
1146
1162
  "target": {
1147
1163
  "type": "string",
1148
- "description": "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
1164
+ "description": "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>' | 'worker:<substring>' -> a service/shared worker whose url contains the substring, which is how you reach an MV3 extension's background service worker (worker:<extension-id> or worker:background.js). The worker: arm is CHROME-ONLY (Capability 'worker.targets'), is refused on Firefox, and a bare worker target id works too."
1149
1165
  },
1150
1166
  "reload": {
1151
1167
  "type": "boolean",
1152
- "description": "Record fresh by reloading the page and capturing for a window. Default false (read the existing buffer)."
1168
+ "description": "Record fresh by reloading the page and capturing for a window. Default false (read the existing buffer). With a worker: target there is nothing to reload: the window records for durationMs while the worker runs, and the session it holds keeps that worker from being idle-evicted until the capture stops."
1153
1169
  },
1154
1170
  "durationMs": {
1155
1171
  "type": "number",
1156
1172
  "description": "Capture window for reload mode, in milliseconds. Default 2500."
1173
+ },
1174
+ "wake": {
1175
+ "type": "boolean",
1176
+ "description": "Worker targets only, and only when a capture is being started (reload:true). Default true: an MV3 extension service worker is idle-evicted after seconds of inactivity and then exists in NO target listing, so the capture asks Chrome to start it (ServiceWorker.startWorker over a page session) and then re-reads the target list to confirm it actually came up. Pass false to fail fast instead of starting anything. Rejected rather than ignored on a page target, on a bare target id (Chrome re-mints a worker's target id when it restarts, so only the url-substring form can name a worker that is not running), and on a read-only call (the buffer is keyed by target id, so a woken worker's buffer would be empty)."
1157
1177
  }
1158
1178
  },
1159
1179
  "required": [],
@@ -1185,7 +1205,7 @@ export const MANIFEST: ToolSpec[] = [
1185
1205
  },
1186
1206
  {
1187
1207
  "name": "list_network_requests",
1188
- "description": "Return correlated network request rows (one per requestId, with status/headers/state) for the target page. By default reads the target's existing buffer; with reload:true it reloads and records a fresh both-domains capture window. Use filterUrl to keep only requests whose URL contains a substring.",
1208
+ "description": "Return correlated network request rows (one per requestId, with status/headers/state) for the target page. By default reads the target's existing buffer; with reload:true it reloads and records a fresh both-domains capture window. Use filterUrl to keep only requests whose URL contains a substring. SERVICE WORKERS (Chrome only, Capability 'worker.targets'): pass target 'worker:<extension-id>' to watch an MV3 extension's BACKGROUND SERVICE WORKER. The outbound request that worker makes to your backend is recorded from CDP's Network domain on the worker's own session, which observes what the code ACTUALLY SENT and so needs neither of the two tricks a caller reaches for first. Both of those fail for ordinary JavaScript reasons rather than service-worker ones: a value bound at module top level is not visible from evaluate_script's global scope (plain lexical scoping, exactly the same in a page), and assigning self.fetch cannot rebind a fetch reference the module already captured. A worker capture has no reload to drive it: reload:true means 'listen for durationMs while the worker runs', so trigger the worker's work (another tool call, an extension message, an alarm) during the window; a worker woken by the call has already run its top-level code by the time the recorder attaches, so a request it makes at startup can be missed. SIDE EFFECT, deliberate and documented: the recording holds a CDP session on the worker, and that KEEPS THE WORKER ALIVE for as long as the capture runs — an MV3 worker is otherwise idle-evicted within seconds — and Chrome resumes evicting it once the capture stops.",
1189
1209
  "inputSchema": {
1190
1210
  "type": "object",
1191
1211
  "properties": {
@@ -1195,11 +1215,11 @@ export const MANIFEST: ToolSpec[] = [
1195
1215
  },
1196
1216
  "target": {
1197
1217
  "type": "string",
1198
- "description": "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
1218
+ "description": "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>' | 'worker:<substring>' -> a service/shared worker whose url contains the substring, which is how you reach an MV3 extension's background service worker (worker:<extension-id> or worker:background.js). The worker: arm is CHROME-ONLY (Capability 'worker.targets'), is refused on Firefox, and a bare worker target id works too."
1199
1219
  },
1200
1220
  "reload": {
1201
1221
  "type": "boolean",
1202
- "description": "Record fresh by reloading the page and capturing for a window. Default false (read the existing buffer)."
1222
+ "description": "Record fresh by reloading the page and capturing for a window. Default false (read the existing buffer). With a worker: target there is nothing to reload: the window records for durationMs while the worker runs, and the session it holds keeps that worker from being idle-evicted until the capture stops."
1203
1223
  },
1204
1224
  "durationMs": {
1205
1225
  "type": "number",
@@ -1208,6 +1228,10 @@ export const MANIFEST: ToolSpec[] = [
1208
1228
  "filterUrl": {
1209
1229
  "type": "string",
1210
1230
  "description": "Only return requests whose URL contains this substring."
1231
+ },
1232
+ "wake": {
1233
+ "type": "boolean",
1234
+ "description": "Worker targets only, and only when a capture is being started (reload:true). Default true: an MV3 extension service worker is idle-evicted after seconds of inactivity and then exists in NO target listing, so the capture asks Chrome to start it (ServiceWorker.startWorker over a page session) and then re-reads the target list to confirm it actually came up. Pass false to fail fast instead of starting anything. Rejected rather than ignored on a page target, on a bare target id (Chrome re-mints a worker's target id when it restarts, so only the url-substring form can name a worker that is not running), and on a read-only call (the buffer is keyed by target id, so a woken worker's buffer would be empty)."
1211
1235
  }
1212
1236
  },
1213
1237
  "required": [],
@@ -1216,7 +1240,7 @@ export const MANIFEST: ToolSpec[] = [
1216
1240
  },
1217
1241
  {
1218
1242
  "name": "get_network_request",
1219
- "description": "Return one network request (matched by exact requestId, else by url substring) including status/headers. Requires at least one of requestId or url (throws otherwise). With includeBody:true the body fetch drives a fresh reload capture and is matched by url ONLY (reload re-mints requestIds, so a carried-over requestId cannot fetch a body, so it returns metadata plus bodyUnavailableReason).",
1243
+ "description": "Return one network request (matched by exact requestId, else by url substring) including status/headers. Requires at least one of requestId or url (throws otherwise). With includeBody:true the body fetch drives a fresh capture (a reload for a page, a listen window for a worker) and is matched by url ONLY (a fresh capture re-mints requestIds, so a carried-over requestId cannot fetch a body, so it returns metadata plus bodyUnavailableReason). SERVICE WORKERS (Chrome only, Capability 'worker.targets'): pass target 'worker:<extension-id>' to watch an MV3 extension's BACKGROUND SERVICE WORKER. The outbound request that worker makes to your backend is recorded from CDP's Network domain on the worker's own session, which observes what the code ACTUALLY SENT and so needs neither of the two tricks a caller reaches for first. Both of those fail for ordinary JavaScript reasons rather than service-worker ones: a value bound at module top level is not visible from evaluate_script's global scope (plain lexical scoping, exactly the same in a page), and assigning self.fetch cannot rebind a fetch reference the module already captured. A worker capture has no reload to drive it: reload:true means 'listen for durationMs while the worker runs', so trigger the worker's work (another tool call, an extension message, an alarm) during the window; a worker woken by the call has already run its top-level code by the time the recorder attaches, so a request it makes at startup can be missed. SIDE EFFECT, deliberate and documented: the recording holds a CDP session on the worker, and that KEEPS THE WORKER ALIVE for as long as the capture runs — an MV3 worker is otherwise idle-evicted within seconds — and Chrome resumes evicting it once the capture stops.",
1220
1244
  "inputSchema": {
1221
1245
  "type": "object",
1222
1246
  "properties": {
@@ -1226,7 +1250,7 @@ export const MANIFEST: ToolSpec[] = [
1226
1250
  },
1227
1251
  "target": {
1228
1252
  "type": "string",
1229
- "description": "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
1253
+ "description": "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>' | 'worker:<substring>' -> a service/shared worker whose url contains the substring, which is how you reach an MV3 extension's background service worker (worker:<extension-id> or worker:background.js). The worker: arm is CHROME-ONLY (Capability 'worker.targets'), is refused on Firefox, and a bare worker target id works too."
1230
1254
  },
1231
1255
  "requestId": {
1232
1256
  "type": "string",
@@ -1243,6 +1267,10 @@ export const MANIFEST: ToolSpec[] = [
1243
1267
  "durationMs": {
1244
1268
  "type": "number",
1245
1269
  "description": "Capture window for the reload-driven body fetch, in milliseconds. Default 2500."
1270
+ },
1271
+ "wake": {
1272
+ "type": "boolean",
1273
+ "description": "Worker targets only, and only when a capture is being started (includeBody together with url). Default true: an MV3 extension service worker is idle-evicted after seconds of inactivity and then exists in NO target listing, so the capture asks Chrome to start it (ServiceWorker.startWorker over a page session) and then re-reads the target list to confirm it actually came up. Pass false to fail fast instead of starting anything. Rejected rather than ignored on a page target, on a bare target id (Chrome re-mints a worker's target id when it restarts, so only the url-substring form can name a worker that is not running), and on a read-only call (the buffer is keyed by target id, so a woken worker's buffer would be empty)."
1246
1274
  }
1247
1275
  },
1248
1276
  "required": [],
package/src/mcp.ts CHANGED
@@ -9,7 +9,9 @@
9
9
  * then fail with a clear error if the browser is unreachable.
10
10
  *
11
11
  * Launch: `bunx -y cdp-toolkit` (or `bun run src/mcp.ts` from a checkout)
12
- * Config: CDP_BASE (default http://127.0.0.1:9222), CDP_TIMEOUT_MS, CDP_ARTIFACT_DIR.
12
+ * Config: CDP_BASE (default http://127.0.0.1:9222), CDP_TIMEOUT_MS, CDP_ARTIFACT_DIR,
13
+ * CDP_FIREFOX_ENDPOINT (attach to a user-launched Firefox instead of spawning one;
14
+ * see backend.ts's ATTACH mode).
13
15
  *
14
16
  * stdout is the JSON-RPC channel, all diagnostics go to stderr only.
15
17
  */
@@ -18,17 +20,19 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
18
20
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
19
21
  import { TOOLS, TOOL_NAMES, BASE } from "./index.ts";
20
22
  import { MANIFEST } from "./manifest.ts";
21
- import { resolveBrowserKind, stripBrowserFlag, getOrCreateFirefoxSession, disposeFirefoxSession } from "./backend.ts";
23
+ import { resolveBackend, getOrCreateFirefoxSession, disposeFirefoxSession } from "./backend.ts";
22
24
  import { disposeBrowserSession } from "./tools/browser-session.ts";
23
25
  import { toolAvailability } from "./capabilities.ts";
24
26
  import { FIREFOX_TOOLS } from "./firefox-tools.ts";
25
27
  import { leaseFromArgs, markLongLivedProcess, withLeaseScope } from "./leases.ts";
26
28
 
27
- const VERSION = "1.9.0";
29
+ const VERSION = "1.9.4";
28
30
 
29
- // --browser is read once at startup (MCP has no per-call notion of backend): explicit flag,
30
- // else CDP_BROWSER env var, else "chrome" (zero behavior change for existing users/configs).
31
- const BROWSER = resolveBrowserKind(stripBrowserFlag(process.argv.slice(2)));
31
+ // Backend + (Firefox only) attach endpoint are read once at startup (MCP has no per-call notion
32
+ // of backend): --browser flag / CDP_BROWSER env, else "chrome" (zero behavior change for existing
33
+ // users/configs); --connect flag / CDP_FIREFOX_ENDPOINT env selects Firefox ATTACH mode and
34
+ // implies browser=firefox (resolveBackend, backend.ts).
35
+ const { kind: BROWSER, endpoint: FIREFOX_ENDPOINT } = resolveBackend(process.argv.slice(2));
32
36
  const AVAILABILITY = toolAvailability(BROWSER);
33
37
  const AVAILABLE_NAMES = new Set<string>(AVAILABILITY.available);
34
38
 
@@ -90,7 +94,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
90
94
  const neutralFn = neutralDispatch[name];
91
95
  if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
92
96
  try {
93
- const session = await getOrCreateFirefoxSession();
97
+ const session = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
94
98
  const result = await neutralFn(session.driver, args);
95
99
  return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
96
100
  } catch (err) {
@@ -105,12 +109,17 @@ auditCoverage();
105
109
  // (CDP_REQUIRE_LEASE) safe to honor here and unsafe in cli.ts. See requireLease.
106
110
  markLongLivedProcess();
107
111
  await server.connect(new StdioServerTransport());
108
- console.error(`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}, ${AVAILABILITY.available.length} tools, CDP_BASE=${BASE}`);
112
+ console.error(
113
+ `[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools, CDP_BASE=${BASE}`,
114
+ );
109
115
 
110
- // Firefox owns a real OS process (see bidi/launch.ts): it must be reaped on every shutdown path,
111
- // not just a clean exit. SIGINT/SIGTERM cover ctrl-C and a supervising client killing the server;
112
- // stdin 'close' covers the normal MCP shutdown (the client closes the stdio pipe). All three are
113
- // idempotent through disposeFirefoxSession(), and a no-op entirely when Firefox was never launched.
116
+ // LAUNCH-mode Firefox owns a real OS process (see bidi/launch.ts): it must be reaped on every
117
+ // shutdown path, not just a clean exit. ATTACH-mode Firefox (CDP_FIREFOX_ENDPOINT / --connect)
118
+ // owns no process here — disposeFirefoxSession() below only ends its BiDi session (freeing
119
+ // Firefox's single session slot) and closes the socket, leaving the user's browser running.
120
+ // SIGINT/SIGTERM cover ctrl-C and a supervising client killing the server; stdin 'close' covers
121
+ // the normal MCP shutdown (the client closes the stdio pipe). All three are idempotent through
122
+ // disposeFirefoxSession(), and a no-op entirely when Firefox was never launched/attached.
114
123
  let shuttingDown = false;
115
124
  async function shutdown(): Promise<void> {
116
125
  if (shuttingDown) return;