@debugg-ai/debugg-ai-mcp 1.0.65 → 1.0.66

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
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Fixed — tunnel provisioning flakiness surfaces as user-facing errors
11
+
12
+ - `check_app_in_browser` / `trigger_crawl` now automatically retry transient tunnel-provision failures (5xx, 408, 429, network errors like ECONNRESET) with exponential backoff (500ms → 1500ms → 3000ms, 3 attempts). Previously a single ngrok/backend blip forced the caller to manually retry the tool call. Bead `7nx`.
13
+ - Tunnel-provision error messages now carry structured diagnostic context — HTTP status, ngrok error code, backend `x-request-id`, retryable flag — so users have something actionable to file bug reports against instead of opaque "Tunnel setup failed". Bead `5wz`.
14
+ - 4xx auth/quota errors (401/403/404) fail fast without retry to avoid loops against a bad API key.
15
+ - New posthog telemetry event `tunnel.provision_retry` fires per retry attempt with outcome, status, and diagnostic fields so flaky provision rates become measurable.
16
+
10
17
  ## [1.0.64] - 2026-04-23
11
18
 
12
19
  > **⚠️ Semver violation — this is functionally a major release shipped as a patch.**
@@ -8,6 +8,7 @@ import { Logger } from '../utils/logger.js';
8
8
  import { handleExternalServiceError } from '../utils/errors.js';
9
9
  import { fetchImageAsBase64, imageContentBlock } from '../utils/imageUtils.js';
10
10
  import { DebuggAIServerClient } from '../services/index.js';
11
+ import { TunnelProvisionError } from '../services/tunnels.js';
11
12
  import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
12
13
  import { detectRepoName } from '../utils/gitContext.js';
13
14
  const logger = new Logger({ module: 'testPageChangesHandler' });
@@ -96,14 +97,15 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
96
97
  else {
97
98
  let tunnel;
98
99
  try {
99
- tunnel = await client.tunnels.provision();
100
+ tunnel = await client.tunnels.provisionWithRetry();
100
101
  }
101
102
  catch (provisionError) {
102
103
  const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
104
+ const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
103
105
  throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
104
106
  `The remote browser needs a secure tunnel to reach your local dev server. ` +
105
107
  `Make sure your dev server is running on the specified port and try again. ` +
106
- `(Detail: ${msg})`);
108
+ `(Detail: ${msg})${diag}`);
107
109
  }
108
110
  keyId = tunnel.keyId;
109
111
  try {
@@ -14,6 +14,7 @@ import { config } from '../config/index.js';
14
14
  import { Logger } from '../utils/logger.js';
15
15
  import { handleExternalServiceError } from '../utils/errors.js';
16
16
  import { DebuggAIServerClient } from '../services/index.js';
17
+ import { TunnelProvisionError } from '../services/tunnels.js';
17
18
  import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
18
19
  const logger = new Logger({ module: 'triggerCrawlHandler' });
19
20
  const TEMPLATE_KEYWORD = 'raw crawl';
@@ -61,13 +62,14 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
61
62
  else {
62
63
  let tunnel;
63
64
  try {
64
- tunnel = await client.tunnels.provision();
65
+ tunnel = await client.tunnels.provisionWithRetry();
65
66
  }
66
67
  catch (provisionError) {
67
68
  const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
69
+ const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
68
70
  throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
69
71
  `The remote browser needs a secure tunnel to reach your local dev server. ` +
70
- `(Detail: ${msg})`);
72
+ `(Detail: ${msg})${diag}`);
71
73
  }
72
74
  keyId = tunnel.keyId;
73
75
  ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
@@ -3,11 +3,97 @@
3
3
  * Provisions short-lived ngrok keys for MCP-managed tunnel setup.
4
4
  * Called before executeWorkflow so the tunnel URL is known before execution starts.
5
5
  */
6
- export const createTunnelsService = (tx) => ({
7
- async provision(purpose = 'workflow') {
8
- const response = await tx.post('api/v1/tunnels/', { purpose });
6
+ import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
7
+ /**
8
+ * Typed error thrown by provision() when the backend/ngrok path fails.
9
+ * Carries diagnostic fields a retry wrapper (bead 7nx) can use to decide
10
+ * whether to retry, and that handler error messages can surface so users
11
+ * have something actionable to file bug reports against.
12
+ */
13
+ export class TunnelProvisionError extends Error {
14
+ status;
15
+ code;
16
+ requestId;
17
+ networkCode;
18
+ retryable;
19
+ constructor(opts) {
20
+ super(opts.message);
21
+ this.name = 'TunnelProvisionError';
22
+ this.status = opts.status;
23
+ this.code = opts.code;
24
+ this.requestId = opts.requestId;
25
+ this.networkCode = opts.networkCode;
26
+ this.retryable = opts.retryable;
27
+ }
28
+ /**
29
+ * Stable one-line suffix for user-facing error messages.
30
+ * Example: '(status: 503, request-id: abc123, retryable)' or '(network: ECONNRESET, retryable)'.
31
+ */
32
+ diagnosticSuffix() {
33
+ const parts = [];
34
+ if (this.status != null)
35
+ parts.push(`status: ${this.status}`);
36
+ if (this.code)
37
+ parts.push(`code: ${this.code}`);
38
+ if (this.requestId)
39
+ parts.push(`request-id: ${this.requestId}`);
40
+ if (this.networkCode)
41
+ parts.push(`network: ${this.networkCode}`);
42
+ parts.push(this.retryable ? 'retryable' : 'not-retryable');
43
+ return `(${parts.join(', ')})`;
44
+ }
45
+ }
46
+ /**
47
+ * Classify an axios-interceptor-rewritten error (or any thrown Error) into a
48
+ * TunnelProvisionError with retryable semantics. Called from provision().
49
+ *
50
+ * Retryable: 5xx, 408 (request timeout), 429 (rate limit), and any network
51
+ * error (no response received — ECONNRESET / ECONNREFUSED / timeout).
52
+ * Not retryable: 4xx other than 408/429 — those indicate auth/quota/input
53
+ * problems that won't self-heal on the same API key.
54
+ */
55
+ export function classifyProvisionError(err) {
56
+ const e = err;
57
+ const message = e?.message ? String(e.message) : 'Tunnel provisioning failed';
58
+ const status = typeof e?.statusCode === 'number' ? e.statusCode : undefined;
59
+ const data = e?.responseData;
60
+ const code = data && typeof data === 'object' && typeof data.code === 'string' ? data.code : undefined;
61
+ const headers = e?.responseHeaders;
62
+ const requestId = headers && typeof headers === 'object'
63
+ ? ((headers['x-request-id'] || headers['X-Request-Id']) ?? undefined)
64
+ : undefined;
65
+ const networkCode = typeof e?.networkCode === 'string' ? e.networkCode : undefined;
66
+ let retryable;
67
+ if (status == null) {
68
+ retryable = true;
69
+ }
70
+ else if (status >= 500) {
71
+ retryable = true;
72
+ }
73
+ else if (status === 408 || status === 429) {
74
+ retryable = true;
75
+ }
76
+ else {
77
+ retryable = false;
78
+ }
79
+ return new TunnelProvisionError({ message, status, code, requestId, networkCode, retryable });
80
+ }
81
+ const DEFAULT_BACKOFF_MS = [500, 1500, 3000];
82
+ const DEFAULT_MAX_ATTEMPTS = 3;
83
+ export const createTunnelsService = (tx) => {
84
+ async function provision(purpose = 'workflow') {
85
+ let response;
86
+ try {
87
+ response = await tx.post('api/v1/tunnels/', { purpose });
88
+ }
89
+ catch (err) {
90
+ throw classifyProvisionError(err);
91
+ }
9
92
  if (!response?.tunnelId || !response?.tunnelKey) {
10
- throw new Error('Tunnel provisioning failed: missing tunnelId or tunnelKey in response');
93
+ throw new TunnelProvisionError({
94
+ message: 'Tunnel provisioning returned a success response missing tunnelId or tunnelKey',
95
+ retryable: false,
96
+ });
11
97
  }
12
98
  return {
13
99
  tunnelId: response.tunnelId,
@@ -15,5 +101,48 @@ export const createTunnelsService = (tx) => ({
15
101
  keyId: response.keyId,
16
102
  expiresAt: response.expiresAt,
17
103
  };
18
- },
19
- });
104
+ }
105
+ async function provisionWithRetry(opts = {}) {
106
+ const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
107
+ const backoff = opts.backoffMs ?? DEFAULT_BACKOFF_MS;
108
+ const sleep = opts.sleepFn ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
109
+ let lastErr;
110
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
111
+ try {
112
+ const result = await provision(opts.purpose);
113
+ if (attempt > 1) {
114
+ Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
115
+ attempt,
116
+ outcome: 'success',
117
+ });
118
+ }
119
+ return result;
120
+ }
121
+ catch (err) {
122
+ const e = err instanceof TunnelProvisionError ? err : classifyProvisionError(err);
123
+ lastErr = e;
124
+ const isLastAttempt = attempt >= maxAttempts;
125
+ const willRetry = e.retryable && !isLastAttempt;
126
+ Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
127
+ attempt,
128
+ outcome: willRetry ? 'will-retry' : 'giving-up',
129
+ status: e.status,
130
+ code: e.code,
131
+ requestId: e.requestId,
132
+ networkCode: e.networkCode,
133
+ retryable: e.retryable,
134
+ });
135
+ if (!willRetry)
136
+ throw e;
137
+ const waitMs = backoff[attempt - 1] ?? backoff[backoff.length - 1] ?? 0;
138
+ await sleep(waitMs);
139
+ }
140
+ }
141
+ // Unreachable in practice — loop always returns or throws.
142
+ throw lastErr ?? new TunnelProvisionError({
143
+ message: 'provisionWithRetry exhausted attempts without a classified error',
144
+ retryable: false,
145
+ });
146
+ }
147
+ return { provision, provisionWithRetry };
148
+ };
@@ -35,9 +35,14 @@ export class AxiosTransport {
35
35
  const newErr = new Error(String(msg));
36
36
  newErr.statusCode = err.response?.status;
37
37
  newErr.responseData = data;
38
+ newErr.responseHeaders = err.response?.headers;
38
39
  return Promise.reject(newErr);
39
40
  }
40
- return Promise.reject(new Error(err.message || 'Unknown Axios error'));
41
+ // Network-class error (no response received) — preserve err.code (ECONNRESET, etc.)
42
+ const networkErr = new Error(err.message || 'Unknown Axios error');
43
+ if (err.code)
44
+ networkErr.networkCode = err.code;
45
+ return Promise.reject(networkErr);
41
46
  });
42
47
  // Request → snake_case
43
48
  this.axios.interceptors.request.use((cfg) => {
@@ -53,5 +53,6 @@ export const TelemetryEvents = {
53
53
  TOOL_FAILED: 'tool.failed',
54
54
  WORKFLOW_EXECUTED: 'workflow.executed',
55
55
  TUNNEL_PROVISIONED: 'tunnel.provisioned',
56
+ TUNNEL_PROVISION_RETRY: 'tunnel.provision_retry',
56
57
  TUNNEL_STOPPED: 'tunnel.stopped',
57
58
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "1.0.65",
3
+ "version": "1.0.66",
4
4
  "description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
5
5
  "type": "module",
6
6
  "bin": {