leapfrog-mcp 0.6.7 → 0.6.9

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/dist/index.js CHANGED
@@ -347,6 +347,105 @@ server.registerTool("session_create", {
347
347
  return err(e.message);
348
348
  }
349
349
  });
350
+ // ─── session_create_batch ────────────────────────────────────────────────────
351
+ server.registerTool("session_create_batch", {
352
+ title: "Create Multiple Browser Sessions",
353
+ description: "Create multiple isolated browser sessions concurrently — 5-10x faster than sequential session_create calls. " +
354
+ "Optionally navigate each to a URL. Returns all session IDs. " +
355
+ "A single reflow positions all windows into a unified grid after all sessions are created.",
356
+ inputSchema: z.object({
357
+ sessions: z.array(z.object({
358
+ url: z.string().optional().describe("URL to navigate after creation."),
359
+ headed: z.boolean().optional().describe("Run with visible UI."),
360
+ viewport: z.object({ width: z.number(), height: z.number() }).optional().describe("Custom viewport."),
361
+ profile: z.string().optional().describe("Profile shorthand name for persistent Chrome profile."),
362
+ pinned: z.boolean().optional().describe("Pin to prevent idle timeout."),
363
+ waitUntil: z.enum(["load", "domcontentloaded", "networkidle"]).optional().describe("Wait strategy for navigation."),
364
+ })).min(1).max(15).describe("Array of sessions to create."),
365
+ }).strict(),
366
+ }, async ({ sessions: sessionSpecs }) => {
367
+ try {
368
+ const results = [];
369
+ // Phase 1: Create all sessions concurrently
370
+ const createPromises = sessionSpecs.map(async (spec) => {
371
+ try {
372
+ const session = await sessions.createSession({
373
+ headed: spec.headed,
374
+ viewport: spec.viewport,
375
+ profile: spec.profile,
376
+ });
377
+ if (spec.pinned)
378
+ session.pinned = true;
379
+ // Inject init scripts
380
+ if (LEAP_HUD)
381
+ await session.context.addInitScript(getHUDInitScript(session.name ?? session.id));
382
+ if (LEAP_AUTO_CONSENT)
383
+ await session.context.addInitScript(getConsentDismissScript());
384
+ await session.context.addInitScript(getDetectionInitScript());
385
+ if (LEAP_TRACE)
386
+ await session.context.tracing.start({ screenshots: true, snapshots: true });
387
+ // Claim tile slot (without triggering reflow yet — watcher handles it)
388
+ if (tilesCoord) {
389
+ const detected = tileManager.getScreenSize();
390
+ if (detected)
391
+ await tilesCoord.updateScreenSize(detected.width, detected.height).catch(() => { });
392
+ await tilesCoord.claimSlot(session.id).catch(() => { });
393
+ }
394
+ return { session, spec };
395
+ }
396
+ catch (e) {
397
+ return { error: e.message, spec };
398
+ }
399
+ });
400
+ const created = await Promise.all(createPromises);
401
+ // Phase 2: Navigate sessions that have URLs (concurrently)
402
+ const navPromises = created.map(async (result) => {
403
+ if ("error" in result && result.error) {
404
+ results.push({ id: "(failed)", url: result.spec.url, error: result.error });
405
+ return;
406
+ }
407
+ const { session, spec } = result;
408
+ if (spec.url) {
409
+ try {
410
+ const parsed = new URL(spec.url);
411
+ if (!["http:", "https:"].includes(parsed.protocol)) {
412
+ results.push({ id: session.id, error: `Blocked URL scheme: ${parsed.protocol}` });
413
+ return;
414
+ }
415
+ const ssrfBlock = await checkSSRF(parsed.hostname);
416
+ if (ssrfBlock) {
417
+ results.push({ id: session.id, error: ssrfBlock });
418
+ return;
419
+ }
420
+ await session.page.goto(spec.url, {
421
+ waitUntil: spec.waitUntil || "load",
422
+ timeout: 30000,
423
+ });
424
+ results.push({ id: session.id, url: spec.url });
425
+ }
426
+ catch (e) {
427
+ results.push({ id: session.id, url: spec.url, error: e.message });
428
+ }
429
+ }
430
+ else {
431
+ results.push({ id: session.id });
432
+ }
433
+ });
434
+ await Promise.all(navPromises);
435
+ // Phase 3: Single global reflow
436
+ await reflowWithContext().catch(() => { });
437
+ const stats = sessions.getStats();
438
+ const lines = results.map((r) => r.error
439
+ ? `${r.id} — ERROR: ${r.error}`
440
+ : `${r.id}${r.url ? ` → ${r.url}` : ""}`);
441
+ return ok(`Created ${results.filter((r) => !r.error).length}/${sessionSpecs.length} sessions\n` +
442
+ `Pool: ${stats.active}/${stats.maxSessions} active\n\n` +
443
+ lines.join("\n"));
444
+ }
445
+ catch (e) {
446
+ return err(e.message);
447
+ }
448
+ });
350
449
  // ─── session_list ───────────────────────────────────────────────────────────
351
450
  server.registerTool("session_list", {
352
451
  title: "List Browser Sessions",
@@ -8,7 +8,7 @@ import { generateFingerprint } from "./humanize-fingerprint.js";
8
8
  import { isHumanizeEnabled } from "./humanize-utils.js";
9
9
  import { CdpConnector } from "./cdp-connector.js";
10
10
  import { installSSRFRouteGuard } from "./ssrf.js";
11
- import { tileManager } from "./tile-manager.js";
11
+ import { tileManager, TileManager } from "./tile-manager.js";
12
12
  import * as fs from "fs/promises";
13
13
  import * as path from "path";
14
14
  import * as os from "os";
@@ -565,13 +565,27 @@ export class SessionManager {
565
565
  // Record the screen assignment so reflowAll keeps it on this screen.
566
566
  if (isHeaded && !cdpConnected && detectedScreen) {
567
567
  tileManager.assignSessionScreen(id, detectedScreen);
568
+ // Lock viewport for sessions with explicitly-set viewport so reflow
569
+ // doesn't override it. Unlocked sessions get dynamic viewport sync.
570
+ if (opts?.viewport) {
571
+ tileManager.lockViewport(id);
572
+ }
573
+ const initialBounds = {
574
+ x: detectedScreen.x,
575
+ y: detectedScreen.y,
576
+ width: Math.min(1280, detectedScreen.width),
577
+ height: Math.min(720, detectedScreen.height),
578
+ };
568
579
  try {
569
- await tileManager.positionWindowWithBounds(page, id, {
570
- x: detectedScreen.x,
571
- y: detectedScreen.y,
572
- width: Math.min(1280, detectedScreen.width),
573
- height: Math.min(720, detectedScreen.height),
574
- });
580
+ await tileManager.positionWindowWithBounds(page, id, initialBounds);
581
+ // Dynamic viewport sync on initial placement
582
+ if (!opts?.viewport) {
583
+ const viewport = TileManager.calculateViewportFromBounds(initialBounds);
584
+ try {
585
+ await page.setViewportSize(viewport);
586
+ }
587
+ catch { }
588
+ }
575
589
  }
576
590
  catch (e) {
577
591
  logger.warn("tile.position_failed", { error: e.message });
@@ -30,6 +30,10 @@ declare class TileManager {
30
30
  private windowIds;
31
31
  /** Per-session screen assignment — windows stay on the screen where they were created. */
32
32
  private sessionScreens;
33
+ /** Sessions with explicitly-set viewports — reflow won't override these. */
34
+ private viewportLocked;
35
+ /** Chrome UI height (tabs, address bar, bookmarks). Subtracted from tile height to get content area. */
36
+ static CHROME_HEIGHT: number;
33
37
  configure(opts: {
34
38
  layout: TileLayout;
35
39
  padding: number;
@@ -79,6 +83,15 @@ declare class TileManager {
79
83
  globalIndex: number;
80
84
  }): string[];
81
85
  positionWindow(page: Page, sessionId: string): Promise<void>;
86
+ /** Calculate the page viewport that fits inside a window of the given bounds. */
87
+ static calculateViewportFromBounds(bounds: TileBounds): {
88
+ width: number;
89
+ height: number;
90
+ };
91
+ /** Lock a session's viewport so reflow won't override an explicitly-set viewport. */
92
+ lockViewport(sessionId: string): void;
93
+ /** Check if a session's viewport is locked. */
94
+ isViewportLocked(sessionId: string): boolean;
82
95
  /** Record which screen a session was placed on so reflows keep it there. */
83
96
  assignSessionScreen(sessionId: string, screen: ScreenWorkArea): void;
84
97
  /** Get the screen assigned to a session, or the current global screen. */
@@ -4,7 +4,7 @@
4
4
  // Opt-in via LEAP_TILE=true|grid|master env var.
5
5
  //
6
6
  // Key design:
7
- // - Viewport (screenshot resolution) stays at 1280x720 regardless of tile size
7
+ // - Viewport auto-syncs to tile size during reflow (dynamic viewport)
8
8
  // - Screen detection via page.evaluate() on first headed session, cached
9
9
  // - Accounts for macOS menu bar, Dock, and work area offset
10
10
  // - Launch-time positioning via --window-position/--window-size Chrome args
@@ -23,6 +23,10 @@ class TileManager {
23
23
  windowIds = new Map();
24
24
  /** Per-session screen assignment — windows stay on the screen where they were created. */
25
25
  sessionScreens = new Map();
26
+ /** Sessions with explicitly-set viewports — reflow won't override these. */
27
+ viewportLocked = new Set();
28
+ /** Chrome UI height (tabs, address bar, bookmarks). Subtracted from tile height to get content area. */
29
+ static CHROME_HEIGHT = process.platform === "darwin" ? 72 : 85;
26
30
  // ── Configuration ──────────────────────────────────────────────────
27
31
  configure(opts) {
28
32
  this.enabled = true;
@@ -317,6 +321,21 @@ if (found) { found; } else {
317
321
  // This is a low-level primitive; reflowAll calls it with correct bounds.
318
322
  logger.debug("tile.position_window", { sessionId });
319
323
  }
324
+ /** Calculate the page viewport that fits inside a window of the given bounds. */
325
+ static calculateViewportFromBounds(bounds) {
326
+ return {
327
+ width: Math.max(bounds.width, 400),
328
+ height: Math.max(bounds.height - TileManager.CHROME_HEIGHT, 200),
329
+ };
330
+ }
331
+ /** Lock a session's viewport so reflow won't override an explicitly-set viewport. */
332
+ lockViewport(sessionId) {
333
+ this.viewportLocked.add(sessionId);
334
+ }
335
+ /** Check if a session's viewport is locked. */
336
+ isViewportLocked(sessionId) {
337
+ return this.viewportLocked.has(sessionId);
338
+ }
320
339
  /** Record which screen a session was placed on so reflows keep it there. */
321
340
  assignSessionScreen(sessionId, screen) {
322
341
  this.sessionScreens.set(sessionId, screen);
@@ -386,6 +405,18 @@ if (found) { found; } else {
386
405
  if (!page || page.isClosed())
387
406
  return;
388
407
  await this.positionWindowWithBounds(page, id, bounds);
408
+ // Dynamic viewport sync — resize page viewport to match tile content area.
409
+ // Skip sessions where user explicitly set a viewport (viewport-locked).
410
+ if (!this.viewportLocked.has(id)) {
411
+ const viewport = TileManager.calculateViewportFromBounds(bounds);
412
+ try {
413
+ await page.setViewportSize(viewport);
414
+ logger.debug("tile.viewport_synced", { id, ...viewport });
415
+ }
416
+ catch {
417
+ // Viewport sync is non-fatal
418
+ }
419
+ }
389
420
  });
390
421
  }));
391
422
  // Bring all windows to front after positioning
@@ -416,6 +447,7 @@ if (found) { found; } else {
416
447
  removeSession(sessionId) {
417
448
  this.windowIds.delete(sessionId);
418
449
  this.sessionScreens.delete(sessionId);
450
+ this.viewportLocked.delete(sessionId);
419
451
  }
420
452
  }
421
453
  export const tileManager = new TileManager();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leapfrog-mcp",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "description": "Multi-session browser MCP for AI agents — 36 tools, stealth, persistent auth, code-first scripts, API sniffer, agent intelligence",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",