@debugg-ai/debugg-ai-mcp 4.4.1 → 6.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +32 -14
  3. package/dist/handlers/createEnvironmentHandler.js +4 -4
  4. package/dist/handlers/createProjectHandler.js +1 -1
  5. package/dist/handlers/environmentSessionsHandler.js +2 -18
  6. package/dist/handlers/probePageHandler.js +10 -13
  7. package/dist/handlers/runTestSuiteHandler.js +8 -9
  8. package/dist/handlers/searchEnvironmentsHandler.js +3 -6
  9. package/dist/handlers/searchExecutionsHandler.js +3 -11
  10. package/dist/handlers/searchProjectsHandler.js +1 -6
  11. package/dist/handlers/testPageChangesHandler.js +88 -183
  12. package/dist/handlers/triggerCrawlHandler.js +52 -44
  13. package/dist/handlers/updateEnvironmentHandler.js +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/services/caddy/caddyProxy.js +7 -6
  16. package/dist/services/index.js +18 -21
  17. package/dist/services/tunnel/debuggTransport.js +9 -9
  18. package/dist/services/tunnel/protocol/constants.js +3 -3
  19. package/dist/services/tunnel/transport.js +28 -9
  20. package/dist/services/{ngrok → tunnel}/tunnelFaultInjection.js +9 -15
  21. package/dist/services/{ngrok → tunnel}/tunnelManager.js +65 -131
  22. package/dist/services/{ngrok → tunnel}/tunnelRegistry.js +2 -2
  23. package/dist/services/tunnels.js +64 -46
  24. package/dist/services/verdictAdapter.js +167 -41
  25. package/dist/services/workflows.js +10 -1
  26. package/dist/tools/testPageChanges.js +1 -1
  27. package/dist/utils/authorizedCredentialHosts.js +8 -20
  28. package/dist/utils/confirmDestructive.js +1 -2
  29. package/dist/utils/errors.js +4 -5
  30. package/dist/utils/imageUtils.js +2 -2
  31. package/dist/utils/localReachability.js +62 -341
  32. package/dist/utils/resolveProject.js +1 -1
  33. package/dist/utils/telemetry.js +4 -4
  34. package/dist/utils/tunnelContext.js +11 -9
  35. package/dist/utils/tunnelDisposition.js +51 -69
  36. package/dist/utils/tunnelDomains.js +30 -21
  37. package/dist/utils/urlParser.js +38 -6
  38. package/package.json +2 -3
  39. package/dist/services/ngrok/error.js +0 -3
  40. package/dist/services/ngrok/ngrokAgentInspector.js +0 -210
  41. package/dist/services/ngrok/ngrokAgentSession.js +0 -49
  42. package/dist/services/ngrok/types.js +0 -1
  43. package/dist/services/tunnel/ngrokTransport.js +0 -165
package/CHANGELOG.md CHANGED
@@ -5,7 +5,67 @@ 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
8
+ ## [6.0.0] — 2026-10-04 — BREAKING
9
+
10
+ ### Changed — responses are the backend's data, not MCP-authored text
11
+
12
+ Every tool response now relays the backend's fields under the backend's names,
13
+ with its reasons verbatim. The MCP authors text only for what it observed itself
14
+ (its poll deadline, a probe/tunnel failure, input validation).
15
+
16
+ - `check_app_in_browser`: `outcome` is `pass | fail | error`, or `null` with
17
+ `skipped: true` and the backend's `skipReason` verbatim for a run that never
18
+ attempted a test (the raw run outcome is never read). Removed `success`,
19
+ `failureCategory`, `stepsTaken` / `stepsBudget` / `stepsRemaining` (use the
20
+ relayed `budget: {maxSteps, usedSteps}`), `failedNode` (use `errorInfo`),
21
+ `surferOutput`, and the node-rebuilt `actionTrace` / `evaluation` fallbacks.
22
+ `evaluation` now carries only keys the verdict lacks (it is omitted when it
23
+ would repeat `outcome` / `reason`). `credentialWarning` is `{requested, used}`
24
+ (no `message`). A tunnel fault keeps `error`, `backendVerdict`, `detail`; its
25
+ `message` and `failureCategory: 'infrastructure'` are gone and `reason` states
26
+ the recorded marker and the probe result.
27
+ - `executions`: `outcome`, `verdict.outcome` and `evaluation.outcome` go through
28
+ the same `pass | fail | error` allowlist (null stays null for running rows and
29
+ for never-tested runs; a never-tested list row carries `skipReason`).
30
+ - `trigger_crawl`: a never-tested run reads `outcome: null`, `skipped: true`,
31
+ `skipReason`.
32
+ - `get` actions return `{project}` / `{execution}` / `{project, environment}` —
33
+ no echoed `filter`, no single-row `pageInfo`. `list` actions no longer echo
34
+ `filter`.
35
+ - `environment` `sessions` → `{environmentUuid, sessions}`; `clearSessions` →
36
+ `{environmentUuid, invalidated}` (no `note`, `scope`, `usableCount`).
37
+ - `authorizedCredentialHostsWarning` is `{requested, returned}` (no `message`).
38
+ - `trigger_crawl`: `crawlSummary` / `knowledgeGraph` carry only reported keys
39
+ (no derived `imported`, no `0` / `''` defaults).
40
+ - `probe_page`: an unreported `statusCode` / `loadTimeMs` / `finalUrl` is `null`,
41
+ not `0` or the input URL.
42
+ - `test_suite`: an unreported `runStatus` / run counts are `null` (no invented
43
+ `NEVER_RUN` / `PENDING` / `0`); `results` per-test `lastRun.outcome` goes
44
+ through the `pass | fail | error` allowlist. `run`: no `note`. Error messages no longer append advice
45
+ ("Start your dev server…", "Common causes…", "Ensure the template is seeded…",
46
+ "Pass … explicitly", "Link this repo…").
47
+
48
+ ### Fixed — localhost URLs inside goal text now point at the tunnel
49
+
50
+ `check_app_in_browser` rewrites every `http(s)://localhost[:port]` /
51
+ `127.0.0.1` origin inside the goal text onto the run's tunnel origin, keeping path, query and
52
+ fragment. The remote browser used to dial its own loopback for those URLs and the run was
53
+ recorded as the app failing. With no tunnel the text is left unchanged.
54
+
55
+ ### Migration from 5.x
56
+
57
+ | 5.x field | 6.0.0 |
58
+ |---|---|
59
+ | `success` | removed — read `outcome` (`pass` / `fail` / `error`, or `null` + `skipped` + `skipReason`) |
60
+ | `failureCategory` | removed — read `outcome` and the backend `reason` |
61
+ | `stepsTaken` / `stepsBudget` / `stepsRemaining` | removed — read `budget: {maxSteps, usedSteps}` |
62
+ | `failedNode` | removed — read `errorInfo` |
63
+ | `surferOutput`, rebuilt `actionTrace` / `evaluation` | removed |
64
+ | `project` / `executions` / `environment` `get` → `projects[0]` / `executions[0]` / `environments[0]` + `pageInfo` + `filter` | `{project}` / `{execution}` / `{project, environment}` |
65
+ | `list` → `filter` echo | removed |
66
+ | `*Warning.message`, `note`, error-message advice | removed — structured fields only |
67
+
68
+ ## Earlier changes (recorded before versioned headings resumed)
9
69
 
10
70
  ### Changed — one ngrok tunnel per session instead of one per local port
11
71
 
package/README.md CHANGED
@@ -13,9 +13,10 @@ AI-powered browser testing via the [Model Context Protocol](https://modelcontext
13
13
  **Testing `http://localhost:...` URLs requires the `caddy` binary** — `check_app_in_browser`,
14
14
  `probe_page`, and `trigger_crawl` tunnel localhost targets through a local Caddy reverse proxy.
15
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
16
+ pinned Caddy release for your platform during `npm install`/`npx` — nothing to install yourself in
17
+ the normal case. It is now the **only** binary this package downloads; the tunnel client itself is
18
+ pure TypeScript — it replaced the `ngrok` package, which fetched the ngrok agent.
19
+ If that download never ran (`npm install --ignore-scripts`, an offline/air-gapped install), point `CADDY_BIN` at
19
20
  your own install (`brew install caddy` / `apt install caddy` / see
20
21
  [caddyserver.com/docs/install](https://caddyserver.com/docs/install)) — missing it surfaces as a
21
22
  clear error on the first localhost-URL call, not a silent hang. Public-URL calls, every
@@ -63,7 +64,7 @@ The server exposes **8** tools: three **Browser** tools plus one **action-based*
63
64
 
64
65
  #### `check_app_in_browser`
65
66
 
66
- Runs an AI browser agent against your app. The agent navigates, interacts, and reports back with screenshots. Localhost URLs are auto-tunneled via ngrok.
67
+ Runs an AI browser agent against your app. The agent navigates, interacts, and reports back with screenshots. Localhost URLs are auto-tunneled through the debugg tunnel server.
67
68
 
68
69
  | Parameter | Type | Description |
69
70
  |-----------|------|-------------|
@@ -117,8 +118,7 @@ Results report the identity actually used, so a wrong one is visible rather than
117
118
  ],
118
119
  "credentialWarning": {
119
120
  "requested": "qa+invitefix@example.com",
120
- "used": ["qatest123@example.com"],
121
- "message": "This run signed in with an environment default credential even though '…' was specified. …"
121
+ "used": ["qatest123@example.com"]
122
122
  }
123
123
  ```
124
124
 
@@ -142,7 +142,7 @@ URLs are short-lived presigned S3 — refetch the parent execution via `executio
142
142
 
143
143
  #### `trigger_crawl`
144
144
 
145
- Fires a server-side browser-agent crawl to populate the project's knowledge graph. Localhost URLs tunnel automatically. Returns `{executionId, status, targetUrl, durationMs, outcome?, crawlSummary?, knowledgeGraph?, browserSession?}` with `knowledgeGraph.imported === true` on successful ingestion. The `browserSession` block (HAR + console-log URLs, same shape as above) is also present on completed crawls.
145
+ Fires a server-side browser-agent crawl to populate the project's knowledge graph. Localhost URLs tunnel automatically. Returns `{executionId, status, targetUrl, durationMs, outcome?, crawlSummary?, knowledgeGraph?, browserSession?}` with `knowledgeGraph.skipped === false` on successful ingestion. `crawlSummary` and `knowledgeGraph` carry only the keys the backend's crawl / import nodes reported — a missing count is absent, never a filled-in `0`. The `browserSession` block (HAR + console-log URLs, same shape as above) is also present on completed crawls.
146
146
 
147
147
  #### `probe_page`
148
148
 
@@ -183,12 +183,12 @@ Team and repo resolve by **either** uuid **or** name (case-insensitive exact mat
183
183
  | `create` | `{name, url, description?, projectUuid?, credentials?, authorizedCredentialHosts?}` | Created env (optionally seeds credentials) |
184
184
  | `update` | `{uuid, name?, url?, description?, addCredentials?, updateCredentials?, removeCredentialIds?, authorizedCredentialHosts?}` | Patched env; credential ops run **remove → update → add** |
185
185
  | `delete` | `{uuid, projectUuid?, confirm?}` | Deletes env (cascades credentials) — **requires confirmation** |
186
- | `sessions` | `{uuid, username?, credentialId?}` | Captured login sessions the env holds, per account, with `isUsable` and a `usableCount` |
186
+ | `sessions` | `{uuid, username?, credentialId?}` | Captured login sessions the env holds, per account, each with its own `isUsable` |
187
187
  | `clearSessions` | `{uuid, username?, credentialId?, confirm?}` | Invalidates them so the next run logs in for real — **unscoped clears require confirmation** |
188
188
 
189
189
  `projectUuid` auto-resolves from the git repo when omitted. Per-cred failures surface in `credentialWarnings[]` without blocking the env op.
190
190
 
191
- `authorizedCredentialHosts` lists hosts where a run may enter this environment's credentials besides the app's own host — **for cross-domain SSO, add the IdP host here** (e.g. `["auth.example.com"]`). Bare hostnames only: no scheme, path, port or wildcard (subdomains of the app's host are already in scope). On `update` it replaces the list; `[]` clears it. `get`/`list` return it when the server supports it. The response echoes the saved list; if the server did not persist it (older servers ignore the field), the result carries an `authorizedCredentialHostsWarning` saying so instead of a silent success.
191
+ `authorizedCredentialHosts` lists hosts where a run may enter this environment's credentials besides the app's own host — **for cross-domain SSO, add the IdP host here** (e.g. `["auth.example.com"]`). Bare hostnames only: no scheme, path, port or wildcard (subdomains of the app's host are already in scope). On `update` it replaces the list; `[]` clears it. `get`/`list` return it when the server supports it. The response echoes the saved list; if the server did not persist it (older servers ignore the field), the result carries `authorizedCredentialHostsWarning: {requested, returned}` instead of a silent success.
192
192
 
193
193
  `sessions` / `clearSessions` manage the warm authenticated sessions the backend reuses to skip login (see [Session reuse](#session-reuse-why-a-check-can-report-no-login-form)). Session contents are never returned — a session cookie is a bearer credential. `clearSessions` marks sessions invalid rather than deleting the rows, so reuse stops immediately while the capture history stays readable.
194
194
 
@@ -221,11 +221,10 @@ Team and repo resolve by **either** uuid **or** name (case-insensitive exact mat
221
221
 
222
222
  ### Pagination
223
223
 
224
- Every filter-mode response is paginated. Response shape:
224
+ Every `list` response is paginated. Response shape:
225
225
 
226
226
  ```json
227
227
  {
228
- "filter": { "...echoed query params..." },
229
228
  "pageInfo": { "page": 1, "pageSize": 20, "totalCount": 47, "totalPages": 3, "hasMore": true },
230
229
  "<items>": [ ... ]
231
230
  }
@@ -233,6 +232,8 @@ Every filter-mode response is paginated. Response shape:
233
232
 
234
233
  Pass optional `page` (1-indexed, default 1) and `pageSize` (default 20, max 200; oversized values are clamped). No response is ever silently truncated.
235
234
 
235
+ A `get` returns the one object under its singular name — `{project}`, `{execution}`, `{project, environment}` — with no `pageInfo`.
236
+
236
237
  ## Resources
237
238
 
238
239
  Alongside tools, the server exposes the read-only entities as MCP **resources**
@@ -254,10 +255,27 @@ clients without resource support keep using the tools.
254
255
  ### Security invariants
255
256
 
256
257
  - Passwords are write-only. They never appear in any response body from any tool.
257
- - Tunnel URLs (`*.ngrok.debugg.ai`) are stripped from all browser-agent responses, including agent-authored text.
258
+ - Tunnel URLs (`*.tunnel.debugg.ai`, and the retired `*.ngrok.debugg.ai` that historical runs still
259
+ reference) are stripped from all browser-agent responses, including agent-authored text.
258
260
  - 404s from the backend surface as `isError: true` with `{error: 'NotFound', ...}`, never as thrown exceptions.
259
261
  - Missing `DEBUGGAI_API_KEY` surfaces as a structured tool error on first invocation — the server still registers and lists tools normally.
260
262
 
263
+ ## Migration to v6.0.0 (backend verdicts, no MCP-authored text)
264
+
265
+ A run verdict is `pass | fail | error`, with the backend's `reason` relayed verbatim. A run that
266
+ never attempted a test reads `outcome: null`, `skipped: true`, `skipReason`.
267
+
268
+ | Removed | Replacement |
269
+ |---------|-------------|
270
+ | `success` | `outcome` |
271
+ | `failureCategory` | `outcome` + `reason` |
272
+ | `stepsTaken` / `stepsBudget` / `stepsRemaining` | `budget: {maxSteps, usedSteps}` |
273
+ | `failedNode` | `errorInfo` |
274
+ | `get` → `projects[0]` / `executions[0]` / `environments[0]` + `pageInfo` + `filter` | `{project}` / `{execution}` / `{project, environment}` |
275
+ | `message` / `note` on warnings and results | structured fields only |
276
+
277
+ Full list: [CHANGELOG.md](CHANGELOG.md) 6.0.0.
278
+
261
279
  ## Migration to v3.0.0 (action-based tools)
262
280
 
263
281
  v3 consolidated the 20 per-verb tools into 8 action-based tools. Old tool → new `tool {action}`:
@@ -341,11 +359,11 @@ flow against the advertised authorization server. The bearer is request-scoped
341
359
 
342
360
  stdio installs need none of these.
343
361
 
344
- **Multi-replica deployments (go/no-go before rollout):** tunnel state (the ngrok session tunnel,
362
+ **Multi-replica deployments (go/no-go before rollout):** tunnel state (the session tunnel,
345
363
  its Caddy instance, and its port-route lock) is in-process, keyed per caller by a hash of the
346
364
  bearer token — there is no cross-process coordination. Running several replicas behind a plain
347
365
  round-robin load balancer means one caller's calls can land on different replicas and mint one
348
- tunnel **per replica they hit** instead of one for the whole session (extra ngrok cost, bounded by
366
+ tunnel **per replica they hit** instead of one for the whole session (bounded by
349
367
  replica count, self-healing via the existing 55-minute idle auto-shutoff — never a cross-session
350
368
  correctness bug, since any single tool call stays on one replica for its whole duration). To get
351
369
  the intended "one tunnel per session" behavior on a multi-replica HTTP deployment, configure
@@ -21,7 +21,7 @@ export async function createEnvironmentHandler(input, _context) {
21
21
  if (!repoName) {
22
22
  const payload = {
23
23
  error: 'NoProjectResolved',
24
- message: 'No git repo detected and no projectUuid provided. Pass projectUuid (get it from search_projects) or invoke from a directory with a git origin.',
24
+ message: 'No git repo detected and no projectUuid provided.',
25
25
  };
26
26
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
27
27
  }
@@ -29,7 +29,7 @@ export async function createEnvironmentHandler(input, _context) {
29
29
  if (!project) {
30
30
  const payload = {
31
31
  error: 'NoProjectResolved',
32
- message: `No DebuggAI project found for repo "${repoName}". Pass projectUuid explicitly.`,
32
+ message: `No DebuggAI project found for repo "${repoName}".`,
33
33
  };
34
34
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
35
35
  }
@@ -81,10 +81,10 @@ export async function createEnvironmentHandler(input, _context) {
81
81
  }
82
82
  // Bead q4d4: only the echo proves the backend kept the hosts.
83
83
  if (input.authorizedCredentialHosts !== undefined) {
84
- const hostsWarning = checkAuthorizedCredentialHostsEcho(input.authorizedCredentialHosts, env.authorizedCredentialHosts, 'create');
84
+ const hostsWarning = checkAuthorizedCredentialHostsEcho(input.authorizedCredentialHosts, env.authorizedCredentialHosts);
85
85
  if (hostsWarning) {
86
86
  payload.authorizedCredentialHostsWarning = hostsWarning;
87
- logger.warn(`create_environment: ${hostsWarning.message}`);
87
+ logger.warn(`create_environment: authorizedCredentialHosts not persisted`, hostsWarning);
88
88
  }
89
89
  }
90
90
  logger.toolComplete('create_environment', Date.now() - start);
@@ -32,7 +32,7 @@ function resolveName(name, candidates, kind) {
32
32
  if (matches.length > 1) {
33
33
  return {
34
34
  error: 'AmbiguousMatch',
35
- message: `Multiple ${kind.toLowerCase()}s match "${name}". Pass ${kind.toLowerCase()}Uuid explicitly.`,
35
+ message: `Multiple ${kind.toLowerCase()}s match "${name}".`,
36
36
  candidates: matches.map(m => ({ uuid: m.uuid, name: m.name })),
37
37
  };
38
38
  }
@@ -18,16 +18,7 @@ export async function listEnvironmentSessionsHandler(input, _context) {
18
18
  // usableCount, not just the rows: "does this environment currently hold a
19
19
  // session that will be restored?" is the question a caller is actually
20
20
  // asking, and an expired row still reports status 'valid'.
21
- const usableCount = sessions.filter(s => s.isUsable).length;
22
- return ok({
23
- environmentUuid: input.uuid,
24
- sessions,
25
- pageInfo: { totalCount: sessions.length, usableCount },
26
- note: sessions.length === 0
27
- ? 'No captured sessions — every run for this environment logs in for real.'
28
- : `${usableCount} of ${sessions.length} session(s) would be restored instead of logging in. `
29
- + 'Use action "clearSessions" to force a real login, or pass freshSession:true on a single run.',
30
- });
21
+ return ok({ environmentUuid: input.uuid, sessions });
31
22
  }
32
23
  catch (error) {
33
24
  throw handleExternalServiceError(error, 'DebuggAI', 'environment.sessions');
@@ -45,14 +36,7 @@ export async function clearEnvironmentSessionsHandler(input, _context) {
45
36
  const { invalidated } = await client.clearEnvironmentSessions(input.uuid, filters);
46
37
  const scope = input.username ?? input.credentialId ?? 'all accounts';
47
38
  logger.info(`environment.clearSessions: invalidated ${invalidated} session(s) for ${scope}`);
48
- return ok({
49
- environmentUuid: input.uuid,
50
- invalidated,
51
- scope,
52
- note: invalidated === 0
53
- ? 'Nothing to clear — no usable captured session matched.'
54
- : 'The next run for this identity will perform a real login and re-capture.',
55
- });
39
+ return ok({ environmentUuid: input.uuid, invalidated });
56
40
  }
57
41
  catch (error) {
58
42
  throw handleExternalServiceError(error, 'DebuggAI', 'environment.clearSessions');
@@ -106,7 +106,7 @@ export async function probePageHandler(input, context, rawProgressCallback) {
106
106
  // Tunnel keys we provisioned this call (for cleanup if creation fails after key acquired).
107
107
  // The provisions this call acquired, in target order. Provisions rather than
108
108
  // bare key ids because revoking follows the transport the backend picked
109
- // (ngrok keys and debugg tunnels have different endpoints).
109
+ // (it routes to the tunnel's own revoke endpoint).
110
110
  const acquiredProvisions = [];
111
111
  // Progress budget: 1 pre-flight + 1 template + 1 execute + N per-target captures + 1 done
112
112
  const TOTAL_STEPS = 3 + input.targets.length + 1;
@@ -126,7 +126,7 @@ export async function probePageHandler(input, context, rawProgressCallback) {
126
126
  if (!probe.reachable) {
127
127
  const payload = {
128
128
  error: 'LocalServerUnreachable',
129
- message: `No server listening on 127.0.0.1:${port}. Start your dev server on that port before running probe_page. Probe result: ${probe.code} (${probe.detail ?? 'no detail'}).`,
129
+ message: `No server listening on 127.0.0.1:${port}.`,
130
130
  detail: {
131
131
  port,
132
132
  probeCode: probe.code,
@@ -157,8 +157,7 @@ export async function probePageHandler(input, context, rawProgressCallback) {
157
157
  catch (provisionError) {
158
158
  const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
159
159
  const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
160
- throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
161
- `(Detail: ${msg})${diag}`);
160
+ throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}: ${msg}${diag}`);
162
161
  }
163
162
  acquiredProvisions.push(tunnel);
164
163
  let tunneled;
@@ -167,7 +166,7 @@ export async function probePageHandler(input, context, rawProgressCallback) {
167
166
  }
168
167
  catch (tunnelError) {
169
168
  const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
170
- throw new Error(`Tunnel creation failed for ${ctx.originalUrl}. (Detail: ${msg})`);
169
+ throw new Error(`Tunnel creation failed for ${ctx.originalUrl}: ${msg}`);
171
170
  }
172
171
  // NOTE: the tunnel health probe used to run right here, immediately
173
172
  // after ensureTunnel. Under the session-tunnel + Caddy model that is
@@ -198,9 +197,7 @@ export async function probePageHandler(input, context, rawProgressCallback) {
198
197
  return client.workflows.findTemplateBySlug(templateSlug);
199
198
  });
200
199
  if (!templateUuid) {
201
- throw new Error(`Page Probe Workflow Template not found (slug "${templateSlug}"). ` +
202
- `Ensure the backend has that template seeded and accessible ` +
203
- `(GET /api/v1/workflows/?slug=${templateSlug}).`);
200
+ throw new Error(`Page Probe workflow template not found (slug "${templateSlug}").`);
204
201
  }
205
202
  // ── Group targets by shared Caddy route (§4 multi-port batch decision) ──
206
203
  // Single-group batches (the common case — one port, or all-public) behave
@@ -253,11 +250,11 @@ export async function probePageHandler(input, context, rawProgressCallback) {
253
250
  if (!health.healthy) {
254
251
  const payload = {
255
252
  error: 'TunnelTrafficBlocked',
256
- message: `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`,
253
+ message: `Tunnel established; a request through it to 127.0.0.1:${extractLocalhostPort(groupCtx.originalUrl)} failed: ${health.detail ?? health.code}.`,
257
254
  detail: {
258
255
  code: health.code,
259
256
  status: health.status,
260
- ngrokErrorCode: health.ngrokErrorCode,
257
+ tunnelErrorCode: health.tunnelErrorCode,
261
258
  elapsedMs: health.elapsedMs,
262
259
  },
263
260
  };
@@ -358,10 +355,10 @@ export async function probePageHandler(input, context, rawProgressCallback) {
358
355
  finalUrl: typeof data.capturedUrl === 'string' ? data.capturedUrl
359
356
  : typeof data.finalUrl === 'string' ? data.finalUrl
360
357
  : typeof data.url === 'string' ? data.url
361
- : target.url,
362
- statusCode: typeof data.statusCode === 'number' ? data.statusCode : 0,
358
+ : null,
359
+ statusCode: typeof data.statusCode === 'number' ? data.statusCode : null,
363
360
  title: typeof data.title === 'string' ? data.title : null,
364
- loadTimeMs: typeof data.loadTimeMs === 'number' ? data.loadTimeMs : 0,
361
+ loadTimeMs: typeof data.loadTimeMs === 'number' ? data.loadTimeMs : null,
365
362
  consoleErrors: mapConsoleSlice(Array.isArray(data.consoleSlice) ? data.consoleSlice : []),
366
363
  networkSummary: reaggregateByOriginPath(Array.isArray(data.networkSummary) ? data.networkSummary : []),
367
364
  };
@@ -6,7 +6,7 @@ import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
6
6
  import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
7
7
  import { extractLocalhostPort } from '../utils/urlParser.js';
8
8
  import { buildContext, sanitizeResponseUrls } from '../utils/tunnelContext.js';
9
- import { tunnelManager } from '../services/ngrok/tunnelManager.js';
9
+ import { tunnelManager } from '../services/tunnel/tunnelManager.js';
10
10
  import { config } from '../config/index.js';
11
11
  import { resolveProject, resolveTestSuite } from '../utils/resolveProject.js';
12
12
  const logger = new Logger({ module: 'runTestSuiteHandler' });
@@ -51,7 +51,7 @@ export async function runTestSuiteHandler(input, _context) {
51
51
  if (typeof port === 'number') {
52
52
  const probe = await probeLocalPort(port);
53
53
  if (!probe.reachable) {
54
- return errorResp('LocalServerUnreachable', `No server listening on 127.0.0.1:${port}. Start your dev server before running the suite. (${probe.code}: ${probe.detail ?? 'no detail'})`, { port, probeCode: probe.code, elapsedMs: probe.elapsedMs });
54
+ return errorResp('LocalServerUnreachable', `No server listening on 127.0.0.1:${port}.`, { port, probeCode: probe.code, elapsedMs: probe.elapsedMs });
55
55
  }
56
56
  }
57
57
  if (config.devMode) {
@@ -68,7 +68,7 @@ export async function runTestSuiteHandler(input, _context) {
68
68
  // and its PortLock would give this handler no real protection —
69
69
  // the lock would release back to contention seconds after
70
70
  // triggering a suite that goes on to use the port for much longer.
71
- // So it gets its OWN tunnel, dialing ngrok directly, bypassing
71
+ // So it gets its OWN tunnel, dialing the app directly, bypassing
72
72
  // Caddy/PortLock entirely (never deduped/reused — always fresh).
73
73
  let tunnel;
74
74
  try {
@@ -77,7 +77,7 @@ export async function runTestSuiteHandler(input, _context) {
77
77
  catch (provisionError) {
78
78
  const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
79
79
  const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
80
- return errorResp('TunnelProvisionFailed', `Failed to provision tunnel for ${input.targetUrl}. (Detail: ${msg})${diag}`);
80
+ return errorResp('TunnelProvisionFailed', `Failed to provision tunnel for ${input.targetUrl}: ${msg}${diag}`);
81
81
  }
82
82
  acquiredProvision = tunnel;
83
83
  let dedicated;
@@ -86,9 +86,9 @@ export async function runTestSuiteHandler(input, _context) {
86
86
  }
87
87
  catch (tunnelError) {
88
88
  const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
89
- return errorResp('TunnelCreationFailed', `Tunnel creation failed for ${input.targetUrl}. (Detail: ${msg})`);
89
+ return errorResp('TunnelCreationFailed', `Tunnel creation failed for ${input.targetUrl}: ${msg}`);
90
90
  }
91
- // Health probe — catches ERR_NGROK_8012 and bind mismatches before
91
+ // Health probe — catches an upstream-refused tunnel and bind mismatches before
92
92
  // the remote agent wastes steps trying to reach the server.
93
93
  if (dedicated.url) {
94
94
  const health = await probeTunnelHealth(dedicated.url);
@@ -104,7 +104,7 @@ export async function runTestSuiteHandler(input, _context) {
104
104
  // revoking a live tunnel's credential would kill what we preserved.
105
105
  // (On the eviction branch markTunnelDead already revokes it.)
106
106
  tunnelId = dedicated.tunnelId;
107
- return errorResp('TunnelTrafficBlocked', `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`, { code: health.code, ngrokErrorCode: health.ngrokErrorCode, elapsedMs: health.elapsedMs });
107
+ return errorResp('TunnelTrafficBlocked', `Tunnel established; a request through it to 127.0.0.1:${port} failed: ${health.detail ?? health.code}.`, { code: health.code, tunnelErrorCode: health.tunnelErrorCode, elapsedMs: health.elapsedMs });
108
108
  }
109
109
  }
110
110
  effectiveTargetUrl = dedicated.url;
@@ -119,13 +119,12 @@ export async function runTestSuiteHandler(input, _context) {
119
119
  const responsePayload = {
120
120
  ...result,
121
121
  ...(tunnelId ? { tunnelActive: true, originalUrl: input.targetUrl } : {}),
122
- note: 'Tests are running asynchronously. Use get_test_suite_results to check progress.',
123
122
  };
124
123
  // Bead debugg_ai_mcp-6cfv.7: this handler previously had NO sanitize call
125
124
  // at all — safe only by accident of `result`'s narrow return type never
126
125
  // having carried a tunnel hostname in practice. Add the same defensive
127
126
  // pass every other handler already runs, so a future backend field that
128
- // echoes back the (dedicated, ngrok-direct) tunnel URL can never leak it
127
+ // echoes back the (dedicated, app-direct) tunnel URL can never leak it
129
128
  // to a caller who only knows their own localhost address.
130
129
  const sanitizedPayload = sanitizeCtx ? sanitizeResponseUrls(responsePayload, sanitizeCtx) : responsePayload;
131
130
  return {
@@ -71,11 +71,11 @@ export async function searchEnvironmentsHandler(input, _context) {
71
71
  if (!projectUuid) {
72
72
  const repoName = detectRepoName();
73
73
  if (!repoName) {
74
- return noProjectResolved(pagination, 'No git repo detected and no projectUuid provided. Pass projectUuid (get via search_projects) or invoke from a directory with a git origin.');
74
+ return noProjectResolved(pagination, 'No git repo detected and no projectUuid provided.');
75
75
  }
76
76
  const resolved = await client.findProjectByRepoName(repoName);
77
77
  if (!resolved) {
78
- return noProjectResolved(pagination, `No DebuggAI project found for repo "${repoName}". Pass projectUuid explicitly.`);
78
+ return noProjectResolved(pagination, `No DebuggAI project found for repo "${repoName}".`);
79
79
  }
80
80
  projectUuid = resolved.uuid;
81
81
  project = { uuid: resolved.uuid };
@@ -95,9 +95,7 @@ export async function searchEnvironmentsHandler(input, _context) {
95
95
  const creds = await client.listCredentialsForEnvironment(projectUuid, input.uuid).catch(() => []);
96
96
  const payload = {
97
97
  project,
98
- filter: { uuid: input.uuid },
99
- pageInfo: { page: 1, pageSize: 1, totalCount: 1, totalPages: 1, hasMore: false },
100
- environments: [{ ...env, credentials: creds.map(stripPassword) }],
98
+ environment: { ...env, credentials: creds.map(stripPassword) },
101
99
  };
102
100
  logger.toolComplete('search_environments', Date.now() - start);
103
101
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
@@ -118,7 +116,6 @@ export async function searchEnvironmentsHandler(input, _context) {
118
116
  }
119
117
  const payload = {
120
118
  project,
121
- filter: { q: input.q ?? null },
122
119
  pageInfo,
123
120
  environments: withCreds,
124
121
  };
@@ -13,6 +13,7 @@ import { handleExternalServiceError } from '../utils/errors.js';
13
13
  import { DebuggAIServerClient } from '../services/index.js';
14
14
  import { config } from '../config/index.js';
15
15
  import { toPaginationParams } from '../utils/pagination.js';
16
+ import { withUserFacingOutcomes } from '../services/verdictAdapter.js';
16
17
  import { fetchImageAsBase64, imageContentBlock, resourceLinkBlock, artifactResourceLinks } from '../utils/imageUtils.js';
17
18
  const logger = new Logger({ module: 'searchExecutionsHandler' });
18
19
  function notFound(uuid) {
@@ -33,11 +34,7 @@ export async function searchExecutionsHandler(input, _context) {
33
34
  if (input.uuid) {
34
35
  try {
35
36
  const execution = await client.workflows.getExecution(input.uuid);
36
- const payload = {
37
- filter: { uuid: input.uuid },
38
- pageInfo: { page: 1, pageSize: 1, totalCount: 1, totalPages: 1, hasMore: false },
39
- executions: [execution],
40
- };
37
+ const payload = { execution: withUserFacingOutcomes(execution) };
41
38
  logger.toolComplete('search_executions', Date.now() - start);
42
39
  const content = [
43
40
  { type: 'text', text: JSON.stringify(payload, null, 2) },
@@ -88,7 +85,6 @@ export async function searchExecutionsHandler(input, _context) {
88
85
  ? [resourceLinkBlock(gifUrl, `run-recording-${input.uuid}.gif`, {
89
86
  mimeType: 'image/gif',
90
87
  title: 'Run recording',
91
- description: 'Animated recording of the execution (presigned URL — open or fetch on demand).',
92
88
  })]
93
89
  : []),
94
90
  ...artifactResourceLinks(execution.browserSession),
@@ -116,12 +112,8 @@ export async function searchExecutionsHandler(input, _context) {
116
112
  pageSize: pagination.pageSize,
117
113
  });
118
114
  const payload = {
119
- filter: {
120
- status: input.status ?? null,
121
- projectUuid: input.projectUuid ?? null,
122
- },
123
115
  pageInfo,
124
- executions,
116
+ executions: executions.map(withUserFacingOutcomes),
125
117
  };
126
118
  logger.toolComplete('search_executions', Date.now() - start);
127
119
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
@@ -36,11 +36,7 @@ export async function searchProjectsHandler(input, _context) {
36
36
  if (input.uuid) {
37
37
  try {
38
38
  const project = await client.getProject(input.uuid);
39
- const payload = {
40
- filter: { uuid: input.uuid },
41
- pageInfo: { page: 1, pageSize: 1, totalCount: 1, totalPages: 1, hasMore: false },
42
- projects: [project],
43
- };
39
+ const payload = { project };
44
40
  logger.toolComplete('search_projects', Date.now() - start);
45
41
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
46
42
  }
@@ -53,7 +49,6 @@ export async function searchProjectsHandler(input, _context) {
53
49
  const pagination = toPaginationParams({ page: input.page, pageSize: input.pageSize });
54
50
  const { pageInfo, projects } = await client.listProjects(pagination, input.q);
55
51
  const payload = {
56
- filter: { q: input.q ?? null },
57
52
  pageInfo,
58
53
  projects: projects.map((p) => ({
59
54
  uuid: p.uuid,