@debugg-ai/debugg-ai-mcp 6.0.3 → 6.1.1

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,47 @@ 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]
9
+
10
+ ### Changed — test_suite `run` retired (platform-g3am)
11
+ - The server removed its suite-run route, so `run` only produced a raw HTML 404. It is gone from the tool's actions and description; a call from an older client now returns an `ActionRetired` error naming `results`, `check_app_in_browser` and `trigger_crawl`, without contacting the server.
12
+
13
+ ### Fixed — REST client now CONNECT-tunnels through HTTPS_PROXY / honors NO_PROXY (platform-y2ob.3)
14
+ - The API client (axios) sent an absolute-URI request to the proxy, which CONNECT-only proxies reject with HTTP 405, so a proxied user failed before any tunnel was attempted. It now opens a CONNECT tunnel via the same proxy selection and NO_PROXY matching as the tunnel websocket. A 407 names the proxy host (never its credentials). No proxy, or a NO_PROXY match: unchanged.
15
+
16
+ ### Fixed — a failed project lookup no longer reads as "no project linked" (platform-y2ob.4)
17
+ - check_app_in_browser and trigger_crawl swallowed any error from the repo-to-project lookup and then said no project is linked to the repo. A lookup error now returns `ProjectLookupFailed` with the HTTP status and the backend's message; only a successful lookup that finds nothing says `ProjectRequired`.
18
+
19
+ ### Fixed — probe_page no longer hides a failed run; check_app_in_browser ProjectRequired says what to pass (platform-y2ob.1, platform-y2ob.2)
20
+
21
+ - `probe_page`: when the backend records the execution as failed/error, the response is `isError` and carries the recorded `status`, `errorMessage`, `agentError` and `errorInfo` verbatim, and each result's `error` is that message. A target that returns no page data now reports the execution status and that no page data came back instead of null fields with no reason.
22
+ - `check_app_in_browser`: `ProjectRequired` now uses the same text as `trigger_crawl` (shared in `utils/projectRequired.ts`), naming `projectUuid` and `repoName`.
23
+
24
+ ### Fixed — tunnel provision reports its client version; no downgrade advice (platform-65o4)
25
+
26
+ - `POST api/v1/tunnels/` now sends `client_version`, so the backend's `Tunnel.client_version` is no longer empty and failing client versions can be told apart.
27
+ - The two error branches that said "Pin @debugg-ai/debugg-ai-mcp@4.4.1" now say to run `npx @debugg-ai/debugg-ai-mcp@latest` and restart the MCP client; a backend that still answers ngrok is reported as out of date.
28
+ - The live tunnel test accepts a loopback `ws://` relay, so it runs against a local stack.
29
+
30
+ ### Fixed — foreign tunnel addresses no longer rewritten to localhost (platform-o7wv)
31
+ - `replaceTunnelUrls` / `sanitizeResponseUrls` rewrote EVERY `*.tunnel.debugg.ai` host in a response to the caller's localhost origin, hiding real fixed addresses (e.g. a `pub-rt-...` tunnel). Now only the hostname(s) of the tunnel this session opened (`ctx.tunnelId` / session tunnel) are mapped back; all other tunnel-domain addresses are left as is. The session's own tunnel behaves as before.
32
+
33
+ ### Fixed — trigger_crawl fails before the tunnel when no project can be determined (platform-xmkv)
34
+
35
+ - `trigger_crawl` never resolved a project (the `projectUuid` doc said "auto-detected" but only an explicit value was sent), so from a repo with no matching project the backend answered `Missing required field(s) in context_data: project_id`, which names no tool parameter and reads as a tunnel failure. It now resolves `projectUuid` > a named `environmentId` > `repoName` > the git checkout, sends the result as `projectId`, and returns `ProjectRequired` (input-validation text, tells you to pass `projectUuid` or `repoName`) BEFORE the port probe or any tunnel.
36
+ - `check_app_in_browser` runs the same `ProjectRequired` check before provisioning a tunnel instead of after.
37
+ - `probe_page` revokes a tunnel key whose `ensureTunnel` failed (bead xkoh.9). The old cleanup indexed a list that is empty exactly on that path, and was misaligned when a public target preceded a localhost one.
38
+
39
+ ### Added — the debugg tunnel honours HTTPS_PROXY (platform-neog)
40
+
41
+ The tunnel's control websocket now goes through an HTTP CONNECT proxy when
42
+ `HTTPS_PROXY` / `https_proxy` / `ALL_PROXY` / `all_proxy` is set, unless the relay
43
+ host matches `NO_PROXY` / `no_proxy` (host, `.suffix`, `*.suffix`, `host:port`, `*`).
44
+ `http://user:pass@host:port` proxy auth works. A proxy 407 or an unreachable proxy
45
+ now says so, naming only the proxy host:port (never credentials). With no proxy
46
+ variable set, behaviour is unchanged. Replaces the old "proxies are not supported"
47
+ message. New dependency: `https-proxy-agent`.
48
+
8
49
  ## [6.0.0] — 2026-10-04 — BREAKING
9
50
 
10
51
  ### Changed — responses are the backend's data, not MCP-authored text
@@ -108,6 +108,9 @@ export async function probePageHandler(input, context, rawProgressCallback) {
108
108
  // bare key ids because revoking follows the transport the backend picked
109
109
  // (it routes to the tunnel's own revoke endpoint).
110
110
  const acquiredProvisions = [];
111
+ // Provisions whose ensureTunnel completed with a live tunnelId. Anything in
112
+ // acquiredProvisions but not here is an orphaned key (platform-xmkv).
113
+ const connectedProvisions = new Set();
111
114
  // Progress budget: 1 pre-flight + 1 template + 1 execute + N per-target captures + 1 done
112
115
  const TOTAL_STEPS = 3 + input.targets.length + 1;
113
116
  let progressStep = 0;
@@ -168,6 +171,8 @@ export async function probePageHandler(input, context, rawProgressCallback) {
168
171
  const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
169
172
  throw new Error(`Tunnel creation failed for ${ctx.originalUrl}: ${msg}`);
170
173
  }
174
+ if (tunneled.tunnelId)
175
+ connectedProvisions.add(tunnel);
171
176
  // NOTE: the tunnel health probe used to run right here, immediately
172
177
  // after ensureTunnel. Under the session-tunnel + Caddy model that is
173
178
  // WRONG — ensureTunnel only confirms the tunnel reaches Caddy, not
@@ -217,6 +222,9 @@ export async function probePageHandler(input, context, rawProgressCallback) {
217
222
  const browserSessions = [];
218
223
  let lastExecutionDurationMs;
219
224
  let completedOffset = 0;
225
+ // Executions the backend recorded as failed/error (platform-y2ob.1) —
226
+ // surfaced verbatim at top level, never swallowed into empty results.
227
+ const failedExecutions = [];
220
228
  for (const group of groups) {
221
229
  // §2.4: acquire this session's shared Caddy route for the group's port
222
230
  // BEFORE dispatching — held for the group's whole execute+poll cycle,
@@ -327,6 +335,9 @@ export async function probePageHandler(input, context, rawProgressCallback) {
327
335
  }
328
336
  }, abortController.signal);
329
337
  lastExecutionDurationMs = finalExecution.durationMs ?? undefined;
338
+ const executionFailed = isFailedStatus(finalExecution.status);
339
+ if (executionFailed)
340
+ failedExecutions.push(finalExecution);
330
341
  if (finalExecution.browserSession)
331
342
  browserSessions.push(finalExecution.browserSession);
332
343
  // ── Format this group's results into the shared, ORIGINAL-order arrays ──
@@ -371,6 +382,13 @@ export async function probePageHandler(input, context, rawProgressCallback) {
371
382
  if (typeof data.surferPageUuid === 'string' && data.surferPageUuid) {
372
383
  result.surferPageUuid = data.surferPageUuid;
373
384
  }
385
+ // platform-y2ob.1: a target with no page data must never read as a
386
+ // clean success. Relay the backend's recorded failure verbatim; when
387
+ // it recorded none, state what was observed.
388
+ if (!result.error && !hasPageData(data)) {
389
+ result.error = executionFailureText(finalExecution)
390
+ ?? `Execution status: ${finalExecution.status ?? 'unknown'}; no page data was returned for this target.`;
391
+ }
374
392
  // Bead debugg_ai_mcp-6cfv.6 fix: sanitize each result against ITS
375
393
  // OWN target's tunnel context, never the whole accumulated payload.
376
394
  // Under the old per-port-tunnel model every target had a distinct
@@ -411,6 +429,18 @@ export async function probePageHandler(input, context, rawProgressCallback) {
411
429
  // (single-execution) response shape any existing caller already parses.
412
430
  if (executionIds.length > 1)
413
431
  responsePayload.executionIds = executionIds;
432
+ if (failedExecutions.length > 0) {
433
+ // Backend's recorded status + message, verbatim (same fields
434
+ // check_app_in_browser relays: errorMessage, agentError, errorInfo).
435
+ const f = failedExecutions[0];
436
+ responsePayload.status = f.status;
437
+ if (f.errorMessage)
438
+ responsePayload.errorMessage = f.errorMessage;
439
+ if (f.state?.error)
440
+ responsePayload.agentError = f.state.error;
441
+ if (f.errorInfo)
442
+ responsePayload.errorInfo = f.errorInfo;
443
+ }
414
444
  if (browserSessions.length === 1) {
415
445
  responsePayload.browserSession = browserSessions[0];
416
446
  }
@@ -447,7 +477,9 @@ export async function probePageHandler(input, context, rawProgressCallback) {
447
477
  }
448
478
  }
449
479
  }
450
- return { content: responseContent };
480
+ return failedExecutions.length > 0
481
+ ? { content: responseContent, isError: true }
482
+ : { content: responseContent };
451
483
  }
452
484
  catch (error) {
453
485
  const duration = Date.now() - startTime;
@@ -463,16 +495,27 @@ export async function probePageHandler(input, context, rawProgressCallback) {
463
495
  // Tunnels intentionally NOT torn down — reuse pattern (bead vwd) +
464
496
  // 55-min idle auto-shutoff. Revoke only orphaned keys (we acquired the
465
497
  // key but tunnel creation failed before ensureTunnel completed).
466
- // NOTE (bead debugg_ai_mcp-xkoh.9): `tc` is undefined when ensureTunnel
467
- // threw, so this condition does not fire on the very path it describes.
468
- // That is a pre-existing defect with its own bead and is deliberately NOT
469
- // fixed here; only the endpoint the revoke goes to changed.
470
- for (let i = 0; i < acquiredProvisions.length; i++) {
471
- const provision = acquiredProvisions[i];
472
- const tc = targetContexts[i];
473
- if (tc && !tc.tunnelId && provision) {
474
- client.tunnels.revoke(provision).catch(err => logger.warn(`Failed to revoke unused tunnel ${provision.tunnelId}: ${err}`));
475
- }
498
+ // Revoke only orphaned keys: provisioned, but ensureTunnel threw or
499
+ // returned no tunnelId. (The old check indexed targetContexts by provision
500
+ // position, which is undefined exactly when ensureTunnel threw, and
501
+ // misaligned whenever a public target preceded a localhost one.)
502
+ for (const provision of acquiredProvisions) {
503
+ if (connectedProvisions.has(provision))
504
+ continue;
505
+ client.tunnels.revoke(provision).catch(err => logger.warn(`Failed to revoke unused tunnel ${provision.tunnelId}: ${err}`));
476
506
  }
477
507
  }
478
508
  }
509
+ function isFailedStatus(status) {
510
+ return typeof status === 'string' && ['failed', 'error'].includes(status.toLowerCase());
511
+ }
512
+ function hasPageData(data) {
513
+ return ['capturedUrl', 'finalUrl', 'url', 'title'].some(k => typeof data?.[k] === 'string' && data[k])
514
+ || typeof data?.statusCode === 'number';
515
+ }
516
+ /** The backend's own recorded failure text for an execution, verbatim. */
517
+ function executionFailureText(exec) {
518
+ if (!isFailedStatus(exec?.status))
519
+ return null;
520
+ return exec.errorMessage || exec.state?.error || `Execution status: ${exec.status}`;
521
+ }
@@ -21,6 +21,7 @@ import { extractLocalhostPort, rewriteLocalhostInText } from '../utils/urlParser
21
21
  import { getCachedTemplateUuid, getCachedProjectUuid, invalidateTemplateCache, invalidateProjectCache, } from '../utils/handlerCaches.js';
22
22
  import { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
23
23
  import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
24
+ import { projectRequiredMessage, projectLookupFailedPayload } from '../utils/projectRequired.js';
24
25
  const logger = new Logger({ module: 'testPageChangesHandler' });
25
26
  // Bead kbxy: bounded retry on known transient backend signatures (Pydantic
26
27
  // JSON parse errors, 502s, ECONNRESETs). Default 1 retry; env-overridable
@@ -199,6 +200,43 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
199
200
  const MAX_EXEC_STEPS = 25;
200
201
  const TOTAL_STEPS = SETUP_STEPS + MAX_EXEC_STEPS;
201
202
  try {
203
+ // --- Project: resolve + validate BEFORE any tunnel ---
204
+ const repoName = input.repoName || detectRepoName();
205
+ // An environment belongs to one project, so naming one names the project:
206
+ // the backend files the run under it (platform-98fv.21). The working folder's
207
+ // repo name is only a guess, used when no environment is named.
208
+ const namedEnvironmentId = input.environmentId || input.auth?.environmentId;
209
+ let lookupError;
210
+ const projectUuid = !namedEnvironmentId && repoName
211
+ ? await getCachedProjectUuid(repoName, async (repo) => {
212
+ try {
213
+ return await client.findProjectByRepoName(repo);
214
+ }
215
+ catch (err) {
216
+ logger.warn(`Failed to look up project for repo "${repo}": ${err}`);
217
+ lookupError = err;
218
+ return null;
219
+ }
220
+ })
221
+ : undefined;
222
+ // Fail fast + actionable when project_id can't be resolved (pinned backend
223
+ // semantics: project_id is required). Checked BEFORE any tunnel is
224
+ // provisioned (platform-xmkv): a tunnel bought for a call that cannot run
225
+ // is a billed leak, and beats a backend node failing mid-run.
226
+ if (!projectUuid && !namedEnvironmentId && lookupError !== undefined && repoName) {
227
+ // The lookup FAILED — not "no project linked" (platform-y2ob.4).
228
+ const payload = projectLookupFailedPayload(repoName, lookupError);
229
+ logger.warn(`check_app_in_browser: ${payload.message}`);
230
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
231
+ }
232
+ if (!projectUuid && !namedEnvironmentId) {
233
+ const payload = {
234
+ error: 'ProjectRequired',
235
+ message: projectRequiredMessage(repoName),
236
+ };
237
+ logger.warn(`check_app_in_browser: ${payload.message}`);
238
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
239
+ }
202
240
  // --- Tunnel: reuse existing or provision a fresh one ---
203
241
  if (ctx.isLocalhost) {
204
242
  // Bead 1om: pre-flight local port probe BEFORE committing to backend
@@ -319,47 +357,16 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
319
357
  if (progressCallback) {
320
358
  await progressCallback({ progress: 2, total: TOTAL_STEPS, message: 'Locating evaluation workflow template...' });
321
359
  }
322
- const repoName = input.repoName || detectRepoName();
323
- // An environment belongs to one project, so naming one names the project:
324
- // the backend files the run under it (platform-98fv.21). The working folder's
325
- // repo name is only a guess, used when no environment is named.
326
- const namedEnvironmentId = input.environmentId || input.auth?.environmentId;
327
- const [templateUuid, projectUuid] = await Promise.all([
328
- // Cache key = the dispatch slug so the cache key and the lookup can never
329
- // drift apart (bug clka: the key used to be a decoupled 'app evaluation'
330
- // literal while the lookup searched a different string).
331
- getCachedTemplateUuid(getEvalTemplateSlug(), async () => {
332
- return client.workflows.findEvaluationTemplate();
333
- }),
334
- !namedEnvironmentId && repoName
335
- ? getCachedProjectUuid(repoName, async (repo) => {
336
- try {
337
- return await client.findProjectByRepoName(repo);
338
- }
339
- catch (err) {
340
- logger.warn(`Failed to look up project for repo "${repo}": ${err}`);
341
- return null;
342
- }
343
- })
344
- : Promise.resolve(undefined),
345
- ]);
360
+ // --- Resolve template (post-tunnel) ---
361
+ // Cache key = the dispatch slug so the cache key and the lookup can never
362
+ // drift apart (bug clka: the key used to be a decoupled 'app evaluation'
363
+ // literal while the lookup searched a different string).
364
+ const templateUuid = await getCachedTemplateUuid(getEvalTemplateSlug(), async () => {
365
+ return client.workflows.findEvaluationTemplate();
366
+ });
346
367
  if (!templateUuid) {
347
368
  throw new Error(`App Evaluation workflow template not found (slug "${getEvalTemplateSlug()}").`);
348
369
  }
349
- // Fail fast + actionable when project_id can't be resolved (pinned backend
350
- // semantics: project_id is required). Surfacing "link this repo to a
351
- // project" now — before executeWorkflow — beats letting a backend workflow
352
- // node fail mid-run several minutes into the evaluation.
353
- if (!projectUuid && !namedEnvironmentId) {
354
- const payload = {
355
- error: 'ProjectRequired',
356
- message: repoName
357
- ? `No DebuggAI project found for repo "${repoName}".`
358
- : 'No git repository detected and no repoName passed.',
359
- };
360
- logger.warn(`check_app_in_browser: ${payload.message}`);
361
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
362
- }
363
370
  // --- Build context data (camelCase here — axiosTransport auto-converts to snake_case) ---
364
371
  const contextData = {
365
372
  targetUrl: ctx.targetUrl ?? originalUrl,
@@ -1,7 +1,6 @@
1
1
  import { ensureConfirmed } from '../utils/confirmDestructive.js';
2
2
  import { searchTestSuitesHandler } from './searchTestSuitesHandler.js';
3
3
  import { createTestSuiteHandler } from './createTestSuiteHandler.js';
4
- import { runTestSuiteHandler } from './runTestSuiteHandler.js';
5
4
  import { getTestSuiteResultsHandler } from './getTestSuiteResultsHandler.js';
6
5
  import { deleteTestSuiteHandler } from './deleteTestSuiteHandler.js';
7
6
  export async function testSuiteHandler(input, ctx) {
@@ -14,10 +13,15 @@ export async function testSuiteHandler(input, ctx) {
14
13
  const { action, ...rest } = input;
15
14
  return createTestSuiteHandler(rest, ctx);
16
15
  }
17
- case 'run': {
18
- const { action, ...rest } = input;
19
- return runTestSuiteHandler(rest, ctx);
20
- }
16
+ case 'run':
17
+ // The server no longer has a suite-run route; calling it only yields a raw HTML 404.
18
+ return {
19
+ content: [{ type: 'text', text: JSON.stringify({
20
+ error: 'ActionRetired',
21
+ message: 'test_suite "run" is no longer supported: the DebuggAI server removed suite runs. Use action "results" for existing outcomes, or check_app_in_browser / trigger_crawl to exercise an app.',
22
+ }, null, 2) }],
23
+ isError: true,
24
+ };
21
25
  case 'results': {
22
26
  const { action, ...rest } = input;
23
27
  return getTestSuiteResultsHandler(rest, ctx);
@@ -21,11 +21,12 @@ import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js
21
21
  import { extractLocalhostPort } from '../utils/urlParser.js';
22
22
  import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, acquirePortRoute, releasePortRoute, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
23
23
  import { randomUUID } from 'node:crypto';
24
- import { getCachedTemplateUuid, invalidateTemplateCache } from '../utils/handlerCaches.js';
25
- import { detectLocalGitRef } from '../utils/gitContext.js';
24
+ import { getCachedTemplateUuid, getCachedProjectUuid, invalidateTemplateCache } from '../utils/handlerCaches.js';
25
+ import { detectLocalGitRef, detectRepoName } from '../utils/gitContext.js';
26
26
  import { getCrawlTemplateSlug } from '../services/workflows.js';
27
27
  import { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
28
28
  import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
29
+ import { projectRequiredMessage, projectLookupFailedPayload } from '../utils/projectRequired.js';
29
30
  const logger = new Logger({ module: 'triggerCrawlHandler' });
30
31
  // Bead kbo9: same env-driven retry budget as testPageChangesHandler (kbxy).
31
32
  function getMaxTransientRetries() {
@@ -84,6 +85,41 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
84
85
  requestSignal.addEventListener('abort', onAbort, { once: true });
85
86
  }
86
87
  try {
88
+ // --- Project: resolve BEFORE any tunnel (platform-xmkv) ---
89
+ // The backend requires a project; failing here costs nothing, failing after
90
+ // provisioning leaves a billed tunnel behind and surfaces as a raw backend
91
+ // "Missing required field(s) in context_data: project_id" that names no
92
+ // tool parameter. Precedence: projectUuid > a named environment (it belongs
93
+ // to one project, the backend resolves it) > repoName > the git checkout.
94
+ let projectUuid = input.projectUuid;
95
+ let lookupError;
96
+ const repoName = input.repoName || detectRepoName();
97
+ if (!projectUuid && !input.environmentId && repoName) {
98
+ projectUuid = await getCachedProjectUuid(repoName, async (repo) => {
99
+ try {
100
+ return await client.findProjectByRepoName(repo);
101
+ }
102
+ catch (err) {
103
+ logger.warn(`Failed to look up project for repo "${repo}": ${err}`);
104
+ lookupError = err;
105
+ return null;
106
+ }
107
+ });
108
+ }
109
+ if (!projectUuid && !input.environmentId && lookupError !== undefined && repoName) {
110
+ // The lookup FAILED — not "no project linked" (platform-y2ob.4).
111
+ const payload = projectLookupFailedPayload(repoName, lookupError);
112
+ logger.warn(`trigger_crawl: ${payload.message}`);
113
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
114
+ }
115
+ if (!projectUuid && !input.environmentId) {
116
+ const payload = {
117
+ error: 'ProjectRequired',
118
+ message: projectRequiredMessage(repoName),
119
+ };
120
+ logger.warn(`trigger_crawl: ${payload.message}`);
121
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
122
+ }
87
123
  // --- Tunnel: reuse existing or provision a fresh one ---
88
124
  if (ctx.isLocalhost) {
89
125
  // Bead 1om: pre-flight local port probe BEFORE provision/tunnel/backend.
@@ -191,8 +227,8 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
191
227
  const contextData = {
192
228
  targetUrl: ctx.targetUrl ?? ctx.originalUrl,
193
229
  };
194
- if (input.projectUuid)
195
- contextData.projectId = input.projectUuid;
230
+ if (projectUuid)
231
+ contextData.projectId = projectUuid;
196
232
  contextData.headless = true; // D7: the MCP always runs headless — no opt-out.
197
233
  if (typeof input.timeoutSeconds === 'number')
198
234
  contextData.timeoutSeconds = input.timeoutSeconds;
@@ -21,6 +21,7 @@ import * as tls from 'node:tls';
21
21
  import WebSocket from 'ws';
22
22
  import { Logger } from '../../utils/logger.js';
23
23
  import { buildHandshakeHeaders, interpretHandshakeResponse, webSocketTransport, CloseCode, ErrorCode, MAX_FRAME_SIZE, PROBE_TIMEOUT_MS, TunnelSession, } from './protocol/index.js';
24
+ import { resolveTunnelProxy } from './proxy.js';
24
25
  import { registerControlProbe, unregisterControlProbe } from './probeRegistry.js';
25
26
  const logger = new Logger({ module: 'debuggTransport' });
26
27
  /** A DebuggSocketLike is always usable as the protocol module's WebSocketLike. */
@@ -29,6 +30,7 @@ void _socketIsWebSocketLike;
29
30
  const DEFAULT_RECONNECT_BACKOFF_MS = [250, 500, 1000, 2000, 5000, 10000];
30
31
  const defaultSocketFactory = (url, init) => new WebSocket(url, {
31
32
  headers: init.headers,
33
+ ...(init.agent ? { agent: init.agent } : {}),
32
34
  // Both ends disable it: dev-server payloads are already compressed and
33
35
  // deflate's per-connection buffers break the protocol's bounded-memory
34
36
  // property (bead debugg_ai_mcp-xkoh.1.2 §0).
@@ -100,7 +102,8 @@ class DebuggTransport {
100
102
  });
101
103
  // The token rides in a header and NEVER in the URL: the ALB and nginx log
102
104
  // URLs, not headers.
103
- const socket = this.socketFactory(state.relayUrl, { headers });
105
+ const proxy = resolveTunnelProxy(state.relayUrl);
106
+ const socket = this.socketFactory(state.relayUrl, proxy ? { headers, agent: proxy.agent } : { headers });
104
107
  // ATTACH THE SESSION NOW, not on 'open', and not after connect() resolves.
105
108
  //
106
109
  // The server registers a tunnel the instant the handshake completes and may
@@ -145,7 +148,7 @@ class DebuggTransport {
145
148
  }
146
149
  };
147
150
  const timer = setTimeout(() => {
148
- fail(egressError(state, 'the connection timed out', 'ETIMEDOUT'));
151
+ fail(egressError(state, 'the connection timed out', 'ETIMEDOUT', proxy));
149
152
  abandon();
150
153
  }, this.handshakeTimeoutMs);
151
154
  timer.unref?.();
@@ -187,14 +190,14 @@ class DebuggTransport {
187
190
  const status = res?.statusCode ?? 0;
188
191
  const result = interpretHandshakeResponse(status, res?.headers ?? {});
189
192
  abandon();
190
- fail(handshakeFailureError(state, status, result.ok ? undefined : result.supported, result.ok ? true : result.retryable));
193
+ fail(handshakeFailureError(state, status, result.ok ? undefined : result.supported, result.ok ? true : result.retryable, proxy));
191
194
  });
192
195
  socket.on('error', (err) => {
193
196
  // Before the upgrade completes this is always a connection-level
194
197
  // failure: DNS, TLS, refused, or blocked egress.
195
198
  if (!settled)
196
199
  abandon();
197
- fail(egressError(state, err.message, err.code));
200
+ fail(egressError(state, err.message, err.code, proxy));
198
201
  });
199
202
  });
200
203
  }
@@ -356,7 +359,7 @@ function tunnelError(message, retryable) {
356
359
  function terminal(message) {
357
360
  return tunnelError(message, false);
358
361
  }
359
- function handshakeFailureError(state, status, supported, retryable) {
362
+ function handshakeFailureError(state, status, supported, retryable, proxy) {
360
363
  switch (status) {
361
364
  case 401:
362
365
  // The word "authtoken" is load-bearing: TunnelManager's connect ladder
@@ -371,28 +374,32 @@ function handshakeFailureError(state, status, supported, retryable) {
371
374
  `${supported ? `, server supports ${supported.join(', ')}` : ''}). ` +
372
375
  'Update @debugg-ai/debugg-ai-mcp.');
373
376
  case 407:
374
- return terminal(`an HTTP proxy at your network edge intercepted the tunnel connection to ${hostOf(state.relayUrl)} ` +
375
- 'and demanded authentication (HTTP 407). The tunnel needs a DIRECT outbound HTTPS (443) ' +
376
- 'connection; proxies are not supported.');
377
+ return terminal(proxy
378
+ ? `the HTTP proxy ${proxy.display} (from ${proxy.varName}) demanded authentication for the tunnel ` +
379
+ `connection to ${hostOf(state.relayUrl)} (HTTP 407). Put valid credentials in the proxy URL ` +
380
+ `(http://user:password@${proxy.display}) or ask your network admin to allow it.`
381
+ : `an HTTP proxy at your network edge intercepted the tunnel connection to ${hostOf(state.relayUrl)} ` +
382
+ 'and demanded authentication (HTTP 407). Set HTTPS_PROXY=http://user:password@host:port so the ' +
383
+ 'tunnel connects through it, or allow a direct outbound HTTPS (443) connection.');
377
384
  default:
378
385
  return tunnelError(`the debugg tunnel server refused the connection (HTTP ${status})`, retryable);
379
386
  }
380
387
  }
381
388
  /**
382
- * A connection-level failure reaching the relay. This client does NOT honour
383
- * HTTPS_PROXY (the retired ngrok agent did, which is the one capability lost
384
- * in the cutover — design §5 "open gap"), so the message says plainly what has
385
- * to be allowed instead of reading as a generic tunnel error.
389
+ * A connection-level failure reaching the relay (or, when HTTPS_PROXY is in
390
+ * use, the proxy in front of it). Says plainly what has to be allowed instead
391
+ * of reading as a generic tunnel error. Never includes proxy credentials.
386
392
  */
387
- function egressError(state, detail, code) {
393
+ function egressError(state, detail, code, proxy) {
388
394
  const relayHost = hostOf(state.relayUrl);
389
- const proxyVar = ['HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy'].find((name) => process.env[name]);
390
- const proxyNote = proxyVar
391
- ? ` This environment sets ${proxyVar}, which the tunnel connection does NOT use — HTTP(S) proxies are not supported for it.`
392
- : ' HTTP(S) proxies are not supported for the tunnel connection.';
395
+ if (proxy) {
396
+ return tunnelError(`could not connect to the debugg tunnel server at ${relayHost} through the HTTP proxy ${proxy.display} ` +
397
+ `(from ${proxy.varName}) (${code ?? 'connection failed'}: ${detail}). Check that the proxy is reachable ` +
398
+ `and allows CONNECT to ${relayHost}, or set NO_PROXY=${relayHost} to connect directly.`, true);
399
+ }
393
400
  return tunnelError(`could not reach the debugg tunnel server at ${relayHost} (${code ?? 'connection failed'}: ${detail}). ` +
394
- `Testing a local app needs a direct outbound HTTPS connection to ${relayHost} on port 443 — ` +
395
- `allow it in your firewall, VPN or container network rules.${proxyNote}`, true);
401
+ `Testing a local app needs outbound HTTPS to ${relayHost} on port 443 — allow it in your firewall, ` +
402
+ 'VPN or container network rules, or set HTTPS_PROXY=http://host:port if you reach the internet through an HTTP proxy.', true);
396
403
  }
397
404
  // ── Address handling ─────────────────────────────────────────────────────────
398
405
  /**
@@ -0,0 +1,65 @@
1
+ import { HttpsProxyAgent } from 'https-proxy-agent';
2
+ const PROXY_VARS = ['HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy'];
3
+ function defaultPort(url) {
4
+ if (url.port)
5
+ return Number(url.port);
6
+ return url.protocol === 'wss:' || url.protocol === 'https:' ? 443 : 80;
7
+ }
8
+ /** curl-style NO_PROXY: comma/space list, `*`, `.suffix`, `*.suffix`, `host`, `host:port`. */
9
+ export function noProxyMatches(target, noProxy) {
10
+ const host = target.hostname.toLowerCase().replace(/^\[|\]$/g, '');
11
+ const port = defaultPort(target);
12
+ for (const raw of noProxy.split(/[,\s]+/)) {
13
+ let entry = raw.trim().toLowerCase();
14
+ if (!entry)
15
+ continue;
16
+ if (entry === '*')
17
+ return true;
18
+ let entryPort;
19
+ const m = /^(.*):(\d+)$/.exec(entry);
20
+ if (m && !m[1].includes(':')) {
21
+ entry = m[1];
22
+ entryPort = Number(m[2]);
23
+ }
24
+ if (entryPort !== undefined && entryPort !== port)
25
+ continue;
26
+ entry = entry.replace(/^\*?\./, '');
27
+ if (!entry)
28
+ continue;
29
+ if (host === entry || host.endsWith(`.${entry}`))
30
+ return true;
31
+ }
32
+ return false;
33
+ }
34
+ export function resolveTunnelProxy(relayUrl, env = process.env) {
35
+ const varName = PROXY_VARS.find((name) => env[name]?.trim());
36
+ if (!varName)
37
+ return undefined;
38
+ let target;
39
+ try {
40
+ target = new URL(relayUrl);
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ const noProxy = env.NO_PROXY ?? env.no_proxy;
46
+ if (noProxy && noProxyMatches(target, noProxy))
47
+ return undefined;
48
+ const value = env[varName].trim();
49
+ let proxyUrl;
50
+ try {
51
+ proxyUrl = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`);
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ if (proxyUrl.protocol !== 'http:' && proxyUrl.protocol !== 'https:')
57
+ return undefined;
58
+ if (!proxyUrl.hostname)
59
+ return undefined;
60
+ return {
61
+ varName,
62
+ display: `${proxyUrl.hostname}${proxyUrl.port ? `:${proxyUrl.port}` : ''}`,
63
+ agent: new HttpsProxyAgent(proxyUrl.toString()),
64
+ };
65
+ }
@@ -3,6 +3,7 @@
3
3
  * Provisions short-lived debugg tunnel tokens for MCP-managed tunnel setup.
4
4
  * Called before executeWorkflow so the tunnel URL is known before execution starts.
5
5
  */
6
+ import { config } from '../config/index.js';
6
7
  import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
7
8
  import { isValidTunnelDomain, registerTunnelDomain, } from '../utils/tunnelDomains.js';
8
9
  /**
@@ -111,7 +112,11 @@ export const createTunnelsService = (tx) => {
111
112
  async function provision(purpose = 'workflow') {
112
113
  let response;
113
114
  try {
114
- response = await tx.post('api/v1/tunnels/', { purpose, transports: [...SUPPORTED_TRANSPORTS] });
115
+ response = await tx.post('api/v1/tunnels/', {
116
+ purpose,
117
+ transports: [...SUPPORTED_TRANSPORTS],
118
+ client_version: config.server.version,
119
+ });
115
120
  }
116
121
  catch (err) {
117
122
  throw classifyProvisionError(err);
@@ -135,9 +140,9 @@ export const createTunnelsService = (tx) => {
135
140
  throw new TunnelProvisionError({
136
141
  message: 'Tunnel provisioning selected the ngrok transport, which this version of ' +
137
142
  '@debugg-ai/debugg-ai-mcp no longer implements (it offered: ' +
138
- `${SUPPORTED_TRANSPORTS.join(', ')}). Either the backend has not been moved off ngrok yet, ` +
139
- 'or this account is still pinned to it. Pin @debugg-ai/debugg-ai-mcp@4.4.1 until the ' +
140
- 'backend serves debugg tunnels.',
143
+ `${SUPPORTED_TRANSPORTS.join(', ')}). Run ` +
144
+ '`npx @debugg-ai/debugg-ai-mcp@latest` and restart your MCP client. If the backend still ' +
145
+ 'answers ngrok, the backend is out of date; please report it.',
141
146
  retryable: false,
142
147
  });
143
148
  }
@@ -154,8 +159,8 @@ export const createTunnelsService = (tx) => {
154
159
  if (!response.relayUrl || !response.tunnelDomain) {
155
160
  throw new TunnelProvisionError({
156
161
  message: 'Tunnel provisioning returned no relayUrl/tunnelDomain, so there is no debugg tunnel to ' +
157
- 'connect to. This backend predates the debugg tunnel server; pin ' +
158
- '@debugg-ai/debugg-ai-mcp@4.4.1 until it is upgraded.',
162
+ 'connect to. Run `npx @debugg-ai/debugg-ai-mcp@latest` and restart your MCP client. If the ' +
163
+ 'backend still answers without a relayUrl, the backend is out of date; please report it.',
159
164
  retryable: false,
160
165
  });
161
166
  }
@@ -1,10 +1,9 @@
1
1
  import { TestSuiteInputSchema } from '../types/index.js';
2
2
  import { testSuiteHandler } from '../handlers/testSuiteHandler.js';
3
3
  import { DESTRUCTIVE } from './annotations.js';
4
- const DESCRIPTION = `Manage and run test suites. Identify a suite by suiteUuid, or suiteName + a project identifier (projectUuid|projectName). Pass an "action":
4
+ const DESCRIPTION = `Manage test suites. Identify a suite by suiteUuid, or suiteName + a project identifier (projectUuid|projectName). Pass an "action":
5
5
  - "list" {projectUuid|projectName, search?, page?, pageSize?} → paginated suites with status/pass-rate.
6
6
  - "create" {name, description, projectUuid|projectName} → create a suite.
7
- - "run" {suiteUuid|(suiteName+project), targetUrl?} → run all tests async. Poll with action:"results".
8
7
  - "results" {suiteUuid|(suiteName+project)} → suite + per-test outcomes.
9
8
  - "delete" {suiteUuid|(suiteName+project), confirm?} → soft-delete (DESTRUCTIVE; requires confirmation).`;
10
9
  const PROJECT_PROPS = {
@@ -24,7 +23,7 @@ export function buildTestSuiteTool() {
24
23
  inputSchema: {
25
24
  type: 'object',
26
25
  properties: {
27
- action: { type: 'string', enum: ['list', 'create', 'run', 'results', 'delete'], description: 'Operation to perform.' },
26
+ action: { type: 'string', enum: ['list', 'create', 'results', 'delete'], description: 'Operation to perform.' },
28
27
  ...SUITE_PROPS,
29
28
  ...PROJECT_PROPS,
30
29
  name: { type: 'string', description: '[create] Suite name.' },
@@ -32,7 +31,6 @@ export function buildTestSuiteTool() {
32
31
  search: { type: 'string', description: '[list] Text filter over name/description.' },
33
32
  page: { type: 'number', description: '[list] Page (1-indexed).' },
34
33
  pageSize: { type: 'number', description: '[list] Page size (1..100).' },
35
- targetUrl: { type: 'string', description: '[run] Override the default test target (full URL).' },
36
34
  confirm: { type: 'boolean', description: '[delete] Set true to confirm deletion (when the client cannot prompt).' },
37
35
  },
38
36
  required: ['action'],
@@ -1,13 +1,22 @@
1
1
  // utils/axiosTransport.ts
2
2
  import axios from "axios";
3
3
  import { objToCamelCase, objToSnakeCase, } from "./objectNaming.js";
4
+ import { resolveTunnelProxy } from "../services/tunnel/proxy.js";
4
5
  /**
5
6
  * A tiny wrapper around axios that keeps all your interceptors
6
7
  * but gives service factories a clean, typed surface.
7
8
  */
8
9
  export class AxiosTransport {
9
10
  axios;
10
- constructor({ baseUrl, apiKey, tokenType = 'token', instance }) {
11
+ constructor({ baseUrl, apiKey, tokenType = 'token', instance, env = process.env }) {
12
+ // HTTPS_PROXY / NO_PROXY via the same selection the tunnel websocket uses
13
+ // (platform-y2ob.3). axios's built-in env proxy sends an absolute-URI
14
+ // request, which CONNECT-only proxies answer 405; use a CONNECT tunnel.
15
+ // No proxy applies => the config below is exactly what it always was.
16
+ const proxy = instance ? undefined : resolveTunnelProxy(baseUrl, env);
17
+ const proxyConfig = proxy
18
+ ? { proxy: false, httpAgent: proxy.agent, httpsAgent: proxy.agent }
19
+ : {};
11
20
  // Use an injected instance or create one that mimics `axiosServices`
12
21
  // Use provided apiKey as the Token. Must be requested on the app.
13
22
  this.axios =
@@ -15,6 +24,7 @@ export class AxiosTransport {
15
24
  axios.create({
16
25
  baseURL: baseUrl.replace(/\/+$/, "/"),
17
26
  timeout: 30_000,
27
+ ...proxyConfig,
18
28
  headers: {
19
29
  Accept: "application/json",
20
30
  "Content-Type": "application/json",
@@ -27,6 +37,13 @@ export class AxiosTransport {
27
37
  res.data = objToCamelCase(res.data);
28
38
  return res;
29
39
  }, (err) => {
40
+ if (proxy && err.response?.status === 407) {
41
+ const e407 = new Error(`the HTTP proxy ${proxy.display} (from ${proxy.varName}) demanded authentication for the connection to ` +
42
+ `${new URL(baseUrl).host} (HTTP 407). Put valid credentials in the proxy URL ` +
43
+ `(http://user:password@${proxy.display}) or ask your network admin to allow it.`);
44
+ e407.statusCode = 407;
45
+ return Promise.reject(e407);
46
+ }
30
47
  const data = err.response?.data;
31
48
  if (data) {
32
49
  const strField = (v) => typeof v === 'string' && v ? v : null;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared ProjectRequired text (platform-y2ob.2): says what was observed and
3
+ * what to pass. Used by check_app_in_browser and trigger_crawl.
4
+ */
5
+ export function projectRequiredMessage(repoName) {
6
+ const observed = repoName
7
+ ? `Could not determine the DebuggAI project: no project is linked to git repo "${repoName}".`
8
+ : 'Could not determine the DebuggAI project: this is not a git repo and no repoName was passed.';
9
+ return observed
10
+ + ' Pass `projectUuid` (list yours with the `project` tool, action "list") or `repoName` (the "org/repo" of a linked project).';
11
+ }
12
+ /**
13
+ * A repo->project lookup that FAILED (platform-y2ob.4), as opposed to one that
14
+ * succeeded and found nothing. Only the latter may say "not linked": a 405 or a
15
+ * network error says nothing about whether a project exists. Reports the HTTP
16
+ * status and the backend's message verbatim.
17
+ */
18
+ export function projectLookupFailedPayload(repoName, err) {
19
+ const e = err;
20
+ const statusCode = typeof e?.statusCode === 'number' ? e.statusCode : undefined;
21
+ const reason = err instanceof Error ? err.message : String(err);
22
+ const how = statusCode !== undefined
23
+ ? `HTTP ${statusCode}: ${reason}`
24
+ : `${e?.networkCode ? `${e.networkCode}: ` : ''}${reason}`;
25
+ return {
26
+ error: 'ProjectLookupFailed',
27
+ message: `Could not look up the DebuggAI project for git repo "${repoName}" (${how}). `
28
+ + 'This is a lookup failure, not a missing link: pass `projectUuid` to skip the lookup, or fix the connection and retry.',
29
+ detail: { repoName, ...(statusCode !== undefined ? { statusCode } : {}), ...(e?.networkCode ? { networkCode: e.networkCode } : {}) },
30
+ };
31
+ }
@@ -179,5 +179,14 @@ export function sanitizeResponseUrls(value, ctx) {
179
179
  if (!ctx.isLocalhost)
180
180
  return value;
181
181
  const origin = new URL(ctx.originalUrl).origin;
182
- return replaceTunnelUrls(value, origin);
182
+ const ids = [];
183
+ if (ctx.tunnelId)
184
+ ids.push(ctx.tunnelId);
185
+ try {
186
+ const sessionTunnel = tunnelManager.getSessionTunnelInfo(getSessionKey());
187
+ if (sessionTunnel?.tunnelId)
188
+ ids.push(sessionTunnel.tunnelId);
189
+ }
190
+ catch { /* no session tunnel: ctx.tunnelId alone decides */ }
191
+ return replaceTunnelUrls(value, origin, ids);
183
192
  }
@@ -2,7 +2,7 @@
2
2
  * URL Parser Utilities
3
3
  * Helper functions for parsing and validating URLs, specifically for detecting localhost URLs
4
4
  */
5
- import { tunnelUrlPattern } from './tunnelDomains.js';
5
+ import { tunnelUrlPattern, extractTunnelIdFromHost } from './tunnelDomains.js';
6
6
  /**
7
7
  * Normalize a user-supplied URL string before validation.
8
8
  * Handles bare hostnames without a scheme (e.g. "localhost:3000" → "http://localhost:3000").
@@ -95,18 +95,32 @@ export function isLocalhostUrl(urlString) {
95
95
  * Covers EVERY known tunnel domain (utils/tunnelDomains.ts), including the
96
96
  * retired `ngrok.debugg.ai` one a historical run's stored URLs still carry: a
97
97
  * domain this does not know is a hostname that leaks to the caller.
98
+ *
99
+ * When `ownTunnelIds` is given, ONLY hosts whose tunnel id (leftmost label) is
100
+ * listed are rewritten; foreign tunnel-domain addresses are left untouched.
101
+ * Omitted = legacy rewrite-everything behavior.
98
102
  */
99
- export function replaceTunnelUrls(value, localhostOrigin) {
103
+ export function replaceTunnelUrls(value, localhostOrigin, ownTunnelIds) {
100
104
  if (typeof value === 'string') {
101
- return value.replace(tunnelUrlPattern(), localhostOrigin.replace(/\/$/, ''));
105
+ const target = localhostOrigin.replace(/\/$/, '');
106
+ if (ownTunnelIds === undefined)
107
+ return value.replace(tunnelUrlPattern(), target);
108
+ const own = new Set(ownTunnelIds.filter((id) => !!id).map(id => id.toLowerCase()));
109
+ // Only hosts of tunnels THIS session opened are mapped back; any other
110
+ // tunnel-domain address (a fixed runner/pub- tunnel, another user's) is
111
+ // a real public address and stays exactly as is.
112
+ return value.replace(tunnelUrlPattern(), (match) => {
113
+ const id = extractTunnelIdFromHost(match);
114
+ return id !== null && own.has(id.toLowerCase()) ? target : match;
115
+ });
102
116
  }
103
117
  if (Array.isArray(value)) {
104
- return value.map(item => replaceTunnelUrls(item, localhostOrigin));
118
+ return value.map(item => replaceTunnelUrls(item, localhostOrigin, ownTunnelIds));
105
119
  }
106
120
  if (value !== null && typeof value === 'object') {
107
121
  const result = {};
108
122
  for (const [k, v] of Object.entries(value)) {
109
- result[k] = replaceTunnelUrls(v, localhostOrigin);
123
+ result[k] = replaceTunnelUrls(v, localhostOrigin, ownTunnelIds);
110
124
  }
111
125
  return result;
112
126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "6.0.3",
3
+ "version": "6.1.1",
4
4
  "description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
5
5
  "type": "module",
6
6
  "caddy": "2.11.3",
@@ -60,6 +60,7 @@
60
60
  "@modelcontextprotocol/sdk": "^1.27.0",
61
61
  "@radically-straightforward/caddy": "^2.0.12",
62
62
  "axios": "^1.9.0",
63
+ "https-proxy-agent": "^7.0.6",
63
64
  "mkdirp": "^3.0.1",
64
65
  "posthog-node": "^5.26.0",
65
66
  "uuid": "^11.1.0",