@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.
- package/CHANGELOG.md +13 -0
- package/README.md +4 -0
- package/dist/handlers/localExecutorBridge.js +152 -0
- package/dist/handlers/localProbeHandler.js +46 -0
- package/dist/handlers/probePageHandler.js +4 -0
- package/dist/handlers/testPageChangesHandler.js +37 -8
- package/dist/services/localBrowser.js +207 -0
- package/dist/services/localExecutor/credentialBox.js +43 -0
- package/dist/services/localExecutor/httpTransport.js +72 -0
- package/dist/services/localExecutor/opRunner.js +437 -0
- package/dist/services/localExecutor/ops.js +12 -0
- package/dist/services/localExecutor/session.js +125 -0
- package/dist/services/navigationGuard.js +71 -0
- package/dist/services/workflows.js +31 -1
- package/dist/tools/probePage.js +22 -0
- package/dist/tools/testPageChanges.js +8 -0
- package/dist/types/index.js +16 -0
- package/dist/utils/projectRequired.js +8 -4
- package/package.json +4 -1
|
@@ -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
|
-
|
|
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) {
|
package/dist/tools/probePage.js
CHANGED
|
@@ -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" },
|
package/dist/types/index.js
CHANGED
|
@@ -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
|
-
+
|
|
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:
|
|
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
|
|
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
|
}
|