@debugg-ai/debugg-ai-mcp 2.4.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,7 @@ docker run -i --rm --init -e DEBUGGAI_API_KEY=your_api_key quinnosha/debugg-ai-m
34
34
 
35
35
  ## Tools
36
36
 
37
- The server exposes **11** tools grouped into Browser (2), Search (3), Projects (3), and Environments (3). The headline tool is `check_app_in_browser`; the rest manage projects, environments + their credentials, and execution history through a uniform `search_*` + CRUD pattern.
37
+ The server exposes **12** tools grouped into Browser (3), Search (3), Projects (3), and Environments (3). The headline tools are `check_app_in_browser` (full AI agent) and `probe_page` (lightweight no-LLM page probe); the rest manage projects, environments + their credentials, and execution history through a uniform `search_*` + CRUD pattern.
38
38
 
39
39
  ### Browser
40
40
 
@@ -75,6 +75,26 @@ URLs are short-lived presigned S3 — refetch the parent execution via `search_e
75
75
 
76
76
  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.
77
77
 
78
+ #### `probe_page`
79
+
80
+ **Lightweight no-LLM batch page probe.** Pass 1-20 URLs; each navigates, waits for load, and returns rendered state — screenshot + page metadata + structured console errors + network summary. No agent loop, no LLM cost, no scenario assertions. Use it for "did I just break /settings?", multi-route smoke after a refactor, CI per-PR sweeps, and quick is-it-up checks where `check_app_in_browser`'s 60-150s agent loop is overkill.
81
+
82
+ | Parameter | Type | Description |
83
+ |-----------|------|-------------|
84
+ | `targets` | array **required** | 1-20 entries: `[{url, waitForSelector?, waitForLoadState?, timeoutMs?}]` |
85
+ | `targets[].url` | string **required** | Public URL or localhost (auto-tunneled) |
86
+ | `targets[].waitForLoadState` | enum | `'load'` (default) / `'domcontentloaded'` / `'networkidle'` |
87
+ | `targets[].waitForSelector` | string | Optional CSS selector to wait for after navigation |
88
+ | `targets[].timeoutMs` | number | Per-URL timeout, 1000-30000 (default 10000) |
89
+ | `includeHtml` | boolean | Return raw HTML in each result (default false) |
90
+ | `captureScreenshots` | boolean | Return one PNG per target (default true) |
91
+
92
+ The whole batch shares a single backend execution + browser session + tunnel — 5 URLs in one call is dramatically faster than 5 parallel single-URL calls. Per-URL `error` field preserves batch resilience: a single failed target doesn't fail the others.
93
+
94
+ **`networkSummary` aggregation key is `origin + pathname`** — refetch loops (`?n=0..4` repeatedly hitting the same endpoint) collapse into a single entry with the count, so `/api/poll` showing up with `count: 47` is the actionable "infinite refetch loop" signal users originally asked for.
95
+
96
+ Performance budget: <10s for 1 URL, <25s for 20. Localhost dead-port returns `LocalServerUnreachable` in <2s without burning a workflow execution.
97
+
78
98
  ### Search (dual-mode: uuid detail OR filtered list)
79
99
 
80
100
  Each `search_*` tool has two modes. Pass `{uuid}` for a single-record detail response. Pass filter params for a paginated summary list. 404 from the backend surfaces as `isError: true` with `{error: 'NotFound', message, uuid}`.
@@ -1,5 +1,6 @@
1
1
  export * from './testPageChangesHandler.js';
2
2
  export * from './triggerCrawlHandler.js';
3
+ export * from './probePageHandler.js';
3
4
  export * from './searchProjectsHandler.js';
4
5
  export * from './searchEnvironmentsHandler.js';
5
6
  export * from './searchExecutionsHandler.js';
@@ -0,0 +1,314 @@
1
+ /**
2
+ * probePageHandler — lightweight no-LLM batch page probe.
3
+ *
4
+ * Mirrors triggerCrawlHandler's 4-step pattern (find template → execute →
5
+ * poll → format response) but: (a) takes a list of targets and produces a
6
+ * list of results, (b) does no agent steps (zero LLM in critical path),
7
+ * (c) MCP-side aggregates per-target HAR slices into NetworkSummary[].
8
+ *
9
+ * The backend "Page Probe" workflow template runs:
10
+ * browser.setup → loop[targets](browser.navigate → browser.capture) → done
11
+ *
12
+ * Each browser.capture node emits per-iteration outputData with consoleSlice
13
+ * + harSlice windowed to that URL's load span — that's what makes per-URL
14
+ * networkSummary attribution accurate.
15
+ */
16
+ import { config } from '../config/index.js';
17
+ import { Logger } from '../utils/logger.js';
18
+ import { handleExternalServiceError } from '../utils/errors.js';
19
+ import { DebuggAIServerClient } from '../services/index.js';
20
+ import { TunnelProvisionError } from '../services/tunnels.js';
21
+ import { tunnelManager } from '../services/ngrok/tunnelManager.js';
22
+ import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
23
+ import { extractLocalhostPort } from '../utils/urlParser.js';
24
+ import { buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
25
+ import { getCachedTemplateUuid, invalidateTemplateCache } from '../utils/handlerCaches.js';
26
+ import { reaggregateByOriginPath, mapConsoleSlice } from '../utils/harSummarizer.js';
27
+ const logger = new Logger({ module: 'probePageHandler' });
28
+ const TEMPLATE_KEYWORD = 'page probe';
29
+ export async function probePageHandler(input, context, rawProgressCallback) {
30
+ const startTime = Date.now();
31
+ logger.toolStart('probe_page', input);
32
+ // Bead 0bq: progress circuit-breaker — see testPageChangesHandler for rationale.
33
+ let progressDisabled = false;
34
+ const progressCallback = rawProgressCallback
35
+ ? async (update) => {
36
+ if (progressDisabled)
37
+ return;
38
+ try {
39
+ await rawProgressCallback(update);
40
+ }
41
+ catch (err) {
42
+ progressDisabled = true;
43
+ logger.warn('Progress emission failed; disabling further emissions for this request', {
44
+ error: err instanceof Error ? err.message : String(err),
45
+ });
46
+ }
47
+ }
48
+ : undefined;
49
+ const client = new DebuggAIServerClient(config.api.key);
50
+ await client.init();
51
+ const abortController = new AbortController();
52
+ const onStdinClose = () => {
53
+ abortController.abort();
54
+ progressDisabled = true;
55
+ };
56
+ process.stdin.once('close', onStdinClose);
57
+ // Per-target tunnel contexts. Index aligns with input.targets[].
58
+ const targetContexts = [];
59
+ // Tunnel keys we provisioned this call (for cleanup if creation fails after key acquired).
60
+ const acquiredKeyIds = [];
61
+ // Progress budget: 1 pre-flight + 1 template + 1 execute + N per-target captures + 1 done
62
+ const TOTAL_STEPS = 3 + input.targets.length + 1;
63
+ let progressStep = 0;
64
+ try {
65
+ if (progressCallback) {
66
+ await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: `Pre-flight + tunnel setup (${input.targets.length} target${input.targets.length === 1 ? '' : 's'})...` });
67
+ }
68
+ // ── Per-target pre-flight + tunnel resolution ──────────────────────────
69
+ for (const target of input.targets) {
70
+ const ctx = buildContext(target.url);
71
+ if (ctx.isLocalhost) {
72
+ // Pre-flight TCP probe: fail fast if dev server isn't listening.
73
+ const port = extractLocalhostPort(ctx.originalUrl);
74
+ if (typeof port === 'number') {
75
+ const probe = await probeLocalPort(port);
76
+ if (!probe.reachable) {
77
+ const payload = {
78
+ error: 'LocalServerUnreachable',
79
+ 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'}).`,
80
+ detail: {
81
+ port,
82
+ probeCode: probe.code,
83
+ probeDetail: probe.detail,
84
+ elapsedMs: probe.elapsedMs,
85
+ },
86
+ };
87
+ logger.warn(`Pre-flight port probe failed for ${ctx.originalUrl}: ${probe.code} in ${probe.elapsedMs}ms`);
88
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
89
+ }
90
+ }
91
+ // Reuse existing tunnel for this port if any; otherwise provision.
92
+ const reused = findExistingTunnel(ctx);
93
+ if (reused) {
94
+ targetContexts.push(reused);
95
+ }
96
+ else {
97
+ let tunnel;
98
+ try {
99
+ tunnel = await client.tunnels.provisionWithRetry();
100
+ }
101
+ catch (provisionError) {
102
+ const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
103
+ const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
104
+ throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
105
+ `(Detail: ${msg})${diag}`);
106
+ }
107
+ acquiredKeyIds.push(tunnel.keyId);
108
+ let tunneled;
109
+ try {
110
+ tunneled = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
111
+ }
112
+ catch (tunnelError) {
113
+ const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
114
+ throw new Error(`Tunnel creation failed for ${ctx.originalUrl}. (Detail: ${msg})`);
115
+ }
116
+ // Tunnel health probe: catch the IPv4/IPv6 bind / dead-server case
117
+ // before committing to a full backend execution.
118
+ if (tunneled.targetUrl) {
119
+ const health = await probeTunnelHealth(tunneled.targetUrl);
120
+ if (!health.healthy) {
121
+ const payload = {
122
+ error: 'TunnelTrafficBlocked',
123
+ message: `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`,
124
+ detail: {
125
+ code: health.code,
126
+ status: health.status,
127
+ ngrokErrorCode: health.ngrokErrorCode,
128
+ elapsedMs: health.elapsedMs,
129
+ },
130
+ };
131
+ if (tunneled.tunnelId) {
132
+ tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
133
+ }
134
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
135
+ }
136
+ }
137
+ targetContexts.push(tunneled);
138
+ }
139
+ }
140
+ else {
141
+ // Public URL — no tunnel needed.
142
+ targetContexts.push(ctx);
143
+ }
144
+ }
145
+ // ── Locate workflow template ───────────────────────────────────────────
146
+ if (progressCallback) {
147
+ await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: 'Locating page-probe workflow template...' });
148
+ }
149
+ const templateUuid = await getCachedTemplateUuid(TEMPLATE_KEYWORD, async (name) => {
150
+ return client.workflows.findTemplateByName(name);
151
+ });
152
+ if (!templateUuid) {
153
+ throw new Error(`Page Probe Workflow Template not found. ` +
154
+ `Ensure the backend has a template matching "${TEMPLATE_KEYWORD}" seeded and accessible.`);
155
+ }
156
+ // ── Build contextData (camelCase; axiosTransport snake_cases on the wire) ──
157
+ // Backend's browser.setup node (shared with App Evaluation + Raw Crawl
158
+ // templates) requires `target_url` (singular). The Page Probe template
159
+ // currently uses that node as-is — the per-target loop primitive is
160
+ // pending. Send BOTH:
161
+ // - targetUrl: first target's tunneled URL (satisfies browser.setup
162
+ // today; will keep working when the loop wraps it later)
163
+ // - targets[]: the full per-URL config for when the loop primitive
164
+ // ships and iterates over them
165
+ const firstTargetUrl = targetContexts[0]?.targetUrl ?? input.targets[0].url;
166
+ const contextData = {
167
+ targetUrl: firstTargetUrl,
168
+ targets: input.targets.map((t, i) => ({
169
+ url: targetContexts[i].targetUrl ?? t.url,
170
+ // Send null (not undefined) for optional fields so the field exists
171
+ // in the target object even when the caller didn't pass one. Backend
172
+ // placeholder resolver was fixed in commit 154e1e69 to type-preserve
173
+ // null in single-placeholder substitutions, so null flows through.
174
+ waitForSelector: t.waitForSelector ?? null,
175
+ waitForLoadState: t.waitForLoadState,
176
+ timeoutMs: t.timeoutMs,
177
+ })),
178
+ // Backend's browser.capture template binds {{include_dom}} and
179
+ // {{include_screenshot}} from contextData (verified 2026-04-29).
180
+ // The MCP-facing schema keeps `includeHtml` / `captureScreenshots`
181
+ // for caller ergonomics; we just map them to what the template wants.
182
+ includeDom: input.includeHtml,
183
+ includeScreenshot: input.captureScreenshots,
184
+ // Keep the original keys too for any downstream node that reads them
185
+ // (cheap to send, future-proof against template field-name churn).
186
+ includeHtml: input.includeHtml,
187
+ captureScreenshots: input.captureScreenshots,
188
+ };
189
+ // ── Execute ────────────────────────────────────────────────────────────
190
+ if (progressCallback) {
191
+ await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: 'Queuing workflow execution...' });
192
+ }
193
+ const executeResponse = await client.workflows.executeWorkflow(templateUuid, contextData);
194
+ const executionUuid = executeResponse.executionUuid;
195
+ logger.info(`Probe execution queued: ${executionUuid}`);
196
+ // ── Poll ───────────────────────────────────────────────────────────────
197
+ let lastCompleted = -1;
198
+ const finalExecution = await client.workflows.pollExecution(executionUuid, async (exec) => {
199
+ // Keep all active tunnels alive during polling.
200
+ for (const tc of targetContexts) {
201
+ if (tc.tunnelId)
202
+ touchTunnelById(tc.tunnelId);
203
+ }
204
+ if (!progressCallback)
205
+ return;
206
+ const completedNodes = (exec.nodeExecutions ?? []).filter(n => n.nodeType === 'browser.capture' && n.status === 'success').length;
207
+ if (completedNodes !== lastCompleted) {
208
+ lastCompleted = completedNodes;
209
+ await progressCallback({
210
+ progress: Math.min(progressStep + completedNodes, TOTAL_STEPS - 1),
211
+ total: TOTAL_STEPS,
212
+ message: `Probed ${completedNodes}/${input.targets.length} target${input.targets.length === 1 ? '' : 's'}...`,
213
+ });
214
+ }
215
+ }, abortController.signal);
216
+ // ── Format response ────────────────────────────────────────────────────
217
+ const duration = Date.now() - startTime;
218
+ const captureNodes = (finalExecution.nodeExecutions ?? [])
219
+ .filter(n => n.nodeType === 'browser.capture')
220
+ .sort((a, b) => a.executionOrder - b.executionOrder);
221
+ const results = [];
222
+ for (let i = 0; i < input.targets.length; i++) {
223
+ const target = input.targets[i];
224
+ const node = captureNodes[i];
225
+ const data = node?.outputData ?? {};
226
+ // Backend (post-154e1e69) emits browser.capture output_data with:
227
+ // captured_url, status_code, title, load_time_ms,
228
+ // console_slice (already per-capture, in {text, level, location, timestamp} shape),
229
+ // network_summary (already pre-aggregated by FULL URL,
230
+ // in {url, count, methods[], statuses{}, resource_types[]} shape),
231
+ // surfer_page_uuid (reference to SurferPage row for screenshot/title/visible_text),
232
+ // error
233
+ // axiosTransport snake→camel'd at the wire, so JS-side these are
234
+ // capturedUrl / consoleSlice / networkSummary / surferPageUuid / etc.
235
+ // Re-aggregate networkSummary by origin+pathname so refetch loops
236
+ // collapse (preserves the original client-feedback contract).
237
+ const result = {
238
+ url: target.url, // ORIGINAL caller URL — not the tunneled rewrite
239
+ finalUrl: typeof data.capturedUrl === 'string' ? data.capturedUrl
240
+ : typeof data.finalUrl === 'string' ? data.finalUrl
241
+ : typeof data.url === 'string' ? data.url
242
+ : target.url,
243
+ statusCode: typeof data.statusCode === 'number' ? data.statusCode : 0,
244
+ title: typeof data.title === 'string' ? data.title : null,
245
+ loadTimeMs: typeof data.loadTimeMs === 'number' ? data.loadTimeMs : 0,
246
+ consoleErrors: mapConsoleSlice(Array.isArray(data.consoleSlice) ? data.consoleSlice : []),
247
+ networkSummary: reaggregateByOriginPath(Array.isArray(data.networkSummary) ? data.networkSummary : []),
248
+ };
249
+ if (input.includeHtml && typeof data.html === 'string') {
250
+ result.html = data.html;
251
+ }
252
+ if (typeof data.error === 'string' && data.error) {
253
+ result.error = data.error;
254
+ }
255
+ if (typeof data.surferPageUuid === 'string' && data.surferPageUuid) {
256
+ result.surferPageUuid = data.surferPageUuid;
257
+ }
258
+ results.push(result);
259
+ // Backend stores screenshots on the SurferPage row referenced by
260
+ // surfer_page_uuid; the inline screenshotB64 is no longer in capture
261
+ // output. v1: skip the per-result image content block; the screenshot
262
+ // is reachable via search_executions detail's surfer_page_uuid → SurferPage.
263
+ // (Future enhancement: fetch the SurferPage's presigned screenshot_url
264
+ // when input.captureScreenshots is true.)
265
+ }
266
+ const responsePayload = {
267
+ executionId: executionUuid,
268
+ durationMs: typeof finalExecution.durationMs === 'number' ? finalExecution.durationMs : duration,
269
+ results,
270
+ };
271
+ if (finalExecution.browserSession) {
272
+ responsePayload.browserSession = finalExecution.browserSession;
273
+ }
274
+ // Sanitize ngrok URLs from the entire payload — agent-authored strings in
275
+ // node outputData (titles, HTML, console messages from the page itself)
276
+ // can occasionally contain the tunnel URL; rewrite to the original
277
+ // localhost origin per tunnel context. For multi-localhost batches we
278
+ // run sanitize once per localhost target since each may have its own
279
+ // tunnel↔origin mapping.
280
+ let sanitizedPayload = responsePayload;
281
+ for (const tc of targetContexts) {
282
+ if (tc.isLocalhost) {
283
+ sanitizedPayload = sanitizeResponseUrls(sanitizedPayload, tc);
284
+ }
285
+ }
286
+ logger.toolComplete('probe_page', duration);
287
+ return {
288
+ content: [
289
+ { type: 'text', text: JSON.stringify(sanitizedPayload, null, 2) },
290
+ ],
291
+ };
292
+ }
293
+ catch (error) {
294
+ const duration = Date.now() - startTime;
295
+ logger.toolError('probe_page', error, duration);
296
+ if (error instanceof Error && (error.message.includes('not found') || error.message.includes('401'))) {
297
+ invalidateTemplateCache();
298
+ }
299
+ throw handleExternalServiceError(error, 'DebuggAI', 'probe_page execution');
300
+ }
301
+ finally {
302
+ process.stdin.removeListener('close', onStdinClose);
303
+ // Tunnels intentionally NOT torn down — reuse pattern (bead vwd) +
304
+ // 55-min idle auto-shutoff. Revoke only orphaned keys (we acquired the
305
+ // key but tunnel creation failed before ensureTunnel completed).
306
+ for (let i = 0; i < acquiredKeyIds.length; i++) {
307
+ const keyId = acquiredKeyIds[i];
308
+ const tc = targetContexts[i];
309
+ if (tc && !tc.tunnelId && keyId) {
310
+ client.revokeNgrokKey(keyId).catch(err => logger.warn(`Failed to revoke unused ngrok key ${keyId}: ${err}`));
311
+ }
312
+ }
313
+ }
314
+ }
@@ -61,6 +61,11 @@ export async function searchEnvironmentsHandler(input, _context) {
61
61
  const client = new DebuggAIServerClient(config.api.key);
62
62
  await client.init();
63
63
  // ── Resolve projectUuid ──
64
+ // Bead gb4n: when projectUuid is provided directly (caller skips git
65
+ // auto-resolution), `name` and `repoName` are unknown. OMIT those fields
66
+ // rather than emitting nulls — null fields surprised callers and
67
+ // muddied the contract. If a caller needs them, they fetch via
68
+ // search_projects.
64
69
  let projectUuid = input.projectUuid;
65
70
  let project = null;
66
71
  if (!projectUuid) {
@@ -73,10 +78,15 @@ export async function searchEnvironmentsHandler(input, _context) {
73
78
  return noProjectResolved(pagination, `No DebuggAI project found for repo "${repoName}". Pass projectUuid explicitly.`);
74
79
  }
75
80
  projectUuid = resolved.uuid;
76
- project = { uuid: resolved.uuid, name: resolved.name, repoName: resolved.repo?.name ?? repoName };
81
+ project = { uuid: resolved.uuid };
82
+ if (resolved.name)
83
+ project.name = resolved.name;
84
+ const rn = resolved.repo?.name ?? repoName;
85
+ if (rn)
86
+ project.repoName = rn;
77
87
  }
78
88
  else {
79
- project = { uuid: projectUuid, name: null, repoName: null };
89
+ project = { uuid: projectUuid };
80
90
  }
81
91
  // ── uuid mode ──
82
92
  if (input.uuid) {