@debugg-ai/debugg-ai-mcp 3.10.0 → 4.0.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 CHANGED
@@ -5,6 +5,48 @@ All notable changes to the DebuggAI MCP project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased] — BREAKING
9
+
10
+ ### Changed — one ngrok tunnel per session instead of one per local port
11
+
12
+ `check_app_in_browser`, `probe_page`, and `trigger_crawl` now share a **single**
13
+ ngrok tunnel per session (keyed per-caller on HTTP transport, so different
14
+ callers never share state), backed by a local Caddy reverse proxy that gets
15
+ repointed at whichever local port a call targets immediately before dispatch.
16
+ Previously every distinct local port tested in a session opened its own ngrok
17
+ tunnel — N ports meant N billed tunnels. Full design:
18
+ `docs/local-tunnel-multiplexer-architecture-2026-07-31.md`.
19
+
20
+ This replaces (not extends) a Feb 2026 attempt at the same goal that used
21
+ path-prefix routing (`/p/{port}/*`) and broke on any root-absolute asset/API
22
+ path (`/api/...`, `/_next/...` — the default in most modern frameworks). The
23
+ new design routes through a single dynamic upstream with zero path/host
24
+ rewriting, so that failure mode is structurally impossible rather than patched.
25
+ A live-browser regression test for exactly this case now exists and passes
26
+ (`__tests__/integration/caddyProxy.test.ts`).
27
+
28
+ **New runtime dependency, auto-installed:** `check_app_in_browser`/`probe_page`/`trigger_crawl`
29
+ now need the `caddy` binary for any `http://localhost:...` call. It installs itself — the
30
+ `@radically-straightforward/caddy` npm dependency downloads a pinned Caddy release (`2.11.3`) for
31
+ your platform during `npm install`/`npx`, the same pattern this project already uses for the
32
+ `ngrok` binary. Falls back to `CADDY_BIN` (or a system `caddy` on `PATH`) if that download never
33
+ ran (`npm install --ignore-scripts`, offline install) — fails fast with a clear
34
+ `CaddyBinaryNotFoundError` rather than a silent hang if none of those resolve.
35
+ `test_suite {action:"run"}` is unaffected either way (dedicated per-run tunnel, bypasses Caddy).
36
+
37
+ **Deployment precondition for HTTP transport:** multi-replica deployments that
38
+ want the one-tunnel-per-session guarantee need session-affine load-balancer
39
+ routing (consistent hash / sticky on the caller's bearer token). Without it,
40
+ tunnel count degrades to bounded, cost-only over-provisioning (never a
41
+ correctness issue) — see architecture doc §2.1.
42
+
43
+ ### Fixed — two latent response-sanitization bugs surfaced by the above
44
+
45
+ - `probe_page` multi-target batches could cross-attribute a tunnel-hostname
46
+ rewrite from one target's result onto another's once targets started sharing
47
+ a session hostname (they didn't, before this change).
48
+ - `run_test_suite` had no defensive URL-sanitization call at all.
49
+
8
50
  ## [3.5.1]
9
51
 
10
52
  ### Fixed — default OAuth issuer points at the Django AS
package/README.md CHANGED
@@ -10,6 +10,18 @@ AI-powered browser testing via the [Model Context Protocol](https://modelcontext
10
10
 
11
11
  **Requires Node.js 20.20.0 or later** (transitive requirement from `posthog-node@^5.26.0`).
12
12
 
13
+ **Testing `http://localhost:...` URLs requires the `caddy` binary** — `check_app_in_browser`,
14
+ `probe_page`, and `trigger_crawl` tunnel localhost targets through a local Caddy reverse proxy.
15
+ This installs automatically: the `@radically-straightforward/caddy` npm dependency downloads a
16
+ pinned Caddy release for your platform during `npm install`/`npx`, same as this project already
17
+ does for the `ngrok` binary — nothing to install yourself in the normal case. If that download
18
+ never ran (`npm install --ignore-scripts`, an offline/air-gapped install), point `CADDY_BIN` at
19
+ your own install (`brew install caddy` / `apt install caddy` / see
20
+ [caddyserver.com/docs/install](https://caddyserver.com/docs/install)) — missing it surfaces as a
21
+ clear error on the first localhost-URL call, not a silent hang. Public-URL calls, every
22
+ non-browser tool, and `test_suite {action:"run"}` (which uses its own dedicated tunnel and
23
+ bypasses Caddy entirely) don't need it either way.
24
+
13
25
  Get an API key at [debugg.ai](https://debugg.ai), then add to your MCP client config:
14
26
 
15
27
  ```json
@@ -32,6 +44,17 @@ Or with Docker:
32
44
  docker run -i --rm --init -e DEBUGGAI_API_KEY=your_api_key quinnosha/debugg-ai-mcp
33
45
  ```
34
46
 
47
+ The `Dockerfile`'s `npm install` step would pick up `caddy` the same automatic way local installs
48
+ do, in principle — but as of this writing the `Dockerfile` doesn't `COPY` several directories the
49
+ build now needs (`handlers`, `tools`, `types`, `config`) and still references a `tunnels/`
50
+ directory that no longer exists, so a fresh build likely fails before that matters. That's a
51
+ pre-existing gap, unrelated to Caddy. The **currently published** `quinnosha/debugg-ai-mcp` image
52
+ predates the Caddy dependency regardless — localhost-URL calls to
53
+ `check_app_in_browser`/`probe_page`/`trigger_crawl` will fail with `CaddyBinaryNotFoundError`
54
+ inside that image until it's rebuilt (Dockerfile fixed) and republished, or `CADDY_BIN` points at
55
+ one baked in separately. Public-URL calls, the non-browser tools, and `test_suite {action:"run"}`
56
+ are unaffected either way.
57
+
35
58
  ## Tools
36
59
 
37
60
  The server exposes **8** tools: three **Browser** tools plus one **action-based** tool per managed entity. The headline tools are `check_app_in_browser` (full AI agent) and `probe_page` (lightweight no-LLM page probe). The rest — `project`, `environment`, `test_suite`, `test_case`, `executions` — each take an `action` discriminator (e.g. `{"action":"list"}`) that selects the operation. Destructive `delete` actions require confirmation (an elicitation prompt where supported, otherwise `confirm: true`).
@@ -117,7 +140,7 @@ Fires a server-side browser-agent crawl to populate the project's knowledge grap
117
140
  | `includeHtml` | boolean | Return raw HTML in each result (default false) |
118
141
  | `captureScreenshots` | boolean | Return one PNG per target (default true) |
119
142
 
120
- The whole batch shares a single backend execution + browser session + tunnel — 5 URLs in one call is dramatically faster than 5 parallel single-URL calls. Per-URL `error` field preserves batch resilience: a single failed target doesn't fail the others.
143
+ All targets in a batch share one session tunnel, but only same-port (or all-public) batches share a **single** backend execution — 5 URLs on one port in one call is dramatically faster than 5 parallel single-URL calls. A batch that mixes multiple **local** ports decomposes into one sequential backend execution per port group (still one call, still one merged `results[]` in your original order, but N backend round-trips instead of one — slower, not rejected). Per-URL `error` field preserves batch resilience: a single failed target doesn't fail the others.
121
144
 
122
145
  **`networkSummary` aggregation key is `origin + pathname`** — refetch loops (`?n=0..4` repeatedly hitting the same endpoint) collapse into a single entry with the count, so `/api/poll` showing up with `count: 47` is the actionable "infinite refetch loop" signal users originally asked for.
123
146
 
@@ -294,6 +317,19 @@ flow against the advertised authorization server. The bearer is request-scoped
294
317
 
295
318
  stdio installs need none of these.
296
319
 
320
+ **Multi-replica deployments (go/no-go before rollout):** tunnel state (the ngrok session tunnel,
321
+ its Caddy instance, and its port-route lock) is in-process, keyed per caller by a hash of the
322
+ bearer token — there is no cross-process coordination. Running several replicas behind a plain
323
+ round-robin load balancer means one caller's calls can land on different replicas and mint one
324
+ tunnel **per replica they hit** instead of one for the whole session (extra ngrok cost, bounded by
325
+ replica count, self-healing via the existing 55-minute idle auto-shutoff — never a cross-session
326
+ correctness bug, since any single tool call stays on one replica for its whole duration). To get
327
+ the intended "one tunnel per session" behavior on a multi-replica HTTP deployment, configure
328
+ **session-affine routing** at the load balancer (sticky/consistent-hash keyed on the same identity
329
+ `getSessionKey()` derives — in practice, the caller's `Authorization` bearer token). See
330
+ `docs/local-tunnel-multiplexer-architecture-2026-07-31.md` §2.1 for the full reasoning and the
331
+ honest degrade path if this isn't configured.
332
+
297
333
  ## Telemetry
298
334
 
299
335
  The MCP server ships with telemetry enabled by default — an embedded write-only PostHog project key (`phc_*`) so the team can observe cache hit rates, poll cadence, tunnel reliability, and other operational metrics across the install base. Captured events:
@@ -21,12 +21,41 @@ import { TunnelProvisionError } from '../services/tunnels.js';
21
21
  import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
22
22
  import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
23
23
  import { extractLocalhostPort } from '../utils/urlParser.js';
24
- import { buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
24
+ import { buildContext, findExistingTunnel, ensureTunnel, acquirePortRoute, releasePortRoute, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
25
+ import { randomUUID } from 'node:crypto';
25
26
  import { getCachedTemplateUuid, invalidateTemplateCache } from '../utils/handlerCaches.js';
26
27
  import { getPageProbeTemplateSlug } from '../services/workflows.js';
27
28
  import { reaggregateByOriginPath, mapConsoleSlice } from '../utils/harSummarizer.js';
28
29
  import { fetchImageAsBase64, imageContentBlock } from '../utils/imageUtils.js';
29
30
  const logger = new Logger({ module: 'probePageHandler' });
31
+ function groupTargetsByRoute(targetContexts) {
32
+ const groups = [];
33
+ const byKey = new Map();
34
+ for (let i = 0; i < targetContexts.length; i++) {
35
+ const tc = targetContexts[i];
36
+ let key;
37
+ let needsRoute = false;
38
+ if (tc.isLocalhost && tc.tunnelId) {
39
+ const port = extractLocalhostPort(tc.originalUrl);
40
+ const isHttpsLocal = tc.originalUrl.startsWith('https:');
41
+ key = `local:${port}:${isHttpsLocal}`;
42
+ needsRoute = true;
43
+ }
44
+ else {
45
+ // Public URLs (and dev-mode localhost, which never gets a tunnelId)
46
+ // share ONE group — no route to serialize, no reason to split them.
47
+ key = 'public';
48
+ }
49
+ let group = byKey.get(key);
50
+ if (!group) {
51
+ group = { key, indices: [], needsRoute };
52
+ byKey.set(key, group);
53
+ groups.push(group);
54
+ }
55
+ group.indices.push(i);
56
+ }
57
+ return groups;
58
+ }
30
59
  export async function probePageHandler(input, context, rawProgressCallback) {
31
60
  const startTime = Date.now();
32
61
  logger.toolStart('probe_page', input);
@@ -68,6 +97,10 @@ export async function probePageHandler(input, context, rawProgressCallback) {
68
97
  else
69
98
  requestSignal.addEventListener('abort', onAbort, { once: true });
70
99
  }
100
+ // Base id for the shared port-route lock's holder bookkeeping (§2.4) — one
101
+ // per target GROUP, suffixed below, since a multi-port batch acquires the
102
+ // route once per group, sequentially.
103
+ const callId = randomUUID();
71
104
  // Per-target tunnel contexts. Index aligns with input.targets[].
72
105
  const targetContexts = [];
73
106
  // Tunnel keys we provisioned this call (for cleanup if creation fails after key acquired).
@@ -133,30 +166,15 @@ export async function probePageHandler(input, context, rawProgressCallback) {
133
166
  const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
134
167
  throw new Error(`Tunnel creation failed for ${ctx.originalUrl}. (Detail: ${msg})`);
135
168
  }
136
- // Tunnel health probe: catch the IPv4/IPv6 bind / dead-server case
137
- // before committing to a full backend execution.
138
- if (tunneled.targetUrl) {
139
- const health = await probeTunnelHealth(tunneled.targetUrl);
140
- if (!health.healthy) {
141
- const payload = {
142
- error: 'TunnelTrafficBlocked',
143
- message: `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`,
144
- detail: {
145
- code: health.code,
146
- status: health.status,
147
- ngrokErrorCode: health.ngrokErrorCode,
148
- elapsedMs: health.elapsedMs,
149
- },
150
- };
151
- // Evict ONLY on a code proving the endpoint is gone; every other
152
- // failure keeps the tunnel we are already paying for, since a
153
- // teardown+re-provision costs two billed hours and this probe
154
- // cannot tell a dead endpoint from a transient edge flake. See
155
- // utils/tunnelDisposition.ts for the allowlist and the evidence.
156
- disposeUnhealthyTunnel({ health, tunnelId: tunneled.tunnelId, originalUrl: tunneled.originalUrl });
157
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
158
- }
159
- }
169
+ // NOTE: the tunnel health probe used to run right here, immediately
170
+ // after ensureTunnel. Under the session-tunnel + Caddy model that is
171
+ // WRONG — ensureTunnel only confirms the tunnel reaches Caddy, not
172
+ // that Caddy is pointed at THIS target's port yet (that's
173
+ // acquirePortRoute's job, per-group, below). Probing here would
174
+ // probe whatever port Caddy happened to be pointed at a moment ago
175
+ // (often the placeholder, on a session's first call), not this
176
+ // target's port see utils/tunnelContext.ts's acquirePortRoute
177
+ // doc comment. Moved to right after acquirePortRoute succeeds.
160
178
  targetContexts.push(tunneled);
161
179
  }
162
180
  }
@@ -181,140 +199,235 @@ export async function probePageHandler(input, context, rawProgressCallback) {
181
199
  `Ensure the backend has that template seeded and accessible ` +
182
200
  `(GET /api/v1/workflows/?slug=${templateSlug}).`);
183
201
  }
184
- // ── Build contextData (camelCase; axiosTransport snake_cases on the wire) ──
185
- // Backend's browser.setup node (shared with App Evaluation + Raw Crawl
186
- // templates) requires `target_url` (singular). The Page Probe template
187
- // currently uses that node as-is the per-target loop primitive is
188
- // pending. Send BOTH:
189
- // - targetUrl: first target's tunneled URL (satisfies browser.setup
190
- // today; will keep working when the loop wraps it later)
191
- // - targets[]: the full per-URL config for when the loop primitive
192
- // ships and iterates over them
193
- const firstTargetUrl = targetContexts[0]?.targetUrl ?? input.targets[0].url;
194
- const contextData = {
195
- targetUrl: firstTargetUrl,
196
- targets: input.targets.map((t, i) => ({
197
- url: targetContexts[i].targetUrl ?? t.url,
198
- // Send null (not undefined) for optional fields so the field exists
199
- // in the target object even when the caller didn't pass one. Backend
200
- // placeholder resolver was fixed in commit 154e1e69 to type-preserve
201
- // null in single-placeholder substitutions, so null flows through.
202
- waitForSelector: t.waitForSelector ?? null,
203
- waitForLoadState: t.waitForLoadState,
204
- timeoutMs: t.timeoutMs,
205
- })),
206
- // Backend's browser.capture template binds {{include_dom}} and
207
- // {{include_screenshot}} from contextData (verified 2026-04-29).
208
- // The MCP-facing schema keeps `includeHtml` / `captureScreenshots`
209
- // for caller ergonomics; we just map them to what the template wants.
210
- includeDom: input.includeHtml,
211
- includeScreenshot: input.captureScreenshots,
212
- // Keep the original keys too for any downstream node that reads them
213
- // (cheap to send, future-proof against template field-name churn).
214
- includeHtml: input.includeHtml,
215
- captureScreenshots: input.captureScreenshots,
216
- };
217
- // ── Execute ────────────────────────────────────────────────────────────
202
+ // ── Group targets by shared Caddy route (§4 multi-port batch decision) ──
203
+ // Single-group batches (the common case one port, or all-public) behave
204
+ // EXACTLY as before: one repoint (if localhost), one backend execution.
205
+ // Multi-group batches decompose into one sequential
206
+ // acquirePortRoute executeWorkflow(subset) → poll → releasePortRoute
207
+ // cycle per group slower (N backend round-trips) but correct, rather
208
+ // than hard-rejecting a batch shape that worked when it was single-port.
209
+ const groups = groupTargetsByRoute(targetContexts);
210
+ // ── Execute (queuing progress step, once — shared across all groups) ───
218
211
  if (progressCallback) {
219
212
  await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: 'Queuing workflow execution...' });
220
213
  }
221
- const executeResponse = await client.workflows.executeWorkflow(templateUuid, contextData);
222
- const executionUuid = executeResponse.executionUuid;
223
- logger.info(`Probe execution queued: ${executionUuid}`);
224
- // ── Poll ───────────────────────────────────────────────────────────────
225
- let lastCompleted = -1;
226
- const finalExecution = await client.workflows.pollExecution(executionUuid, async (exec) => {
227
- // Keep all active tunnels alive during polling.
228
- for (const tc of targetContexts) {
229
- if (tc.tunnelId)
230
- touchTunnelById(tc.tunnelId);
231
- }
232
- if (!progressCallback)
233
- return;
234
- const completedNodes = (exec.nodeExecutions ?? []).filter(n => n.nodeType === 'browser.capture' && n.status === 'success').length;
235
- if (completedNodes !== lastCompleted) {
236
- lastCompleted = completedNodes;
237
- await progressCallback({
238
- progress: Math.min(progressStep + completedNodes, TOTAL_STEPS - 1),
239
- total: TOTAL_STEPS,
240
- message: `Probed ${completedNodes}/${input.targets.length} target${input.targets.length === 1 ? '' : 's'}...`,
214
+ const results = new Array(input.targets.length);
215
+ const captureDataByIndex = new Array(input.targets.length);
216
+ const executionIds = [];
217
+ const browserSessions = [];
218
+ let lastExecutionDurationMs;
219
+ let completedOffset = 0;
220
+ for (const group of groups) {
221
+ // §2.4: acquire this session's shared Caddy route for the group's port
222
+ // BEFORE dispatching — held for the group's whole execute+poll cycle,
223
+ // not just the repoint. No-op (undefined) for the 'public' group.
224
+ let groupCtx;
225
+ if (group.needsRoute) {
226
+ const representative = targetContexts[group.indices[0]];
227
+ groupCtx = await acquirePortRoute(representative, {
228
+ callId: `${callId}:${group.key}`,
229
+ signal: abortController.signal,
230
+ onWaitProgress: progressCallback
231
+ ? async (info) => {
232
+ await progressCallback({
233
+ progress: Math.min(progressStep + completedOffset, TOTAL_STEPS - 1),
234
+ total: TOTAL_STEPS,
235
+ message: `Waiting for shared tunnel — port ${info.blockingPort} is in use (waited ${Math.round(info.waitedMs / 1000)}s)...`,
236
+ });
237
+ }
238
+ : undefined,
241
239
  });
242
240
  }
243
- }, abortController.signal);
244
- // ── Format response ────────────────────────────────────────────────────
245
- const duration = Date.now() - startTime;
246
- const captureNodes = (finalExecution.nodeExecutions ?? [])
247
- .filter(n => n.nodeType === 'browser.capture')
248
- .sort((a, b) => a.executionOrder - b.executionOrder);
249
- const results = [];
250
- for (let i = 0; i < input.targets.length; i++) {
251
- const target = input.targets[i];
252
- const node = captureNodes[i];
253
- const data = node?.outputData ?? {};
254
- // Backend (post-154e1e69) emits browser.capture output_data with:
255
- // captured_url, status_code, title, load_time_ms,
256
- // console_slice (already per-capture, in {text, level, location, timestamp} shape),
257
- // network_summary (already pre-aggregated by FULL URL,
258
- // in {url, count, methods[], statuses{}, resource_types[]} shape),
259
- // surfer_page_uuid (reference to SurferPage row for screenshot/title/visible_text),
260
- // error
261
- // axiosTransport snake→camel'd at the wire, so JS-side these are
262
- // capturedUrl / consoleSlice / networkSummary / surferPageUuid / etc.
263
- // Re-aggregate networkSummary by origin+pathname so refetch loops
264
- // collapse (preserves the original client-feedback contract).
265
- const result = {
266
- url: target.url, // ORIGINAL caller URL not the tunneled rewrite
267
- finalUrl: typeof data.capturedUrl === 'string' ? data.capturedUrl
268
- : typeof data.finalUrl === 'string' ? data.finalUrl
269
- : typeof data.url === 'string' ? data.url
270
- : target.url,
271
- statusCode: typeof data.statusCode === 'number' ? data.statusCode : 0,
272
- title: typeof data.title === 'string' ? data.title : null,
273
- loadTimeMs: typeof data.loadTimeMs === 'number' ? data.loadTimeMs : 0,
274
- consoleErrors: mapConsoleSlice(Array.isArray(data.consoleSlice) ? data.consoleSlice : []),
275
- networkSummary: reaggregateByOriginPath(Array.isArray(data.networkSummary) ? data.networkSummary : []),
276
- };
277
- if (input.includeHtml && typeof data.html === 'string') {
278
- result.html = data.html;
279
- }
280
- if (typeof data.error === 'string' && data.error) {
281
- result.error = data.error;
241
+ try {
242
+ // Tunnel health probe: catch the IPv4/IPv6 bind / dead-server case
243
+ // before committing to a full backend execution. Must run AFTER
244
+ // acquirePortRoute (above) confirms Caddy is actually pointed at this
245
+ // group's port probing any earlier would probe whatever port Caddy
246
+ // happened to be pointed at a moment ago, not this one (see
247
+ // utils/tunnelContext.ts's acquirePortRoute doc comment).
248
+ if (groupCtx?.targetUrl) {
249
+ const health = await probeTunnelHealth(groupCtx.targetUrl);
250
+ if (!health.healthy) {
251
+ const payload = {
252
+ error: 'TunnelTrafficBlocked',
253
+ message: `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`,
254
+ detail: {
255
+ code: health.code,
256
+ status: health.status,
257
+ ngrokErrorCode: health.ngrokErrorCode,
258
+ elapsedMs: health.elapsedMs,
259
+ },
260
+ };
261
+ // Evict ONLY on a code proving the endpoint is gone; every other
262
+ // failure keeps the tunnel we are already paying for, since a
263
+ // teardown+re-provision costs two billed hours and this probe
264
+ // cannot tell a dead endpoint from a transient edge flake. See
265
+ // utils/tunnelDisposition.ts for the allowlist and the evidence.
266
+ disposeUnhealthyTunnel({ health, tunnelId: groupCtx.tunnelId, originalUrl: groupCtx.originalUrl });
267
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
268
+ }
269
+ }
270
+ // ── Build contextData for THIS group's subset (camelCase; axiosTransport
271
+ // snake_cases on the wire) ──────────────────────────────────────────
272
+ // Backend's browser.setup node (shared with App Evaluation + Raw Crawl
273
+ // templates) requires `target_url` (singular). Send BOTH:
274
+ // - targetUrl: this group's first target's tunneled URL (satisfies
275
+ // browser.setup today; will keep working when the loop wraps it later)
276
+ // - targets[]: the full per-URL config for when the loop primitive
277
+ // ships and iterates over them
278
+ const groupTargets = group.indices.map(i => input.targets[i]);
279
+ const firstTargetUrl = targetContexts[group.indices[0]]?.targetUrl ?? groupTargets[0].url;
280
+ const contextData = {
281
+ targetUrl: firstTargetUrl,
282
+ targets: group.indices.map(i => ({
283
+ url: targetContexts[i].targetUrl ?? input.targets[i].url,
284
+ // Send null (not undefined) for optional fields so the field exists
285
+ // in the target object even when the caller didn't pass one. Backend
286
+ // placeholder resolver was fixed in commit 154e1e69 to type-preserve
287
+ // null in single-placeholder substitutions, so null flows through.
288
+ waitForSelector: input.targets[i].waitForSelector ?? null,
289
+ waitForLoadState: input.targets[i].waitForLoadState,
290
+ timeoutMs: input.targets[i].timeoutMs,
291
+ })),
292
+ // Backend's browser.capture template binds {{include_dom}} and
293
+ // {{include_screenshot}} from contextData (verified 2026-04-29).
294
+ // The MCP-facing schema keeps `includeHtml` / `captureScreenshots`
295
+ // for caller ergonomics; we just map them to what the template wants.
296
+ includeDom: input.includeHtml,
297
+ includeScreenshot: input.captureScreenshots,
298
+ // Keep the original keys too for any downstream node that reads them
299
+ // (cheap to send, future-proof against template field-name churn).
300
+ includeHtml: input.includeHtml,
301
+ captureScreenshots: input.captureScreenshots,
302
+ };
303
+ const executeResponse = await client.workflows.executeWorkflow(templateUuid, contextData);
304
+ const executionUuid = executeResponse.executionUuid;
305
+ executionIds.push(executionUuid);
306
+ logger.info(`Probe execution queued: ${executionUuid} (group ${group.key}, ${group.indices.length} target(s))`);
307
+ // ── Poll ───────────────────────────────────────────────────────────
308
+ let lastCompletedInGroup = -1;
309
+ const finalExecution = await client.workflows.pollExecution(executionUuid, async (exec) => {
310
+ // Keep this group's active tunnels alive during polling.
311
+ for (const i of group.indices) {
312
+ const tunnelId = targetContexts[i].tunnelId;
313
+ if (tunnelId)
314
+ touchTunnelById(tunnelId);
315
+ }
316
+ if (!progressCallback)
317
+ return;
318
+ const completedNodes = (exec.nodeExecutions ?? []).filter(n => n.nodeType === 'browser.capture' && n.status === 'success').length;
319
+ if (completedNodes !== lastCompletedInGroup) {
320
+ lastCompletedInGroup = completedNodes;
321
+ const totalCompleted = completedOffset + completedNodes;
322
+ await progressCallback({
323
+ progress: Math.min(progressStep + totalCompleted, TOTAL_STEPS - 1),
324
+ total: TOTAL_STEPS,
325
+ message: `Probed ${totalCompleted}/${input.targets.length} target${input.targets.length === 1 ? '' : 's'}...`,
326
+ });
327
+ }
328
+ }, abortController.signal);
329
+ lastExecutionDurationMs = finalExecution.durationMs ?? undefined;
330
+ if (finalExecution.browserSession)
331
+ browserSessions.push(finalExecution.browserSession);
332
+ // ── Format this group's results into the shared, ORIGINAL-order arrays ──
333
+ const captureNodes = (finalExecution.nodeExecutions ?? [])
334
+ .filter(n => n.nodeType === 'browser.capture')
335
+ .sort((a, b) => a.executionOrder - b.executionOrder);
336
+ for (let gi = 0; gi < group.indices.length; gi++) {
337
+ const originalIndex = group.indices[gi];
338
+ const target = input.targets[originalIndex];
339
+ const node = captureNodes[gi];
340
+ const data = node?.outputData ?? {};
341
+ captureDataByIndex[originalIndex] = data;
342
+ // Backend (post-154e1e69) emits browser.capture output_data with:
343
+ // captured_url, status_code, title, load_time_ms,
344
+ // console_slice (already per-capture, in {text, level, location, timestamp} shape),
345
+ // network_summary (already pre-aggregated by FULL URL,
346
+ // in {url, count, methods[], statuses{}, resource_types[]} shape),
347
+ // surfer_page_uuid (reference to SurferPage row for screenshot/title/visible_text),
348
+ // error
349
+ // axiosTransport snake→camel'd at the wire, so JS-side these are
350
+ // capturedUrl / consoleSlice / networkSummary / surferPageUuid / etc.
351
+ // Re-aggregate networkSummary by origin+pathname so refetch loops
352
+ // collapse (preserves the original client-feedback contract).
353
+ const result = {
354
+ url: target.url, // ORIGINAL caller URL — not the tunneled rewrite
355
+ finalUrl: typeof data.capturedUrl === 'string' ? data.capturedUrl
356
+ : typeof data.finalUrl === 'string' ? data.finalUrl
357
+ : typeof data.url === 'string' ? data.url
358
+ : target.url,
359
+ statusCode: typeof data.statusCode === 'number' ? data.statusCode : 0,
360
+ title: typeof data.title === 'string' ? data.title : null,
361
+ loadTimeMs: typeof data.loadTimeMs === 'number' ? data.loadTimeMs : 0,
362
+ consoleErrors: mapConsoleSlice(Array.isArray(data.consoleSlice) ? data.consoleSlice : []),
363
+ networkSummary: reaggregateByOriginPath(Array.isArray(data.networkSummary) ? data.networkSummary : []),
364
+ };
365
+ if (input.includeHtml && typeof data.html === 'string') {
366
+ result.html = data.html;
367
+ }
368
+ if (typeof data.error === 'string' && data.error) {
369
+ result.error = data.error;
370
+ }
371
+ if (typeof data.surferPageUuid === 'string' && data.surferPageUuid) {
372
+ result.surferPageUuid = data.surferPageUuid;
373
+ }
374
+ // Bead debugg_ai_mcp-6cfv.6 fix: sanitize each result against ITS
375
+ // OWN target's tunnel context, never the whole accumulated payload.
376
+ // Under the old per-port-tunnel model every target had a distinct
377
+ // hostname, so rewriting the whole payload on each pass was safe by
378
+ // accident. Under the new session-tunnel model every localhost
379
+ // target in a batch can share ONE hostname — sanitizing the whole
380
+ // payload with target A's context would also rewrite target B's own
381
+ // (correct) occurrences, a cross-target data leak. Scoping the
382
+ // sanitize call to `result`'s own subtree, keyed by `targetContexts[originalIndex]`,
383
+ // makes that leak structurally impossible regardless of how many
384
+ // targets share a hostname.
385
+ const tc = targetContexts[originalIndex];
386
+ results[originalIndex] = tc.isLocalhost ? sanitizeResponseUrls(result, tc) : result;
387
+ }
388
+ completedOffset += group.indices.length;
282
389
  }
283
- if (typeof data.surferPageUuid === 'string' && data.surferPageUuid) {
284
- result.surferPageUuid = data.surferPageUuid;
390
+ finally {
391
+ if (groupCtx)
392
+ releasePortRoute(groupCtx);
285
393
  }
286
- results.push(result);
287
394
  }
395
+ // ── Format response ────────────────────────────────────────────────────
396
+ const duration = Date.now() - startTime;
288
397
  const responsePayload = {
289
- executionId: executionUuid,
290
- durationMs: typeof finalExecution.durationMs === 'number' ? finalExecution.durationMs : duration,
398
+ executionId: executionIds[0],
399
+ // Single-group batches (every existing caller) keep the exact prior
400
+ // semantics: the backend's own reported durationMs when present, wall
401
+ // clock otherwise. Multi-group batches use wall clock — summing/picking
402
+ // among several backend-reported per-execution durations would be
403
+ // misleading, since the executions ran sequentially, not concurrently.
404
+ durationMs: groups.length === 1 && typeof lastExecutionDurationMs === 'number'
405
+ ? lastExecutionDurationMs
406
+ : duration,
291
407
  results,
292
408
  };
293
- if (finalExecution.browserSession) {
294
- responsePayload.browserSession = finalExecution.browserSession;
409
+ // Multi-group batches ran more than one backend execution — surface all
410
+ // of their ids/sessions additively, without changing the single-group
411
+ // (single-execution) response shape any existing caller already parses.
412
+ if (executionIds.length > 1)
413
+ responsePayload.executionIds = executionIds;
414
+ if (browserSessions.length === 1) {
415
+ responsePayload.browserSession = browserSessions[0];
295
416
  }
296
- // Sanitize ngrok URLs from the entire payload — agent-authored strings in
297
- // node outputData (titles, HTML, console messages from the page itself)
298
- // can occasionally contain the tunnel URL; rewrite to the original
299
- // localhost origin per tunnel context. For multi-localhost batches we
300
- // run sanitize once per localhost target since each may have its own
301
- // tunnel↔origin mapping.
302
- let sanitizedPayload = responsePayload;
303
- for (const tc of targetContexts) {
304
- if (tc.isLocalhost) {
305
- sanitizedPayload = sanitizeResponseUrls(sanitizedPayload, tc);
306
- }
417
+ else if (browserSessions.length > 1) {
418
+ responsePayload.browserSessions = browserSessions;
307
419
  }
308
420
  logger.toolComplete('probe_page', duration);
309
421
  const responseContent = [
310
- { type: 'text', text: JSON.stringify(sanitizedPayload, null, 2) },
422
+ { type: 'text', text: JSON.stringify(responsePayload, null, 2) },
311
423
  ];
312
424
  // Embed screenshots when captureScreenshots is true. The backend may return
313
425
  // screenshotB64 or a URL-keyed field on browser.capture outputData.
314
426
  if (input.captureScreenshots) {
315
427
  const SCREENSHOT_URL_KEYS = ['screenshotB64', 'screenshot', 'screenshotUrl', 'screenshotUri', 'finalScreenshot'];
316
- for (const node of captureNodes) {
317
- const data = node?.outputData ?? {};
428
+ for (const data of captureDataByIndex) {
429
+ if (!data)
430
+ continue;
318
431
  if (typeof data.screenshotB64 === 'string' && data.screenshotB64) {
319
432
  responseContent.push(imageContentBlock(data.screenshotB64, 'image/png'));
320
433
  }