cdp-toolkit 1.7.0 → 1.8.0
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/CHANGELOG.md +284 -0
- package/CONTRACT.md +5 -1
- package/README.md +35 -13
- package/dist/cli.js +973 -56
- package/dist/index.js +834 -44
- package/dist/mcp.js +1213 -66
- package/package.json +5 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +231 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +472 -25
- package/src/driver.ts +161 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +125 -4
- package/src/leases.ts +1 -1
- package/src/manifest.ts +239 -10
- package/src/mcp.ts +6 -1
- package/src/reap.ts +55 -4
- package/src/shared-tools.ts +268 -27
- package/src/tools/browser-session.ts +246 -0
- package/src/tools/dispatch-mouse.ts +108 -0
- package/src/tools/downloads.ts +143 -0
- package/src/tools/permissions.ts +130 -0
package/src/manifest.ts
CHANGED
|
@@ -25,7 +25,7 @@ export interface ToolSpec {
|
|
|
25
25
|
export const MANIFEST: ToolSpec[] = [
|
|
26
26
|
{
|
|
27
27
|
"name": "list_pages",
|
|
28
|
-
"description": "Enumerate browser page targets via the CDP browser endpoint (GET /json/list). By default returns only page-type tabs; set 'all' to also include workers and background pages. Each entry carries the targetId used as a target selector elsewhere, plus an 'origin' field recording where the tab came from: 'agent' means this toolkit created it (the entry then also carries the creating 'label' and 'createdAt'), and this stays true after the creating agent releases its lease or dies, which is what makes a stray agent tab findable later. 'unknown' means there is no creation record. It never says 'human': the toolkit cannot prove a person opened a tab, so 'unknown' is the honest word for a tab it did not create, one opened before the toolkit ran, or one whose record could not be written. A tab whose record exists but could not be read reports origin 'unknown' plus 'originUnreadable', so a broken record is never mistaken for no record. Under CDP_REQUIRE_LEASE this call also reaps
|
|
28
|
+
"description": "Enumerate browser page targets via the CDP browser endpoint (GET /json/list). By default returns only page-type tabs; set 'all' to also include workers and background pages. Each entry carries the targetId used as a target selector elsewhere, plus an 'origin' field recording where the tab came from: 'agent' means this toolkit created it (the entry then also carries the creating 'label' and 'createdAt'), and this stays true after the creating agent releases its lease or dies, which is what makes a stray agent tab findable later. 'unknown' means there is no creation record. It never says 'human': the toolkit cannot prove a person opened a tab, so 'unknown' is the honest word for a tab it did not create, one opened before the toolkit ran, or one whose record could not be written. A tab whose record exists but could not be read reports origin 'unknown' plus 'originUnreadable', so a broken record is never mistaken for no record. Under CDP_REQUIRE_LEASE this call also reaps abandoned agent tabs, but reap-CLOSE and lease-RECLAIMABLE (list_leases' 'stale') now fire at different times: a lease reads reclaimable the moment its TTL elapses, cheap and reversible since another agent may simply take it, while this call only CLOSES the tab, destructively, after an ADDITIONAL grace period past that TTL (CDP_REAP_GRACE_MS, default 2700000ms/45 minutes, so 60 minutes total after last use) — a tab an agent is mid-build between calls on is not destroyed out from under it. A dead-pid lease is reaped immediately regardless of grace: that process is never coming back. Closed tabs are reported in an additive 'reaped' array ({targetId,label,reason}) present only when something was actually closed; a tab with no lease is never reaped. Every entry under an active, readable lease of this backend also carries 'lease': {label, pid, idleMs, expiresAt, stale} — idleMs and expiresAt are computed fresh on every call (now-lastUsedAt and lastUsedAt+ttlMs), never read off disk, and this is unconditional on 'probe'. Set 'probe' to true to also ping each page-type target's renderer: one bounded (500ms) check per target, never more, so a single wedged tab cannot stall the listing beyond its own budget. This adds 'responsive' (true iff the ping answered in time; false on a timeout, a page-side exception, or an unreachable target — never an error for the whole call) and, where that same round trip found human-attributed input, 'humanActiveMs' (see claim_page and list_leases for what that field means and cannot prove). 'responsive' and 'humanActiveMs' are both ABSENT — never false/null — when 'probe' is not set, and 'responsive' is likewise absent on a backend that cannot answer this ping at all.",
|
|
29
29
|
"inputSchema": {
|
|
30
30
|
"type": "object",
|
|
31
31
|
"properties": {
|
|
@@ -36,6 +36,10 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
36
36
|
"all": {
|
|
37
37
|
"type": "boolean",
|
|
38
38
|
"description": "Include non-page targets (service/shared workers, background pages) when true; otherwise only page-type tabs are listed."
|
|
39
|
+
},
|
|
40
|
+
"probe": {
|
|
41
|
+
"type": "boolean",
|
|
42
|
+
"description": "Ping each page-type target's renderer before returning: one bounded (500ms) Runtime.evaluate per target, never more. Adds 'responsive' to every page-type entry and, where the same round trip finds human-attributed input, 'humanActiveMs'. A wedged or unreachable tab reports responsive:false rather than failing the call. Defaults to false, which is byte-identical to the pre-1.8.0 shape."
|
|
39
43
|
}
|
|
40
44
|
},
|
|
41
45
|
"required": [],
|
|
@@ -117,7 +121,7 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
117
121
|
},
|
|
118
122
|
{
|
|
119
123
|
"name": "navigate_page",
|
|
120
|
-
"description": "Navigate a target page to a URL (Page.navigate)
|
|
124
|
+
"description": "Navigate a target page to a URL (Page.navigate), reload it (reload:true), OR go back/forward in its session history (history:'back'|'forward'), then wait for the load milestone with a bounded timeout so a wedged renderer can't hang. Exactly one of url / reload / history per call. Returns {url,frameId,waitedFor} (plus reloaded:true on a reload, traversed:'back'|'forward' on a history move); waitUntil supports 'load'|'domcontentloaded' only (no 'networkidle'), and there is no auto-snapshot of the new page. Pass reload:true with ignoreCache:true for a hard reload that refetches every subresource (e.g. to pick up a freshly-deployed, non-content-hashed bundle the HTTP cache would serve stale).",
|
|
121
125
|
"inputSchema": {
|
|
122
126
|
"type": "object",
|
|
123
127
|
"properties": {
|
|
@@ -137,6 +141,14 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
137
141
|
"type": "boolean",
|
|
138
142
|
"description": "Reload the current page (Page.reload) instead of navigating to url. Default false."
|
|
139
143
|
},
|
|
144
|
+
"history": {
|
|
145
|
+
"type": "string",
|
|
146
|
+
"enum": [
|
|
147
|
+
"back",
|
|
148
|
+
"forward"
|
|
149
|
+
],
|
|
150
|
+
"description": "Traverse this tab's session history instead of loading a url: 'back' and 'forward' are the browser's Back and Forward buttons. Mutually exclusive with 'url' and 'reload' — passing two of the three is refused by name rather than resolved by precedence. Works on BOTH backends (Chrome: Page.getNavigationHistory + Page.navigateToHistoryEntry; Firefox: browsingContext.traverseHistory) and waits for the same load milestone as an ordinary navigation. Going back from the first entry, or forward from the last, is an ERROR naming the direction — never a silent success that navigated nowhere. The result carries traversed:'back'|'forward' alongside the resulting url."
|
|
151
|
+
},
|
|
140
152
|
"ignoreCache": {
|
|
141
153
|
"type": "boolean",
|
|
142
154
|
"description": "On reload, bypass the HTTP cache (hard reload) so subresources are refetched. Ignored unless reload:true. Default false."
|
|
@@ -374,7 +386,7 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
374
386
|
},
|
|
375
387
|
{
|
|
376
388
|
"name": "click",
|
|
377
|
-
"description": "Click an element via a synthetic mouse press/release at the element's scrolled-into-view bounding-rect center. Target the element with exactly one of 'uid' (a CDP backendDOMNodeId from take_snapshot) or 'selector' (a CSS selector).",
|
|
389
|
+
"description": "Click an element via a synthetic mouse press/release at the element's scrolled-into-view bounding-rect center. Target the element with exactly one of 'uid' (a CDP backendDOMNodeId from take_snapshot) or 'selector' (a CSS selector). clickCount:3 triple-clicks (selects a paragraph/line in most editors). 'modifiers' holds Alt/Control/Meta/Shift for the press and release, like a real modifier-click; on the Firefox backend a non-empty 'modifiers' throws (not yet supported over BiDi), so a modifier click there needs --browser chrome.",
|
|
378
390
|
"inputSchema": {
|
|
379
391
|
"type": "object",
|
|
380
392
|
"properties": {
|
|
@@ -405,7 +417,20 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
405
417
|
},
|
|
406
418
|
"clickCount": {
|
|
407
419
|
"type": "number",
|
|
408
|
-
"description": "Number of clicks: 1 = single (default), 2 = double-click."
|
|
420
|
+
"description": "Number of clicks: 1 = single (default), 2 = double-click, 3 = triple-click."
|
|
421
|
+
},
|
|
422
|
+
"modifiers": {
|
|
423
|
+
"type": "array",
|
|
424
|
+
"items": {
|
|
425
|
+
"type": "string",
|
|
426
|
+
"enum": [
|
|
427
|
+
"Alt",
|
|
428
|
+
"Control",
|
|
429
|
+
"Meta",
|
|
430
|
+
"Shift"
|
|
431
|
+
]
|
|
432
|
+
},
|
|
433
|
+
"description": "Modifier keys held for the click's press and release, e.g. ['Shift'] for a shift-click. Not supported on the Firefox backend: passing a non-empty array there throws."
|
|
409
434
|
}
|
|
410
435
|
},
|
|
411
436
|
"required": [],
|
|
@@ -441,7 +466,7 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
441
466
|
},
|
|
442
467
|
{
|
|
443
468
|
"name": "drag",
|
|
444
|
-
"description": "Drag from a source element to a destination element via synthetic mouse press/move/release
|
|
469
|
+
"description": "Drag from a source element to a destination. 'from' takes exactly one of uid (a CDP backendDOMNodeId from take_snapshot) or selector. The destination is exactly one of 'to' (an element via uid/selector, or an absolute viewport point via x+y) or 'by' ({dx,dy} offset from the source point — sliders, map panning, resize handles). mode:'mouse' (default) dispatches synthetic mouse press/move/release: right for widgets built on raw pointer events, and Chrome does turn it into a real drag, but WHICH drag events reach the page depends on where the interpolated pointer path happens to land — measured on Chrome 151, the default steps:2 delivers ZERO dragover events, so an HTML5 drop zone written the standard way (preventDefault inside dragover) refuses the drop entirely. mode:'html5' performs the drag deterministically instead: Chrome's drag interception hands back the DragData the page's own dragstart built, and the toolkit replays it as dragEnter/dragOver/drop exactly at the destination, so a draggable=\"true\" / dataTransfer drop zone works regardless of the pointer path. mode:'html5' is CHROME-ONLY: it requires capability 'input.html5Drag' and is rejected with a clear error under the Firefox backend, where the tool itself remains available for mouse-mode drags. 'steps' (default 2) sets how many interpolated mouse-move events are dispatched between source and destination; raise it for DnD libraries with a movement threshold or per-frame sampling.",
|
|
445
470
|
"inputSchema": {
|
|
446
471
|
"type": "object",
|
|
447
472
|
"properties": {
|
|
@@ -470,7 +495,7 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
470
495
|
},
|
|
471
496
|
"to": {
|
|
472
497
|
"type": "object",
|
|
473
|
-
"description": "
|
|
498
|
+
"description": "Where the drag ends: exactly one of uid, selector, or x+y. Mutually exclusive with 'by'; exactly one of to/by is required.",
|
|
474
499
|
"properties": {
|
|
475
500
|
"uid": {
|
|
476
501
|
"type": "number",
|
|
@@ -479,18 +504,222 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
479
504
|
"selector": {
|
|
480
505
|
"type": "string",
|
|
481
506
|
"description": "CSS selector for the destination element (resolved via document.querySelector)."
|
|
507
|
+
},
|
|
508
|
+
"x": {
|
|
509
|
+
"type": "number",
|
|
510
|
+
"description": "Absolute viewport x-coordinate to drop at. Must be given together with 'y'. Use this when there is no droppable element to name (a canvas, a slider track)."
|
|
511
|
+
},
|
|
512
|
+
"y": {
|
|
513
|
+
"type": "number",
|
|
514
|
+
"description": "Absolute viewport y-coordinate to drop at. Must be given together with 'x'."
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
"additionalProperties": false
|
|
518
|
+
},
|
|
519
|
+
"by": {
|
|
520
|
+
"type": "object",
|
|
521
|
+
"description": "Drag by an offset from the source element's center instead of to a destination: at least one of dx/dy, the other defaults to 0. Mutually exclusive with 'to'; exactly one of to/by is required. Use for sliders (by:{dx:40}), map panning, and resize handles.",
|
|
522
|
+
"properties": {
|
|
523
|
+
"dx": {
|
|
524
|
+
"type": "number",
|
|
525
|
+
"description": "Horizontal offset in CSS pixels; positive drags RIGHT."
|
|
526
|
+
},
|
|
527
|
+
"dy": {
|
|
528
|
+
"type": "number",
|
|
529
|
+
"description": "Vertical offset in CSS pixels; positive drags DOWN."
|
|
482
530
|
}
|
|
483
531
|
},
|
|
484
532
|
"additionalProperties": false
|
|
533
|
+
},
|
|
534
|
+
"mode": {
|
|
535
|
+
"type": "string",
|
|
536
|
+
"enum": [
|
|
537
|
+
"mouse",
|
|
538
|
+
"html5"
|
|
539
|
+
],
|
|
540
|
+
"description": "'mouse' (default): synthetic mouse press/move/release, right for widgets built on raw pointer events. Chrome does turn this into a real HTML5 drag too, but WHICH drag events reach the page depends on the interpolated pointer path: at the default steps:2, an HTML5 drop zone written the standard way (preventDefault inside dragover) sees zero dragover events and refuses the drop. 'html5': real HTML5 drag-and-drop (dragstart/dragEnter/dragOver/drop with the page's own dataTransfer), deterministic regardless of pointer path. 'html5' is CHROME-ONLY and is rejected with a clear error under the Firefox backend."
|
|
541
|
+
},
|
|
542
|
+
"steps": {
|
|
543
|
+
"type": "number",
|
|
544
|
+
"description": "Number of interpolated mouse-move events dispatched between the source and destination points, evenly spaced, the last landing exactly on the destination. Integer 1-500, default 2 (midpoint then destination). Raise it for DnD libraries with a movement threshold or per-frame sampling."
|
|
485
545
|
}
|
|
486
546
|
},
|
|
487
547
|
"required": [
|
|
488
|
-
"from"
|
|
489
|
-
"to"
|
|
548
|
+
"from"
|
|
490
549
|
],
|
|
491
550
|
"additionalProperties": false
|
|
492
551
|
}
|
|
493
552
|
},
|
|
553
|
+
{
|
|
554
|
+
"name": "scroll",
|
|
555
|
+
"description": "Dispatch a wheel/scroll event at an anchor point: provide at most one of 'uid', 'selector', or 'x'+'y'; omit all three to scroll at the viewport center. An element anchor ('uid' or 'selector') is scrolled into view first, the same as click/hover. At least one of 'deltaX'/'deltaY' is required; positive 'deltaY' scrolls DOWN and positive 'deltaX' scrolls RIGHT (wheel-event convention). Chrome dispatches Input.dispatchMouseEvent{type:'mouseWheel'}; Firefox dispatches WebDriver BiDi's 'wheel' input source. Returns the resolved anchor point ({x,y}) plus the delta actually dispatched.",
|
|
556
|
+
"inputSchema": {
|
|
557
|
+
"type": "object",
|
|
558
|
+
"properties": {
|
|
559
|
+
"lease": {
|
|
560
|
+
"type": "string",
|
|
561
|
+
"description": "Opaque lease token from claim_page. Omit it for a tab this process already holds: under CDP_REQUIRE_LEASE the gate acquires a lease automatically and one acquired that way passes for any later call from the same process. Required when the tab is held by ANOTHER process, and required for a tab claimed explicitly via claim_page or new_page{claim:true}, which always demands its own token. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all."
|
|
562
|
+
},
|
|
563
|
+
"target": {
|
|
564
|
+
"type": "string",
|
|
565
|
+
"description": "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
566
|
+
},
|
|
567
|
+
"uid": {
|
|
568
|
+
"type": "number",
|
|
569
|
+
"description": "CDP backendDOMNodeId of the element to scroll into view and anchor at, obtained from take_snapshot. Provide at most one of uid, selector, or x+y; omit all three to scroll at the viewport center."
|
|
570
|
+
},
|
|
571
|
+
"selector": {
|
|
572
|
+
"type": "string",
|
|
573
|
+
"description": "CSS selector for the element to scroll into view and anchor at (resolved via document.querySelector). Provide at most one of uid, selector, or x+y; omit all three to scroll at the viewport center."
|
|
574
|
+
},
|
|
575
|
+
"x": {
|
|
576
|
+
"type": "number",
|
|
577
|
+
"description": "Absolute viewport x-coordinate to anchor the scroll at. Must be given together with 'y'. Provide at most one of uid, selector, or x+y."
|
|
578
|
+
},
|
|
579
|
+
"y": {
|
|
580
|
+
"type": "number",
|
|
581
|
+
"description": "Absolute viewport y-coordinate to anchor the scroll at. Must be given together with 'x'. Provide at most one of uid, selector, or x+y."
|
|
582
|
+
},
|
|
583
|
+
"deltaX": {
|
|
584
|
+
"type": "number",
|
|
585
|
+
"description": "Horizontal scroll delta; positive scrolls RIGHT. At least one of deltaX/deltaY is required."
|
|
586
|
+
},
|
|
587
|
+
"deltaY": {
|
|
588
|
+
"type": "number",
|
|
589
|
+
"description": "Vertical scroll delta; positive scrolls DOWN. At least one of deltaX/deltaY is required."
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
"required": [],
|
|
593
|
+
"additionalProperties": false
|
|
594
|
+
}
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
"name": "dispatch_mouse",
|
|
598
|
+
"description": "Dispatch exactly one raw mouse event ('move', 'down', or 'up') at absolute viewport coordinates: the toolkit's lowest-level input primitive. Compose move/down/move/up calls yourself to reach anything a physical mouse can do that click/drag's fixed sequences cannot — canvas drag-painting, marquee/rubber-band selection, a custom widget with its own hit-testing. Chrome-only (capability 'input.raw'): absent from tools/list under the Firefox backend, never present-and-throwing.",
|
|
599
|
+
"inputSchema": {
|
|
600
|
+
"type": "object",
|
|
601
|
+
"properties": {
|
|
602
|
+
"lease": {
|
|
603
|
+
"type": "string",
|
|
604
|
+
"description": "Opaque lease token from claim_page. Omit it for a tab this process already holds: under CDP_REQUIRE_LEASE the gate acquires a lease automatically and one acquired that way passes for any later call from the same process. Required when the tab is held by ANOTHER process, and required for a tab claimed explicitly via claim_page or new_page{claim:true}, which always demands its own token. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all."
|
|
605
|
+
},
|
|
606
|
+
"target": {
|
|
607
|
+
"type": "string",
|
|
608
|
+
"description": "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
|
|
609
|
+
},
|
|
610
|
+
"action": {
|
|
611
|
+
"type": "string",
|
|
612
|
+
"enum": [
|
|
613
|
+
"move",
|
|
614
|
+
"down",
|
|
615
|
+
"up"
|
|
616
|
+
],
|
|
617
|
+
"description": "Which raw event to dispatch: 'move' (mouseMoved), 'down' (mousePressed), or 'up' (mouseReleased)."
|
|
618
|
+
},
|
|
619
|
+
"x": {
|
|
620
|
+
"type": "number",
|
|
621
|
+
"description": "Viewport x-coordinate. Required on every call: CDP has no notion of a 'current pointer position' to default from."
|
|
622
|
+
},
|
|
623
|
+
"y": {
|
|
624
|
+
"type": "number",
|
|
625
|
+
"description": "Viewport y-coordinate. Required on every call."
|
|
626
|
+
},
|
|
627
|
+
"button": {
|
|
628
|
+
"type": "string",
|
|
629
|
+
"enum": [
|
|
630
|
+
"left",
|
|
631
|
+
"right",
|
|
632
|
+
"middle"
|
|
633
|
+
],
|
|
634
|
+
"description": "Mouse button: 'left' (default), 'right', or 'middle'."
|
|
635
|
+
},
|
|
636
|
+
"clickCount": {
|
|
637
|
+
"type": "number",
|
|
638
|
+
"description": "Click-run length for a 'down' or 'up' event (2 = the second half of a double-click, 3 = triple-click). Ignored for 'move'; defaults to 1."
|
|
639
|
+
},
|
|
640
|
+
"modifiers": {
|
|
641
|
+
"type": "array",
|
|
642
|
+
"items": {
|
|
643
|
+
"type": "string",
|
|
644
|
+
"enum": [
|
|
645
|
+
"Alt",
|
|
646
|
+
"Control",
|
|
647
|
+
"Meta",
|
|
648
|
+
"Shift"
|
|
649
|
+
]
|
|
650
|
+
},
|
|
651
|
+
"description": "Modifier keys held for this one event, e.g. ['Shift']."
|
|
652
|
+
}
|
|
653
|
+
},
|
|
654
|
+
"required": [
|
|
655
|
+
"action",
|
|
656
|
+
"x",
|
|
657
|
+
"y"
|
|
658
|
+
],
|
|
659
|
+
"additionalProperties": false
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
"name": "wait_for_download",
|
|
664
|
+
"description": "Wait for a file download to finish and return it as a real file on disk: {path,suggestedFilename,bytes,url,target}. The file is written under the artifact dir's downloads/ folder and renamed from Chrome's internal guid to the page's own filename, collision-suffixed (report.csv, report-1.csv, ...). ORDERING RULE, and it is not optional: download capture must be ARMED BEFORE the click that starts the download — call wait_for_download{arm:true} first (it arms and returns immediately, reporting {armed:true,downloadPath,pending}), then click, then call wait_for_download to collect the finished file. This is Chrome's behavior, not a preference: the download-behavior override is per-connection state that Chrome REVERTS the moment the arming client disconnects, and an unarmed headless Chrome denies the download outright, so a download triggered before anything armed is lost with no file anywhere. SIDE EFFECT, browser-global: arming redirects EVERY download in this browser (all tabs, all origins) into the toolkit's downloads directory for as long as this server runs, and downloads no longer land in the user's normal Downloads folder. Because the arm lives on a connection this server process holds open, this is an MCP-server capability: under the one-shot CLI the connection dies with the process and nothing is captured. Chrome-only (capability 'browser.downloads'): absent from tools/list under the Firefox backend, never present-and-throwing, because WebDriver BiDi has no command to redirect a download to a chosen directory.",
|
|
665
|
+
"inputSchema": {
|
|
666
|
+
"type": "object",
|
|
667
|
+
"properties": {
|
|
668
|
+
"lease": {
|
|
669
|
+
"type": "string",
|
|
670
|
+
"description": "Opaque lease token from claim_page. Omit it for a tab this process already holds: under CDP_REQUIRE_LEASE the gate acquires a lease automatically and one acquired that way passes for any later call from the same process. Required when the tab is held by ANOTHER process, and required for a tab claimed explicitly via claim_page or new_page{claim:true}, which always demands its own token. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all."
|
|
671
|
+
},
|
|
672
|
+
"target": {
|
|
673
|
+
"type": "string",
|
|
674
|
+
"description": "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'. A download is browser-scoped, so this names the tab for the lease check and for the echoed target, not which download is returned."
|
|
675
|
+
},
|
|
676
|
+
"arm": {
|
|
677
|
+
"type": "boolean",
|
|
678
|
+
"description": "Arm download capture and return immediately instead of waiting: {armed:true,downloadPath,pending}. Call this BEFORE the click that triggers the download. 'pending' counts downloads that already completed and have not been collected yet, so an arm call doubles as a peek."
|
|
679
|
+
},
|
|
680
|
+
"timeoutMs": {
|
|
681
|
+
"type": "number",
|
|
682
|
+
"description": "How long to wait for a download to complete, in milliseconds. Default 30000. Ignored with arm:true. A download that already completed and was not yet collected is returned immediately, so this only bounds the wait for a download still in flight."
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
"required": [],
|
|
686
|
+
"additionalProperties": false
|
|
687
|
+
}
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
"name": "grant_permissions",
|
|
691
|
+
"description": "Grant browser permissions for an origin up front, so a page asking for geolocation / notifications / clipboard gets an answer instead of showing a prompt no agent can click. 'permissions' takes CDP PermissionType values ('geolocation', 'notifications', 'clipboardReadWrite', 'camera', 'microphone', 'midi', ...); an unknown name is refused by Chrome with the bad value in the message. Returns {granted,origin,target}, or {reset:true,target} for a reset-only call. Grants are keyed by ORIGIN, not by tab: every tab on that origin is affected, including ones opened later. reset:true clears this server's previous grants first (or instead, when no permissions are given) — note CDP's reset is not origin-scoped, so it clears them for every origin at once. The grant lives on a connection this server process holds open and Chrome DISCARDS IT when that connection closes, so this is an MCP-server capability: under the one-shot CLI the grant dies with the process. Chrome-only (capability 'browser.permissions'): absent from tools/list under the Firefox backend, never present-and-throwing.",
|
|
692
|
+
"inputSchema": {
|
|
693
|
+
"type": "object",
|
|
694
|
+
"properties": {
|
|
695
|
+
"lease": {
|
|
696
|
+
"type": "string",
|
|
697
|
+
"description": "Opaque lease token from claim_page. Omit it for a tab this process already holds: under CDP_REQUIRE_LEASE the gate acquires a lease automatically and one acquired that way passes for any later call from the same process. Required when the tab is held by ANOTHER process, and required for a tab claimed explicitly via claim_page or new_page{claim:true}, which always demands its own token. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all."
|
|
698
|
+
},
|
|
699
|
+
"target": {
|
|
700
|
+
"type": "string",
|
|
701
|
+
"description": "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'. Used for the lease check and, when 'origin' is omitted, as the source of the origin to grant for."
|
|
702
|
+
},
|
|
703
|
+
"permissions": {
|
|
704
|
+
"type": "array",
|
|
705
|
+
"items": {
|
|
706
|
+
"type": "string"
|
|
707
|
+
},
|
|
708
|
+
"description": "CDP PermissionType values to grant, e.g. ['geolocation'] or ['clipboardReadWrite','clipboardSanitizedWrite']. Required unless reset:true. An empty array is refused rather than treated as a successful no-op."
|
|
709
|
+
},
|
|
710
|
+
"origin": {
|
|
711
|
+
"type": "string",
|
|
712
|
+
"description": "Origin the grant applies to, e.g. 'https://example.com'. Defaults to the target tab's own origin. Required explicitly when that tab has no grantable origin (data:, blob:, about:blank all serialize to the opaque origin 'null'), which is refused with a message naming the tab's url."
|
|
713
|
+
},
|
|
714
|
+
"reset": {
|
|
715
|
+
"type": "boolean",
|
|
716
|
+
"description": "Clear this server's previous permission grants (Browser.resetPermissions). With 'permissions' it resets FIRST and then grants, so the result is exactly the listed permissions; on its own it is a reset-only call answering {reset:true}. Not scoped to 'origin': CDP resets every origin at once."
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
"required": [],
|
|
720
|
+
"additionalProperties": false
|
|
721
|
+
}
|
|
722
|
+
},
|
|
494
723
|
{
|
|
495
724
|
"name": "fill",
|
|
496
725
|
"description": "Fill an element by focusing it, clearing existing content, then inserting 'value' via Input.insertText (atomic paste-like commit, not per-character keystrokes). Target with exactly one of 'uid' (a CDP backendDOMNodeId from take_snapshot) or 'selector' (a CSS selector).",
|
|
@@ -1369,7 +1598,7 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
1369
1598
|
},
|
|
1370
1599
|
{
|
|
1371
1600
|
"name": "claim_page",
|
|
1372
|
-
"description": "Take exclusive ownership of a browser tab and return an opaque lease token. Two modes. FRESH TAB: with no target and no targetId, opens a new tab (optionally at url) and claims it, so 'give me my own tab' is one call. TAKEOVER: with target, claims a tab that is already open — this is how you work in a tab the human already has open when they ask you to, instead of opening your own — resolving any target selector against the live page list and never creating anything. The answer's 'opened' flag says which happened (true only when this call created the tab), and that same creation record is what release_page consults, so a tab you took over is left open when you release it. Any later tool call against a leased tab must carry the matching token in its 'lease' argument or it is refused, naming the holder. The lease is reclaimable once its owning process dies, its ttlMs elapses without use, or its tab is closed. Refused from the CLI: a CLI invocation is one process per call, so its lease would be reclaimable immediately.",
|
|
1601
|
+
"description": "Take exclusive ownership of a browser tab and return an opaque lease token. Two modes. FRESH TAB: with no target and no targetId, opens a new tab (optionally at url) and claims it, so 'give me my own tab' is one call. TAKEOVER: with target, claims a tab that is already open — this is how you work in a tab the human already has open when they ask you to, instead of opening your own — resolving any target selector against the live page list and never creating anything. The answer's 'opened' flag says which happened (true only when this call created the tab), and that same creation record is what release_page consults, so a tab you took over is left open when you release it. Any later tool call against a leased tab must carry the matching token in its 'lease' argument or it is refused, naming the holder. The lease is reclaimable once its owning process dies, its ttlMs elapses without use, or its tab is closed. Refused from the CLI: a CLI invocation is one process per call, so its lease would be reclaimable immediately. IS SOMEBODY ELSE IN THIS TAB: the answer also carries 'humanActiveMs', milliseconds since input that this server did not dispatch — i.e. since a person last clicked, typed, or scrolled here. null means NO DATA (the tab carries no activity beacon, which is normal the first time anyone claims a person's tab, or every input on it was this server's own); it never means 'no human'. The field is absent entirely on a backend that cannot answer. On a takeover of a tab a human used within the last 30 seconds the answer additionally carries 'contention', a warning string. THE CLAIM IS NEVER REFUSED FOR THIS: taking over a person's tab is what takeover mode is for, so by the time you read the warning you already hold the lease and the tab is yours to drive. It tells you that driving it now means fighting a live person for the keyboard and mouse, so you can open your own tab instead or ask first. Known limits: input inside a cross-origin iframe is invisible to it, and a second MCP server's clicks read as human to this one.",
|
|
1373
1602
|
"inputSchema": {
|
|
1374
1603
|
"type": "object",
|
|
1375
1604
|
"properties": {
|
|
@@ -1423,7 +1652,7 @@ export const MANIFEST: ToolSpec[] = [
|
|
|
1423
1652
|
},
|
|
1424
1653
|
{
|
|
1425
1654
|
"name": "list_leases",
|
|
1426
|
-
"description": "Enumerate every active lease for diagnosis: backend, target id, agent label, pid, createdAt, lastUsedAt, ttlMs, whether the owning process is still alive, and whether the lease is reclaimable and why. Requires no token. Never returns the lease nonce, so this cannot be used to forge a token. A row for a lease file that could not be read or parsed instead carries `unreadable` (the errno, or \"unparseable\"), with label, pid, and the timestamp fields as zero placeholders and stale always false, since stale is what marks a lease free to take and an unreadable one must never read that way. Under CDP_REQUIRE_LEASE this call reaps abandoned agent tabs exactly as list_pages does, reporting them in an additive 'reaped' array and omitting their rows from the listing.",
|
|
1655
|
+
"description": "Enumerate every active lease for diagnosis: backend, target id, agent label, pid, createdAt, lastUsedAt, ttlMs, whether the owning process is still alive, and whether the lease is reclaimable and why. Requires no token. Never returns the lease nonce, so this cannot be used to forge a token. A row for a lease file that could not be read or parsed instead carries `unreadable` (the errno, or \"unparseable\"), with label, pid, and the timestamp fields as zero placeholders and stale always false, since stale is what marks a lease free to take and an unreadable one must never read that way. Under CDP_REQUIRE_LEASE this call reaps abandoned agent tabs exactly as list_pages does (see that tool's description for the grace period between a lease reading reclaimable and its tab actually being closed), reporting them in an additive 'reaped' array and omitting their rows from the listing. A row also carries 'humanActiveMs' — milliseconds since input this server did not dispatch, i.e. since a person last used that tab — WHERE THE TAB CAN ANSWER. The field is ABSENT rather than null when it cannot: no activity beacon on that tab, every input on it accounted for by this server, a row belonging to the other backend, a tab no longer open, or a page that would not evaluate. Absence therefore means 'no answer', never 'nobody is there'. Note that lastUsedAt measures TOOLKIT calls only, so a tab a person has been typing in for ten minutes looks perfectly idle by that field and is exactly the case humanActiveMs exists to expose. Two further fields are computed fresh on every call, never read off disk: 'idleMs' (now minus lastUsedAt) and 'expiresAt' (lastUsedAt plus ttlMs — the same value claim_page already returns under that name). Both are omitted on an 'unreadable' row, whose lastUsedAt and ttlMs are the zeroed placeholders described above.",
|
|
1427
1656
|
"inputSchema": {
|
|
1428
1657
|
"type": "object",
|
|
1429
1658
|
"properties": {},
|
package/src/mcp.ts
CHANGED
|
@@ -19,11 +19,12 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
19
19
|
import { TOOLS, TOOL_NAMES, BASE } from "./index.ts";
|
|
20
20
|
import { MANIFEST } from "./manifest.ts";
|
|
21
21
|
import { resolveBrowserKind, stripBrowserFlag, getOrCreateFirefoxSession, disposeFirefoxSession } from "./backend.ts";
|
|
22
|
+
import { disposeBrowserSession } from "./tools/browser-session.ts";
|
|
22
23
|
import { toolAvailability } from "./capabilities.ts";
|
|
23
24
|
import { FIREFOX_TOOLS } from "./firefox-tools.ts";
|
|
24
25
|
import { leaseFromArgs, markLongLivedProcess, withLeaseScope } from "./leases.ts";
|
|
25
26
|
|
|
26
|
-
const VERSION = "1.
|
|
27
|
+
const VERSION = "1.8.0";
|
|
27
28
|
|
|
28
29
|
// --browser is read once at startup (MCP has no per-call notion of backend): explicit flag,
|
|
29
30
|
// else CDP_BROWSER env var, else "chrome" (zero behavior change for existing users/configs).
|
|
@@ -115,6 +116,10 @@ async function shutdown(): Promise<void> {
|
|
|
115
116
|
if (shuttingDown) return;
|
|
116
117
|
shuttingDown = true;
|
|
117
118
|
await disposeFirefoxSession();
|
|
119
|
+
// The standing browser-endpoint connection behind wait_for_download / grant_permissions (1.8.0
|
|
120
|
+
// Track P3). Unlike Firefox it owns no OS process, so process.exit would collect it anyway; it is
|
|
121
|
+
// closed explicitly so shutdown does not depend on that, and is a no-op when neither tool ran.
|
|
122
|
+
await disposeBrowserSession();
|
|
118
123
|
process.exit(0);
|
|
119
124
|
}
|
|
120
125
|
process.on("SIGINT", () => void shutdown());
|
package/src/reap.ts
CHANGED
|
@@ -18,6 +18,16 @@
|
|
|
18
18
|
* whole matrix of {provenance} x {lease state} x {liveness} with no browser and
|
|
19
19
|
* no filesystem, which is the only way to be confident about a destructive
|
|
20
20
|
* operation. The impure wrapper below does nothing but call it and act.
|
|
21
|
+
*
|
|
22
|
+
* WHY REAPING SPLIT FROM RECLAIMABILITY (1.8.0). Before this version `expired`
|
|
23
|
+
* meant one thing everywhere: at ttlMs, both "another agent may take this
|
|
24
|
+
* lease" AND "this tab gets closed" fired at once. That is wrong for the
|
|
25
|
+
* second question and right for the first: a lease going reclaimable at ttlMs
|
|
26
|
+
* is cheap and reversible, but closing someone's tab is not, and an agent 20
|
|
27
|
+
* minutes into a build between browser calls looks identical to one that died.
|
|
28
|
+
* leases.ts's staleReason keeps deciding reclaimability at ttlMs, unchanged;
|
|
29
|
+
* this module alone additionally waits reapGraceMs past that before treating
|
|
30
|
+
* `expired` as grounds to close anything. See staleAgentTabs' condition 4.
|
|
21
31
|
*/
|
|
22
32
|
import type { BrowserDriver } from "./driver.ts";
|
|
23
33
|
import { listLeases, releaseLeaseFor, type LeaseBackend, type LeaseSummary } from "./leases.ts";
|
|
@@ -44,12 +54,19 @@ export interface ReapInput {
|
|
|
44
54
|
livePageIds: readonly string[];
|
|
45
55
|
origins: ReadonlyMap<string, OriginSummary>;
|
|
46
56
|
leases: readonly LeaseSummary[];
|
|
57
|
+
/** Clock for the grace check below. Threaded in rather than read internally
|
|
58
|
+
* (Date.now()) so this function stays pure and the whole matrix is testable
|
|
59
|
+
* with no wall clock. */
|
|
60
|
+
now: number;
|
|
61
|
+
/** How much longer an `expired` lease survives before its tab is destroyed,
|
|
62
|
+
* on top of the ttlMs that already made it reclaimable. See reapGraceMs(). */
|
|
63
|
+
reapGraceMs: number;
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
/**
|
|
50
67
|
* Select the tabs that may be closed. PURE: no I/O, no driver, no clock.
|
|
51
68
|
*
|
|
52
|
-
* All
|
|
69
|
+
* All conditions must hold, and condition 4 (the grace check) is new in 1.8.0:
|
|
53
70
|
*
|
|
54
71
|
* 1. the lease row is for this backend and READABLE. An `unreadable` row is
|
|
55
72
|
* never reaped for the same reason listLeases reports it as stale:false -
|
|
@@ -59,7 +76,17 @@ export interface ReapInput {
|
|
|
59
76
|
* the tab is already closed and there is nothing to do, and obviously not
|
|
60
77
|
* `false`.
|
|
61
78
|
* 3. the target is actually still open, per livePageIds.
|
|
62
|
-
* 4.
|
|
79
|
+
* 4. THE GRACE WINDOW. `stale === "expired"` alone is the RECLAIMABILITY
|
|
80
|
+
* signal (see leases.ts's staleReason): it fires at ttlMs and is cheap and
|
|
81
|
+
* reversible — another agent may simply take the lease. Closing the tab is
|
|
82
|
+
* a different, destructive question, and it does not fire until an
|
|
83
|
+
* ADDITIONAL reapGraceMs has elapsed past that same ttlMs, so an agent that
|
|
84
|
+
* is 20 minutes into a build between browser calls gets a window to come
|
|
85
|
+
* back before its tab is destroyed. `dead-pid` skips this check entirely:
|
|
86
|
+
* a process that no longer exists is never coming back to reclaim
|
|
87
|
+
* anything, so there is nothing to wait for, and that is why dead-pid stays
|
|
88
|
+
* immediate while expired does not.
|
|
89
|
+
* 5. the toolkit OPENED it. An agent-created tab with NO lease is deliberately
|
|
63
90
|
* never reaped: that is exactly what new_page produces for a user who never
|
|
64
91
|
* touches leases, and closing those would be a data-loss bug rather than a
|
|
65
92
|
* cleanup. Only a tab that was claimed and then abandoned qualifies.
|
|
@@ -71,6 +98,7 @@ export function staleAgentTabs(input: ReapInput): ReapedTab[] {
|
|
|
71
98
|
if (row.backend !== input.backend) continue;
|
|
72
99
|
if (row.unreadable !== undefined) continue;
|
|
73
100
|
if (row.stale !== "dead-pid" && row.stale !== "expired") continue;
|
|
101
|
+
if (row.stale === "expired" && input.now - row.lastUsedAt <= row.ttlMs + input.reapGraceMs) continue;
|
|
74
102
|
if (!live.has(row.targetId)) continue;
|
|
75
103
|
const origin = input.origins.get(row.targetId);
|
|
76
104
|
if (origin === undefined || origin.unreadable !== undefined) continue;
|
|
@@ -79,6 +107,24 @@ export function staleAgentTabs(input: ReapInput): ReapedTab[] {
|
|
|
79
107
|
return out;
|
|
80
108
|
}
|
|
81
109
|
|
|
110
|
+
/**
|
|
111
|
+
* 45 minutes. Combined with the default 15-minute lease TTL (leases.ts), the
|
|
112
|
+
* total fuse from an agent's last touch to its tab being destroyed is 60
|
|
113
|
+
* minutes — generous, because the cost of guessing wrong here is a tab an
|
|
114
|
+
* agent was still using getting closed out from under it, not merely a lease
|
|
115
|
+
* another agent could have taken.
|
|
116
|
+
*/
|
|
117
|
+
export const DEFAULT_REAP_GRACE_MS = 2_700_000;
|
|
118
|
+
|
|
119
|
+
/** Read per call, not at module load, so a test can redirect the value — same
|
|
120
|
+
* rule as leaseTtlMs() in leases.ts. Unlike a TTL, zero is a legitimate
|
|
121
|
+
* override (disables the grace period, reverting to at-TTL reaping), so the
|
|
122
|
+
* validity check is `>= 0` rather than `> 0`. */
|
|
123
|
+
export function reapGraceMs(): number {
|
|
124
|
+
const raw = Number(process.env.CDP_REAP_GRACE_MS);
|
|
125
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_REAP_GRACE_MS;
|
|
126
|
+
}
|
|
127
|
+
|
|
82
128
|
/**
|
|
83
129
|
* Select, close, and drop the leases. Returns ONLY the tabs actually closed.
|
|
84
130
|
*
|
|
@@ -92,6 +138,11 @@ export function staleAgentTabs(input: ReapInput): ReapedTab[] {
|
|
|
92
138
|
* on the next listing. One lifetime rule for origin records, not three.
|
|
93
139
|
*/
|
|
94
140
|
export async function reapStaleAgentTabs(driver: BrowserDriver, backend: LeaseBackend): Promise<ReapedTab[]> {
|
|
141
|
+
// ONE clock reading, shared with listLeases' own staleReason classification
|
|
142
|
+
// below: the "expired" verdict a lease reads at and the grace check applied
|
|
143
|
+
// to it here must agree on what "now" was, or the two could disagree at the
|
|
144
|
+
// margin.
|
|
145
|
+
const now = Date.now();
|
|
95
146
|
// The PAGE-ONLY listing: see ReapInput.livePageIds.
|
|
96
147
|
const livePageIds = (await driver.listPages()).map((p) => p.id);
|
|
97
148
|
const [origins, leases] = await Promise.all([
|
|
@@ -100,9 +151,9 @@ export async function reapStaleAgentTabs(driver: BrowserDriver, backend: LeaseBa
|
|
|
100
151
|
// listing); doing it here too would delete records for targets that are
|
|
101
152
|
// live but filtered out of the page-only view.
|
|
102
153
|
originIndex(backend, undefined),
|
|
103
|
-
listLeases({ liveIds: livePageIds, liveBackend: backend }).catch(() => [] as LeaseSummary[]),
|
|
154
|
+
listLeases({ now, liveIds: livePageIds, liveBackend: backend }).catch(() => [] as LeaseSummary[]),
|
|
104
155
|
]);
|
|
105
|
-
const candidates = staleAgentTabs({ backend, livePageIds, origins, leases });
|
|
156
|
+
const candidates = staleAgentTabs({ backend, livePageIds, origins, leases, now, reapGraceMs: reapGraceMs() });
|
|
106
157
|
const closed: ReapedTab[] = [];
|
|
107
158
|
for (const c of candidates) {
|
|
108
159
|
const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
|