@debugg-ai/debugg-ai-mcp 3.9.3 → 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 +42 -0
- package/README.md +37 -1
- package/dist/handlers/probePageHandler.js +252 -139
- package/dist/handlers/runTestSuiteHandler.js +70 -51
- package/dist/handlers/testPageChangesHandler.js +32 -1
- package/dist/handlers/triggerCrawlHandler.js +26 -1
- package/dist/services/caddy/caddyProxy.js +611 -0
- package/dist/services/caddy/portLock.js +321 -0
- package/dist/services/ngrok/tunnelManager.js +526 -625
- package/dist/services/ngrok/tunnelRegistry.js +57 -70
- package/dist/services/verdictAdapter.js +2 -0
- package/dist/utils/telemetry.js +16 -0
- package/dist/utils/tunnelContext.js +52 -15
- package/dist/utils/tunnelDisposition.js +9 -17
- package/package.json +3 -1
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
|
-
|
|
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
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
// ──
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
//
|
|
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
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
let
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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:
|
|
290
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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(
|
|
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
|
|
317
|
-
|
|
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
|
}
|