@debugg-ai/debugg-ai-mcp 6.1.5 → 6.2.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.
@@ -0,0 +1,71 @@
1
+ export function isLoopbackHost(h) {
2
+ return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(h.toLowerCase());
3
+ }
4
+ export function safeHost(u) {
5
+ try {
6
+ return new URL(u).hostname;
7
+ }
8
+ catch {
9
+ return u;
10
+ }
11
+ }
12
+ function hostAllowed(host, allowed) {
13
+ const h = host.toLowerCase();
14
+ return allowed.some((d) => {
15
+ const a = d.trim().toLowerCase().replace(/^\*?\./, '');
16
+ return a !== '' && (h === a || h.endsWith(`.${a}`));
17
+ });
18
+ }
19
+ /** Guard every current and future page of the context. `onBlocked` receives the refused host. */
20
+ export async function installNavigationGuard(context, allowed, onBlocked) {
21
+ const check = (u) => {
22
+ try {
23
+ const h = new URL(u).hostname;
24
+ return hostAllowed(h, allowed) || isLoopbackHost(h);
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ };
30
+ const guard = async (pg) => {
31
+ const cdp = await context.newCDPSession(pg);
32
+ const mainFrameId = (await cdp.send('Page.getFrameTree')).frameTree.frame.id;
33
+ await cdp.send('Fetch.enable', { patterns: [
34
+ { urlPattern: '*', resourceType: 'Document', requestStage: 'Request' },
35
+ { urlPattern: '*', resourceType: 'Document', requestStage: 'Response' },
36
+ ] });
37
+ cdp.on('Fetch.requestPaused', (ev) => {
38
+ void (async () => {
39
+ try {
40
+ if (ev.frameId !== mainFrameId) {
41
+ await cdp.send(ev.responseStatusCode ? 'Fetch.continueResponse' : 'Fetch.continueRequest', { requestId: ev.requestId });
42
+ return;
43
+ }
44
+ if (ev.responseStatusCode === undefined) {
45
+ if (!check(ev.request.url)) {
46
+ onBlocked(safeHost(ev.request.url));
47
+ await cdp.send('Fetch.failRequest', { requestId: ev.requestId, errorReason: 'BlockedByClient' });
48
+ return;
49
+ }
50
+ await cdp.send('Fetch.continueRequest', { requestId: ev.requestId });
51
+ return;
52
+ }
53
+ const loc = ev.responseHeaders?.find((h) => h.name.toLowerCase() === 'location')?.value;
54
+ if (ev.responseStatusCode >= 300 && ev.responseStatusCode < 400 && loc) {
55
+ const next = new URL(loc, ev.request.url).toString();
56
+ if (!check(next)) {
57
+ onBlocked(safeHost(next));
58
+ await cdp.send('Fetch.failRequest', { requestId: ev.requestId, errorReason: 'BlockedByClient' });
59
+ return;
60
+ }
61
+ }
62
+ await cdp.send('Fetch.continueResponse', { requestId: ev.requestId });
63
+ }
64
+ catch { /* page closed */ }
65
+ })();
66
+ });
67
+ };
68
+ for (const pg of context.pages())
69
+ await guard(pg);
70
+ context.on('page', (pg) => { void guard(pg).catch(() => { }); });
71
+ }
@@ -3,12 +3,22 @@
3
3
  * 4-step integration: find template → execute → poll → result
4
4
  */
5
5
  import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
6
+ import { logger } from '../utils/logger.js';
6
7
  import { redactRunSecrets } from '../utils/redactRunSecrets.js';
7
8
  const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
8
9
  // Exponential backoff polling: short executions (10-15s crawls) detect terminal
9
10
  // status quickly via the early polls; long executions (60-150s browser runs)
10
11
  // avoid hammering the backend with 20+ roundtrips. Cap at 5s so we never wait
11
12
  // more than 5s past terminal-state achievement.
13
+ const MAX_CONSECUTIVE_POLL_FAILURES = 5;
14
+ /** Network-class (no response), 5xx, 408 and 429 are worth another poll; any other 4xx is a real answer. */
15
+ function isTransientPollError(err) {
16
+ const e = err;
17
+ const status = e?.statusCode;
18
+ if (typeof status === 'number')
19
+ return status >= 500 || status === 408 || status === 429;
20
+ return true; // no status: network-class error (message-only), as axiosTransport shapes it
21
+ }
12
22
  const POLL_INTERVAL_INITIAL_MS = 1000;
13
23
  const POLL_INTERVAL_MAX_MS = 5000;
14
24
  const POLL_BACKOFF_MULTIPLIER = 1.5;
@@ -33,11 +43,16 @@ const noProgressTimeoutMs = () => {
33
43
  */
34
44
  export const EVAL_TEMPLATE_SLUG = 'flow/e2es/app-eval'; // check_app_in_browser
35
45
  export const PAGE_PROBE_TEMPLATE_SLUG = 'flow/tools/probe'; // probe_page
46
+ export const LOCAL_BROWSE_TEMPLATE_SLUG = 'flow/tools/browse'; // check_app_in_browser browser:'local' (sense/think/act brain)
36
47
  export const RAW_CRAWL_TEMPLATE_SLUG = 'crawl-execution-workflow-template'; // trigger_crawl
37
48
  /** The slug the eval-template dispatch pins to (env-overridable). */
38
49
  export function getEvalTemplateSlug() {
39
50
  return process.env.DEBUGGAI_EVAL_TEMPLATE || EVAL_TEMPLATE_SLUG;
40
51
  }
52
+ /** The slug a local-executor check pins to: the browser_use brain cannot drive a local executor (env-overridable). */
53
+ export function getLocalBrowseTemplateSlug() {
54
+ return process.env.DEBUGGAI_LOCAL_TEMPLATE || LOCAL_BROWSE_TEMPLATE_SLUG;
55
+ }
41
56
  /** The slug the page-probe dispatch pins to (env-overridable). */
42
57
  export function getPageProbeTemplateSlug() {
43
58
  return process.env.DEBUGGAI_PROBE_TEMPLATE || PAGE_PROBE_TEMPLATE_SLUG;
@@ -128,11 +143,26 @@ export const createWorkflowsService = (tx) => {
128
143
  // partial results (screenshot + trace) instead of discarding them (bead
129
144
  // 56kd.3).
130
145
  let lastExecution;
146
+ let consecutivePollFailures = 0;
131
147
  while (Date.now() < deadline) {
132
148
  if (signal?.aborted) {
133
149
  throw new Error(`Polling cancelled for execution ${executionUuid}`);
134
150
  }
135
- const execution = await service.getExecution(executionUuid);
151
+ let execution;
152
+ try {
153
+ execution = await service.getExecution(executionUuid);
154
+ consecutivePollFailures = 0;
155
+ }
156
+ catch (err) {
157
+ // Bead kruv: one blip (ECONNRESET, 5xx during a deploy, 30s client
158
+ // timeout) must not lose a run the server is still executing. Only
159
+ // transient failures are tolerated, and only a few in a row.
160
+ if (!isTransientPollError(err) || ++consecutivePollFailures > MAX_CONSECUTIVE_POLL_FAILURES)
161
+ throw err;
162
+ logger.warn(`pollExecution: transient status-poll failure ${consecutivePollFailures}/${MAX_CONSECUTIVE_POLL_FAILURES} for ${executionUuid}: ${err instanceof Error ? err.message : err}`);
163
+ await new Promise((r) => setTimeout(r, intervalMs));
164
+ continue;
165
+ }
136
166
  lastExecution = execution;
137
167
  pollCount++;
138
168
  if (onUpdate) {
@@ -81,6 +81,28 @@ export function buildProbePageTool() {
81
81
  type: 'string',
82
82
  description: "GitHub repository name (e.g. 'my-org/my-repo'). Auto-detected from the current git repo — only provide this to scope the probe to a different project context.",
83
83
  },
84
+ browser: {
85
+ type: 'string',
86
+ enum: ['remote', 'local'],
87
+ description: "Default 'remote' (cloud browser; localhost is tunneled). 'local' runs Chrome on THIS machine against the URL directly: no tunnel, nothing uploaded, screenshot also saved under ~/.debugg-ai/captures. Works only when the MCP server runs on your machine AND its owner set DEBUGGAI_LOCAL_BROWSER=1. waitForSelector/waitForLoadState/timeoutMs apply; the local mode returns screenshot, console errors, network summary, title and (optionally) HTML.",
88
+ },
89
+ headless: {
90
+ type: 'boolean',
91
+ description: "browser:'local' only. Default true. false opens a visible Chrome window.",
92
+ },
93
+ useProfile: {
94
+ type: 'boolean',
95
+ description: "browser:'local' only. Default false (fresh throwaway profile). true uses the MCP-owned persistent profile (~/.debugg-ai/chrome-profile, never your everyday Chrome profile), so pages you signed into there are captured logged-in. Sign in once with headless:false plus loginWindowMs. Captures from it never leave the machine.",
96
+ },
97
+ allowedDomains: {
98
+ type: 'array',
99
+ items: { type: 'string' },
100
+ description: "browser:'local', useProfile:true only. REQUIRED for a profile session unless the server owner set DEBUGGAI_PROFILE_ALLOWED_DOMAINS (which then overrides this). Hostnames (subdomains included) the logged-in session may navigate to; any other top-level navigation, redirects included, is blocked and reported in blockedNavigations. localhost is always allowed.",
101
+ },
102
+ loginWindowMs: {
103
+ type: 'number',
104
+ description: "browser:'local', useProfile:true, headless:false only. Keep the window open up to this long (1000-600000 ms) or until you close it, so you can sign in to the site once.",
105
+ },
84
106
  },
85
107
  required: ['targets'],
86
108
  additionalProperties: false,
@@ -106,6 +106,14 @@ export function buildTestPageChangesTool(ctx) {
106
106
  type: "boolean",
107
107
  description: "Default false. Set true to force a REAL login instead of reusing the warm session the backend keeps per account. Use when the login flow itself is what you're checking, when you suspect the stored session is stale, or when the app's only route between personas is a logout. Costs one login; the run re-captures afterwards, so later runs stay fast."
108
108
  },
109
+ browser: {
110
+ type: "string",
111
+ enum: ["remote", "local"],
112
+ description: "Default 'remote' (cloud browser; localhost is tunneled). 'local' runs the clicking in Chrome on THIS machine — no tunnel, no *.localhost limits, much faster — while the cloud agent still decides each step. Needs DEBUGGAI_LOCAL_BROWSER=1 on the MCP server and the backend's local executor enabled. Passwords passed in this call stay on this machine. Page screenshots and text are still sent to the cloud agent. Not available over the remote HTTP MCP."
113
+ },
114
+ headless: { type: "boolean", description: "browser:'local' only. Default true; false shows the Chrome window." },
115
+ useProfile: { type: "boolean", description: "browser:'local' only. Use the MCP-owned logged-in Chrome profile (never your everyday Chrome). Requires allowedDomains." },
116
+ allowedDomains: { type: "array", items: { type: "string" }, description: "browser:'local' only. Extra hostnames (and subdomains) the run may navigate to, beyond the url's own host and localhost. Navigation off this list is refused, redirects included." },
109
117
  extraOrigins: {
110
118
  type: "array",
111
119
  items: { type: "string" },
@@ -64,6 +64,12 @@ export const TestPageChangesInputSchema = z.object({
64
64
  // skip login; that is right for speed and wrong when the login itself is what
65
65
  // you are checking, or when the app's only route between personas is a logout.
66
66
  freshSession: z.boolean().optional(),
67
+ // 'local' runs the clicking in Chrome on THIS machine (no tunnel); the cloud agent still decides each step.
68
+ // Needs DEBUGGAI_LOCAL_BROWSER=1 on the MCP server.
69
+ browser: z.enum(['remote', 'local']).default('remote'),
70
+ headless: z.boolean().optional(),
71
+ useProfile: z.boolean().optional(),
72
+ allowedDomains: z.array(z.string().min(1)).max(32).optional(),
67
73
  // Extra LOCAL origins the run may be redirected to (bead w1os.2) — e.g. an OAuth
68
74
  // vendor on :4717 (also host.docker.internal:4717) or a callback on :4304. Each is
69
75
  // tunnelled and redirects to it are followed through the tunnel. Only origins named
@@ -274,6 +280,16 @@ export const ProbePageInputSchema = z.object({
274
280
  includeHtml: z.boolean().default(false),
275
281
  captureScreenshots: z.boolean().default(true),
276
282
  repoName: z.string().optional(),
283
+ // Local Chrome capture (beads jpw3.1/.2). 'remote' (default) is the cloud browser.
284
+ browser: z.enum(['remote', 'local']).default('remote'),
285
+ // local only: false opens a visible window.
286
+ headless: z.boolean().default(true),
287
+ // local only: use the MCP-owned persistent profile (log in once) instead of a throwaway one.
288
+ useProfile: z.boolean().default(false),
289
+ // local + useProfile + headless:false only: keep the window open this long so the user can sign in once.
290
+ loginWindowMs: z.number().int().min(1000).max(600000).optional(),
291
+ // local + useProfile: domains (and subdomains) the session may navigate to. Required unless the server owner set DEBUGGAI_PROFILE_ALLOWED_DOMAINS.
292
+ allowedDomains: z.array(z.string().min(1)).max(50).optional(),
277
293
  }).strict();
278
294
  // ── E2E Suite Management ──────────────────────────────────────────────────────
279
295
  const projectIdentifier = {
@@ -2,12 +2,14 @@
2
2
  * Shared ProjectRequired text (platform-y2ob.2): says what was observed and
3
3
  * what to pass. Used by check_app_in_browser and trigger_crawl.
4
4
  */
5
- export function projectRequiredMessage(repoName) {
5
+ export function projectRequiredMessage(repoName, acceptsProjectUuid = true) {
6
6
  const observed = repoName
7
7
  ? `Could not determine the DebuggAI project: no project is linked to git repo "${repoName}".`
8
8
  : 'Could not determine the DebuggAI project: this is not a git repo and no repoName was passed.';
9
9
  return observed
10
- + ' Pass `projectUuid` (list yours with the `project` tool, action "list") or `repoName` (the "org/repo" of a linked project).';
10
+ + (acceptsProjectUuid
11
+ ? ' Pass `projectUuid` (list yours with the `project` tool, action "list") or `repoName` (the "org/repo" of a linked project).'
12
+ : ' Pass `repoName` (matches a project name, slug or linked "org/repo"; list yours with the `project` tool, action "list") or `environmentId`.');
11
13
  }
12
14
  /**
13
15
  * A repo->project lookup that FAILED (platform-y2ob.4), as opposed to one that
@@ -15,7 +17,7 @@ export function projectRequiredMessage(repoName) {
15
17
  * network error says nothing about whether a project exists. Reports the HTTP
16
18
  * status and the backend's message verbatim.
17
19
  */
18
- export function projectLookupFailedPayload(repoName, err) {
20
+ export function projectLookupFailedPayload(repoName, err, acceptsProjectUuid = true) {
19
21
  const e = err;
20
22
  const statusCode = typeof e?.statusCode === 'number' ? e.statusCode : undefined;
21
23
  const reason = err instanceof Error ? err.message : String(err);
@@ -25,7 +27,9 @@ export function projectLookupFailedPayload(repoName, err) {
25
27
  return {
26
28
  error: 'ProjectLookupFailed',
27
29
  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.',
30
+ + 'This is a lookup failure, not a missing link: '
31
+ + (acceptsProjectUuid ? 'pass `projectUuid` to skip the lookup, ' : 'pass `environmentId` to skip the lookup, ')
32
+ + 'or fix the connection and retry.',
29
33
  detail: { repoName, ...(statusCode !== undefined ? { statusCode } : {}), ...(e?.networkCode ? { networkCode: e.networkCode } : {}) },
30
34
  };
31
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "6.1.5",
3
+ "version": "6.2.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",
@@ -83,5 +83,8 @@
83
83
  "ts-jest": "^29.4.1",
84
84
  "typescript": "^5.8.3",
85
85
  "typescript-eslint": "^8.58.2"
86
+ },
87
+ "optionalDependencies": {
88
+ "playwright-core": "^1.65.0"
86
89
  }
87
90
  }