@debugg-ai/debugg-ai-mcp 6.1.2 → 6.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -23,6 +23,30 @@ clear error on the first localhost-URL call, not a silent hang. Public-URL calls
|
|
|
23
23
|
non-browser tool, and `test_suite {action:"run"}` (which uses its own dedicated tunnel and
|
|
24
24
|
bypasses Caddy entirely) don't need it either way.
|
|
25
25
|
|
|
26
|
+
## Testing a local app (no Tailscale, no port exposure)
|
|
27
|
+
|
|
28
|
+
Start your app on localhost, then pass the `http://localhost:<port>` URL straight to the tool. The MCP
|
|
29
|
+
opens its own DebuggAI tunnel (one per session, dialed through a local Caddy proxy) — do not expose the
|
|
30
|
+
port with Tailscale, ngrok, or anything else, and do not rewrite the URL to a tunnel hostname yourself.
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
probe_page { "targets": [{ "url": "http://localhost:4040" }] }
|
|
34
|
+
check_app_in_browser { "url": "http://localhost:4040", "description": "Open the home page and confirm the hero renders" }
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Verified 2026-11-10 against a Next.js dev server on `localhost:4040`: `probe_page` returned
|
|
38
|
+
`statusCode 200`, title, 0 console errors, and every asset request resolved via the tunnel.
|
|
39
|
+
Prerequisites: `DEBUGGAI_API_KEY` set for the MCP, the `caddy` binary available (see above), and the
|
|
40
|
+
app actually listening (a dead port fails fast with `LocalServerUnreachable`). Several pages on the
|
|
41
|
+
same port can share one `probe_page` call; mixing ports in one call works but runs serially.
|
|
42
|
+
|
|
43
|
+
`TunnelTrafficBlocked` means the tunnel came up but the health probe through it got no valid response. First
|
|
44
|
+
check you are on the latest MCP (`npx -y @debugg-ai/debugg-ai-mcp@latest`; a stale pinned version is the usual
|
|
45
|
+
cause), then that your dev server answers a plain `curl http://127.0.0.1:<port>/` and does not reject unknown
|
|
46
|
+
`Host` headers (tunnel requests carry the tunnel hostname; a gateway that routes by `Host` needs a catch-all).
|
|
47
|
+
Verified 2026-11-10: servers bound to `127.0.0.1` only (IPv4 Caddy gateways on :4040 and :4043) probe fine via
|
|
48
|
+
`http://localhost:<port>` and `http://127.0.0.1:<port>`.
|
|
49
|
+
|
|
26
50
|
Get an API key at [debugg.ai](https://debugg.ai), then add to your MCP client config:
|
|
27
51
|
|
|
28
52
|
```json
|
|
@@ -608,7 +608,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
608
608
|
// the response) must not POST a cancel for finished work. EXCEPTION: on a
|
|
609
609
|
// poll-deadline timeout the execution may still be running backend-side,
|
|
610
610
|
// so keep it cancellable.
|
|
611
|
-
if (!finalExecution.timedOut)
|
|
611
|
+
if (!finalExecution.timedOut && !finalExecution.stalled)
|
|
612
612
|
currentExecutionUuid = '';
|
|
613
613
|
// Decide retry vs exit: only retry on documented transient signatures
|
|
614
614
|
// AND while we still have budget. Otherwise break and surface whatever
|
|
@@ -617,7 +617,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
617
617
|
break;
|
|
618
618
|
// A poll-deadline timeout (bead 56kd.3) is never retried — surface the
|
|
619
619
|
// partial result instead of burning another 10 minutes.
|
|
620
|
-
if (finalExecution.timedOut)
|
|
620
|
+
if (finalExecution.timedOut || finalExecution.stalled)
|
|
621
621
|
break;
|
|
622
622
|
if (!isTransientWorkflowError(finalExecution))
|
|
623
623
|
break;
|
|
@@ -641,6 +641,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
641
641
|
// real numbers from the deadline path, so the reason is a record of what
|
|
642
642
|
// we observed rather than a fixed sentence
|
|
643
643
|
pollTimeout: timedOut ? finalExecution.pollTimeout : undefined,
|
|
644
|
+
stalled: finalExecution.stalled ? finalExecution.stall : undefined,
|
|
644
645
|
});
|
|
645
646
|
const relayActionTrace = verdict.actionTrace;
|
|
646
647
|
// Evaluation: the backend derives it from the SAME verdict as the headline
|
|
@@ -150,6 +150,13 @@ export function credentialSubstitutions(logins, namedIdentities) {
|
|
|
150
150
|
&& typeof l.username === 'string' && l.username.trim() !== ''
|
|
151
151
|
&& !named.has(norm(l.username)));
|
|
152
152
|
}
|
|
153
|
+
/** Backend agent loop was never started: an internal init bug, not a verdict on the user's page. */
|
|
154
|
+
const BRAIN_UNINITIALISED = /BrowserUseBrain\.initialize\(\)|must be called before decide_next_action/i;
|
|
155
|
+
export function stalledReason(facts) {
|
|
156
|
+
const where = facts.executionUuid ? ` (execution ${facts.executionUuid})` : '';
|
|
157
|
+
const secs = typeof facts.elapsedMs === 'number' ? ` after ${Math.round(facts.elapsedMs / 1000)}s` : '';
|
|
158
|
+
return `The run never started its agent loop${secs} (0 steps taken)${where}. This is a backend/browser-session start-up failure, not a result about your page; retry, and if it repeats report this execution id.`;
|
|
159
|
+
}
|
|
153
160
|
/**
|
|
154
161
|
* Map a workflow execution onto the MCP relay verdict. Never throws.
|
|
155
162
|
*/
|
|
@@ -166,8 +173,9 @@ export function adaptVerdict(execution, opts = {}) {
|
|
|
166
173
|
const rawOutcome = opts.outcomeOverride ?? verdict?.outcome;
|
|
167
174
|
const raw = typeof rawOutcome === 'string' ? rawOutcome.trim() : '';
|
|
168
175
|
const isOurPollTimeout = opts.outcomeOverride === 'timeout';
|
|
176
|
+
const isStalled = !!opts.stalled;
|
|
169
177
|
const backendReason = typeof verdict?.reason === 'string' && verdict.reason.trim() !== '' ? verdict.reason : null;
|
|
170
|
-
if (!isOurPollTimeout && !raw) {
|
|
178
|
+
if (!isOurPollTimeout && !isStalled && !raw) {
|
|
171
179
|
// A run that never attempted a test (platform-98fv.16): no verdict, and the
|
|
172
180
|
// backend's recorded reason, verbatim.
|
|
173
181
|
if (verdict?.skipped === true) {
|
|
@@ -189,7 +197,7 @@ export function adaptVerdict(execution, opts = {}) {
|
|
|
189
197
|
// error covers: the backend's own 'error'/'timeout', every retired non-answer
|
|
190
198
|
// (inconclusive/unverified/unknown/abandoned), anything a newer backend
|
|
191
199
|
// invents, and a verdict that never arrived.
|
|
192
|
-
const outcome = toUserFacingOutcome(raw);
|
|
200
|
+
const outcome = isStalled ? 'error' : toUserFacingOutcome(raw);
|
|
193
201
|
const relay = { outcome };
|
|
194
202
|
if (budget && typeof budget === 'object')
|
|
195
203
|
relay.budget = budget;
|
|
@@ -202,13 +210,19 @@ export function adaptVerdict(execution, opts = {}) {
|
|
|
202
210
|
// any reason we are holding came from a mid-flight poll — it describes a run
|
|
203
211
|
// still in progress, not its result. Relaying it next to outcome='error'
|
|
204
212
|
// would pair someone else's "looks good" with our failure.
|
|
205
|
-
if (
|
|
213
|
+
if (isStalled) {
|
|
214
|
+
relay.reason = stalledReason({ executionUuid: execution?.uuid, ...opts.stalled });
|
|
215
|
+
}
|
|
216
|
+
else if (isOurPollTimeout) {
|
|
206
217
|
relay.reason = pollTimeoutReason({
|
|
207
218
|
executionUuid: execution?.uuid,
|
|
208
219
|
lastStatus: execution?.status,
|
|
209
220
|
...(opts.pollTimeout ?? {}),
|
|
210
221
|
});
|
|
211
222
|
}
|
|
223
|
+
else if (backendReason && BRAIN_UNINITIALISED.test(backendReason)) {
|
|
224
|
+
relay.reason = `${stalledReason({ executionUuid: execution?.uuid, elapsedMs: execution?.durationMs ?? undefined })} Backend said: ${backendReason}`;
|
|
225
|
+
}
|
|
212
226
|
else if (backendReason) {
|
|
213
227
|
relay.reason = backendReason;
|
|
214
228
|
}
|
|
@@ -13,6 +13,15 @@ const POLL_INTERVAL_INITIAL_MS = 1000;
|
|
|
13
13
|
const POLL_INTERVAL_MAX_MS = 5000;
|
|
14
14
|
const POLL_BACKOFF_MULTIPLIER = 1.5;
|
|
15
15
|
const EXECUTION_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
|
|
16
|
+
// A run that is still non-terminal with ZERO agent steps this long after we
|
|
17
|
+
// started polling is not "slow", it is wedged (e.g. the backend brain never
|
|
18
|
+
// initialised, so the agent loop never starts). Stop waiting and say so rather
|
|
19
|
+
// than burning the full run budget. Override with DEBUGGAI_NO_PROGRESS_TIMEOUT_MS.
|
|
20
|
+
const DEFAULT_NO_PROGRESS_TIMEOUT_MS = 4 * 60 * 1000;
|
|
21
|
+
const noProgressTimeoutMs = () => {
|
|
22
|
+
const n = Number(process.env.DEBUGGAI_NO_PROGRESS_TIMEOUT_MS);
|
|
23
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_NO_PROGRESS_TIMEOUT_MS;
|
|
24
|
+
};
|
|
16
25
|
/**
|
|
17
26
|
* Stable dispatch slugs for the three MCP browser workflows (bug clka /
|
|
18
27
|
* bead 56kd.8). All three handlers pin to a STABLE slug so a backend
|
|
@@ -145,6 +154,24 @@ export const createWorkflowsService = (tx) => {
|
|
|
145
154
|
if (signal?.aborted) {
|
|
146
155
|
throw new Error(`Polling cancelled for execution ${executionUuid}`);
|
|
147
156
|
}
|
|
157
|
+
const hasSteps = (execution.state?.stepsTaken ?? 0) > 0 ||
|
|
158
|
+
(execution.nodeExecutions ?? []).some(n => n.nodeType === 'brain.step');
|
|
159
|
+
if (!hasSteps && Date.now() - pollStart >= noProgressTimeoutMs()) {
|
|
160
|
+
Telemetry.capture(TelemetryEvents.WORKFLOW_EXECUTED, {
|
|
161
|
+
status: execution.status,
|
|
162
|
+
success: false,
|
|
163
|
+
outcome: 'stalled',
|
|
164
|
+
stepsTaken: 0,
|
|
165
|
+
durationMs: Date.now() - pollStart,
|
|
166
|
+
pollCount,
|
|
167
|
+
finalIntervalMs: intervalMs,
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
...execution,
|
|
171
|
+
stalled: true,
|
|
172
|
+
stall: { elapsedMs: Date.now() - pollStart, pollCount },
|
|
173
|
+
};
|
|
174
|
+
}
|
|
148
175
|
const sleepMs = intervalMs;
|
|
149
176
|
await new Promise((resolve, reject) => {
|
|
150
177
|
const timer = setTimeout(resolve, sleepMs);
|
|
@@ -101,10 +101,13 @@ export async function probeTunnelHealth(tunnelUrl, opts = {}) {
|
|
|
101
101
|
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
102
102
|
const started = Date.now();
|
|
103
103
|
let last;
|
|
104
|
+
let allClosed = true;
|
|
104
105
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
105
106
|
const { result, retryable } = await probeOverControlChannel(tunnelUrl, timeoutMs, started);
|
|
106
107
|
if (!retryable)
|
|
107
108
|
return result;
|
|
109
|
+
if (!result.detail?.endsWith(' CLOSED'))
|
|
110
|
+
allClosed = false;
|
|
108
111
|
last = result;
|
|
109
112
|
if (attempt < maxAttempts) {
|
|
110
113
|
await sleep(backoff[attempt - 1] ?? backoff[backoff.length - 1] ?? 150);
|
|
@@ -112,6 +115,14 @@ export async function probeTunnelHealth(tunnelUrl, opts = {}) {
|
|
|
112
115
|
}
|
|
113
116
|
// Ladder exhausted on a transient error — the tunnel never came up. Report
|
|
114
117
|
// it: "not ready" that never becomes ready IS unhealthy.
|
|
118
|
+
//
|
|
119
|
+
// A control channel CLOSED on every attempt means the tunnel has no live
|
|
120
|
+
// session at all (a reconnect that never lands, or the transport forgot the
|
|
121
|
+
// tunnel while the manager still holds it). It can never serve a request, so
|
|
122
|
+
// flag it for eviction — otherwise every later call reuses the corpse and
|
|
123
|
+
// fails identically until the MCP process restarts.
|
|
124
|
+
if (allClosed && last)
|
|
125
|
+
last.tunnelErrorCode = 'CONTROL_CHANNEL_CLOSED';
|
|
115
126
|
return last;
|
|
116
127
|
}
|
|
117
128
|
// ─ Control-channel probe ─────────────────────────────────────────────────────
|
|
@@ -69,6 +69,11 @@ const logger = new Logger({ module: 'tunnelDisposition' });
|
|
|
69
69
|
const ENDPOINT_GONE_CODES = Object.freeze([
|
|
70
70
|
'DEBUGG_TUNNEL_OFFLINE',
|
|
71
71
|
'DEBUGG_TUNNEL_UNKNOWN',
|
|
72
|
+
// Client-side verdict, not a server marker: probeTunnelHealth saw the tunnel's
|
|
73
|
+
// own control channel CLOSED on every attempt of its ladder, i.e. there is no
|
|
74
|
+
// session left to carry traffic. Keeping it makes every later call fail the
|
|
75
|
+
// same way (the crochets build lead's "stuck at CLOSED" report).
|
|
76
|
+
'CONTROL_CHANNEL_CLOSED',
|
|
72
77
|
]);
|
|
73
78
|
const ENDPOINT_GONE_LOOKUP = new Set(ENDPOINT_GONE_CODES);
|
|
74
79
|
/** The allowlist, as an immutable list. Read-only by construction. */
|