@debugg-ai/debugg-ai-mcp 3.5.3 → 3.7.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/dist/handlers/probePageHandler.js +27 -9
- package/dist/handlers/testPageChangesHandler.js +51 -5
- package/dist/handlers/triggerCrawlHandler.js +27 -9
- package/dist/index.js +4 -1
- package/dist/services/projectContext.js +63 -38
- package/dist/services/workflows.js +26 -64
- package/dist/tools/index.js +29 -0
- package/dist/tools/testPageChanges.js +32 -4
- package/dist/types/index.js +18 -0
- package/package.json +1 -1
|
@@ -23,10 +23,10 @@ import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js
|
|
|
23
23
|
import { extractLocalhostPort } from '../utils/urlParser.js';
|
|
24
24
|
import { buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
|
|
25
25
|
import { getCachedTemplateUuid, invalidateTemplateCache } from '../utils/handlerCaches.js';
|
|
26
|
+
import { getPageProbeTemplateSlug } from '../services/workflows.js';
|
|
26
27
|
import { reaggregateByOriginPath, mapConsoleSlice } from '../utils/harSummarizer.js';
|
|
27
28
|
import { fetchImageAsBase64, imageContentBlock } from '../utils/imageUtils.js';
|
|
28
29
|
const logger = new Logger({ module: 'probePageHandler' });
|
|
29
|
-
const TEMPLATE_KEYWORD = 'page probe';
|
|
30
30
|
export async function probePageHandler(input, context, rawProgressCallback) {
|
|
31
31
|
const startTime = Date.now();
|
|
32
32
|
logger.toolStart('probe_page', input);
|
|
@@ -49,12 +49,25 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
49
49
|
: undefined;
|
|
50
50
|
const client = new DebuggAIServerClient(config.api.key);
|
|
51
51
|
await client.init();
|
|
52
|
+
// Bead 56kd.7: cancellation is driven by the MCP request/transport lifecycle
|
|
53
|
+
// (context.signal), NOT process.stdin. The SDK aborts context.signal when the
|
|
54
|
+
// client cancels the call OR the transport closes — e.g. an HTTP client drops
|
|
55
|
+
// the connection. Under the stateless HTTP transport that is the ONLY signal
|
|
56
|
+
// we get: stdin is not the transport, so the old stdin 'close' listener never
|
|
57
|
+
// fired and a dropped client kept polling for up to ~10 min. Wiring to
|
|
58
|
+
// context.signal cancels the poll immediately (parity with 56kd.5).
|
|
52
59
|
const abortController = new AbortController();
|
|
53
|
-
const
|
|
60
|
+
const onAbort = () => {
|
|
54
61
|
abortController.abort();
|
|
55
|
-
progressDisabled = true;
|
|
62
|
+
progressDisabled = true; // client is gone — stop emitting
|
|
56
63
|
};
|
|
57
|
-
|
|
64
|
+
const requestSignal = context.signal;
|
|
65
|
+
if (requestSignal) {
|
|
66
|
+
if (requestSignal.aborted)
|
|
67
|
+
onAbort();
|
|
68
|
+
else
|
|
69
|
+
requestSignal.addEventListener('abort', onAbort, { once: true });
|
|
70
|
+
}
|
|
58
71
|
// Per-target tunnel contexts. Index aligns with input.targets[].
|
|
59
72
|
const targetContexts = [];
|
|
60
73
|
// Tunnel keys we provisioned this call (for cleanup if creation fails after key acquired).
|
|
@@ -154,12 +167,16 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
154
167
|
if (progressCallback) {
|
|
155
168
|
await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: 'Locating page-probe workflow template...' });
|
|
156
169
|
}
|
|
157
|
-
|
|
158
|
-
|
|
170
|
+
// Pin dispatch to the stable slug (bead 56kd.8) — no fuzzy name resolution.
|
|
171
|
+
// Cache key = the slug so the key and the lookup can never drift apart.
|
|
172
|
+
const templateSlug = getPageProbeTemplateSlug();
|
|
173
|
+
const templateUuid = await getCachedTemplateUuid(templateSlug, async () => {
|
|
174
|
+
return client.workflows.findTemplateBySlug(templateSlug);
|
|
159
175
|
});
|
|
160
176
|
if (!templateUuid) {
|
|
161
|
-
throw new Error(`Page Probe Workflow Template not found. ` +
|
|
162
|
-
`Ensure the backend has
|
|
177
|
+
throw new Error(`Page Probe Workflow Template not found (slug "${templateSlug}"). ` +
|
|
178
|
+
`Ensure the backend has that template seeded and accessible ` +
|
|
179
|
+
`(GET /api/v1/workflows/?slug=${templateSlug}).`);
|
|
163
180
|
}
|
|
164
181
|
// ── Build contextData (camelCase; axiosTransport snake_cases on the wire) ──
|
|
165
182
|
// Backend's browser.setup node (shared with App Evaluation + Raw Crawl
|
|
@@ -325,7 +342,8 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
325
342
|
throw handleExternalServiceError(error, 'DebuggAI', 'probe_page execution');
|
|
326
343
|
}
|
|
327
344
|
finally {
|
|
328
|
-
|
|
345
|
+
if (requestSignal)
|
|
346
|
+
requestSignal.removeEventListener('abort', onAbort);
|
|
329
347
|
// Tunnels intentionally NOT torn down — reuse pattern (bead vwd) +
|
|
330
348
|
// 55-min idle auto-shutoff. Revoke only orphaned keys (we acquired the
|
|
331
349
|
// key but tunnel creation failed before ensureTunnel completed).
|
|
@@ -91,12 +91,26 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
91
91
|
const originalUrl = resolveTargetUrl(input);
|
|
92
92
|
let ctx = buildContext(originalUrl);
|
|
93
93
|
let keyId;
|
|
94
|
+
// Cancellation is driven by the MCP request/transport lifecycle, not
|
|
95
|
+
// process.stdin. The SDK aborts context.signal when the client cancels the
|
|
96
|
+
// call OR the transport closes — e.g. an HTTP client drops the connection.
|
|
97
|
+
// Under the stateless HTTP transport that is the ONLY signal we get: stdin is
|
|
98
|
+
// not the transport, so the old stdin 'close' listener never fired and a
|
|
99
|
+
// dropped client kept polling for up to ~10 min, holding one of just
|
|
100
|
+
// MAX_CONCURRENT=2 slots. Wiring to context.signal cancels the poll and frees
|
|
101
|
+
// the slot immediately.
|
|
94
102
|
const abortController = new AbortController();
|
|
95
|
-
const
|
|
103
|
+
const onAbort = () => {
|
|
96
104
|
abortController.abort();
|
|
97
105
|
progressDisabled = true; // client is gone — stop emitting
|
|
98
106
|
};
|
|
99
|
-
|
|
107
|
+
const requestSignal = context.signal;
|
|
108
|
+
if (requestSignal) {
|
|
109
|
+
if (requestSignal.aborted)
|
|
110
|
+
onAbort();
|
|
111
|
+
else
|
|
112
|
+
requestSignal.addEventListener('abort', onAbort, { once: true });
|
|
113
|
+
}
|
|
100
114
|
// Progress budget: 3 setup steps + 25 execution steps = 28 total
|
|
101
115
|
const SETUP_STEPS = 3;
|
|
102
116
|
const MAX_EXEC_STEPS = 25;
|
|
@@ -226,8 +240,22 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
226
240
|
throw new Error('App Evaluation Workflow Template not found. ' +
|
|
227
241
|
'Ensure the template is seeded in the backend (GET /api/v1/workflows/?is_template=true).');
|
|
228
242
|
}
|
|
229
|
-
|
|
230
|
-
|
|
243
|
+
// Fail fast + actionable when project_id can't be resolved (pinned backend
|
|
244
|
+
// semantics: project_id is required). Surfacing "link this repo to a
|
|
245
|
+
// project" now — before executeWorkflow — beats letting a backend workflow
|
|
246
|
+
// node fail mid-run several minutes into the evaluation.
|
|
247
|
+
if (!projectUuid) {
|
|
248
|
+
const detail = repoName
|
|
249
|
+
? `Repo "${repoName}" isn't linked to a DebuggAI project.`
|
|
250
|
+
: 'No git repository was detected to resolve a project from.';
|
|
251
|
+
const payload = {
|
|
252
|
+
error: 'ProjectRequired',
|
|
253
|
+
message: `project_id is required but could not be resolved. ${detail} ` +
|
|
254
|
+
'Link this repo to a project at https://debugg.ai (Projects → connect your repository), then retry. ' +
|
|
255
|
+
'To evaluate a different repo than the current one, pass repoName.',
|
|
256
|
+
};
|
|
257
|
+
logger.warn(`check_app_in_browser: ${payload.message}`);
|
|
258
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
231
259
|
}
|
|
232
260
|
// --- Build context data (camelCase here — axiosTransport auto-converts to snake_case) ---
|
|
233
261
|
const contextData = {
|
|
@@ -238,6 +266,23 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
238
266
|
contextData.projectId = projectUuid;
|
|
239
267
|
}
|
|
240
268
|
contextData.headless = true; // D7: the MCP always runs headless — no opt-out.
|
|
269
|
+
// Bead 56kd.6: forward the auth-precondition deep-link intent verbatim per
|
|
270
|
+
// backend contract sentinal-k8x1f.8 (contextData.auth). Thin relay — express
|
|
271
|
+
// intent + forward; the backend authenticates then navigates to deepUrl. Only
|
|
272
|
+
// the fields the caller set are sent (camelCase here → snake_case on the wire).
|
|
273
|
+
if (input.auth) {
|
|
274
|
+
const auth = {};
|
|
275
|
+
if (input.auth.environmentId)
|
|
276
|
+
auth.environmentId = input.auth.environmentId;
|
|
277
|
+
if (input.auth.precondition)
|
|
278
|
+
auth.precondition = input.auth.precondition;
|
|
279
|
+
if (input.auth.entryUrl)
|
|
280
|
+
auth.entryUrl = input.auth.entryUrl;
|
|
281
|
+
if (input.auth.deepUrl)
|
|
282
|
+
auth.deepUrl = input.auth.deepUrl;
|
|
283
|
+
if (Object.keys(auth).length > 0)
|
|
284
|
+
contextData.auth = auth;
|
|
285
|
+
}
|
|
241
286
|
// --- Build env (credentials/environment) ---
|
|
242
287
|
const env = {};
|
|
243
288
|
if (input.environmentId)
|
|
@@ -593,7 +638,8 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
593
638
|
throw handleExternalServiceError(error, 'DebuggAI', 'test execution');
|
|
594
639
|
}
|
|
595
640
|
finally {
|
|
596
|
-
|
|
641
|
+
if (requestSignal)
|
|
642
|
+
requestSignal.removeEventListener('abort', onAbort);
|
|
597
643
|
// Tunnel is intentionally NOT torn down here — tunnelManager reuses it on
|
|
598
644
|
// subsequent calls to the same port and auto-shutoffs after 55 min idle.
|
|
599
645
|
// Process-exit cleanup happens via stopAllTunnels() in the SIGINT/SIGTERM
|
|
@@ -20,10 +20,10 @@ import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js
|
|
|
20
20
|
import { extractLocalhostPort } from '../utils/urlParser.js';
|
|
21
21
|
import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
|
|
22
22
|
import { getCachedTemplateUuid, invalidateTemplateCache } from '../utils/handlerCaches.js';
|
|
23
|
+
import { getCrawlTemplateSlug } from '../services/workflows.js';
|
|
23
24
|
import { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
|
|
24
25
|
import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
|
|
25
26
|
const logger = new Logger({ module: 'triggerCrawlHandler' });
|
|
26
|
-
const TEMPLATE_KEYWORD = 'raw crawl';
|
|
27
27
|
// Bead kbo9: same env-driven retry budget as testPageChangesHandler (kbxy).
|
|
28
28
|
function getMaxTransientRetries() {
|
|
29
29
|
const raw = process.env.DEBUGGAI_TRANSIENT_RETRIES;
|
|
@@ -59,12 +59,25 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
59
59
|
const originalUrl = resolveTargetUrl(input);
|
|
60
60
|
let ctx = buildContext(originalUrl);
|
|
61
61
|
let keyId;
|
|
62
|
+
// Bead 56kd.7: cancellation is driven by the MCP request/transport lifecycle
|
|
63
|
+
// (context.signal), NOT process.stdin. The SDK aborts context.signal when the
|
|
64
|
+
// client cancels the call OR the transport closes — e.g. an HTTP client drops
|
|
65
|
+
// the connection. Under the stateless HTTP transport that is the ONLY signal
|
|
66
|
+
// we get: stdin is not the transport, so the old stdin 'close' listener never
|
|
67
|
+
// fired and a dropped client kept polling for up to ~10 min. Wiring to
|
|
68
|
+
// context.signal cancels the poll immediately (parity with 56kd.5).
|
|
62
69
|
const abortController = new AbortController();
|
|
63
|
-
const
|
|
70
|
+
const onAbort = () => {
|
|
64
71
|
abortController.abort();
|
|
65
|
-
progressDisabled = true;
|
|
72
|
+
progressDisabled = true; // client is gone — stop emitting
|
|
66
73
|
};
|
|
67
|
-
|
|
74
|
+
const requestSignal = context.signal;
|
|
75
|
+
if (requestSignal) {
|
|
76
|
+
if (requestSignal.aborted)
|
|
77
|
+
onAbort();
|
|
78
|
+
else
|
|
79
|
+
requestSignal.addEventListener('abort', onAbort, { once: true });
|
|
80
|
+
}
|
|
68
81
|
try {
|
|
69
82
|
// --- Tunnel: reuse existing or provision a fresh one ---
|
|
70
83
|
if (ctx.isLocalhost) {
|
|
@@ -137,12 +150,16 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
137
150
|
if (progressCallback) {
|
|
138
151
|
await progressCallback({ progress: 2, total: 4, message: 'Locating crawl workflow template...' });
|
|
139
152
|
}
|
|
140
|
-
|
|
141
|
-
|
|
153
|
+
// Pin dispatch to the stable slug (bead 56kd.8) — no fuzzy name resolution.
|
|
154
|
+
// Cache key = the slug so the key and the lookup can never drift apart.
|
|
155
|
+
const templateSlug = getCrawlTemplateSlug();
|
|
156
|
+
const templateUuid = await getCachedTemplateUuid(templateSlug, async () => {
|
|
157
|
+
return client.workflows.findTemplateBySlug(templateSlug);
|
|
142
158
|
});
|
|
143
159
|
if (!templateUuid) {
|
|
144
|
-
throw new Error(`
|
|
145
|
-
`Ensure the backend has
|
|
160
|
+
throw new Error(`Crawl Workflow Template not found (slug "${templateSlug}"). ` +
|
|
161
|
+
`Ensure the backend has that template seeded and accessible ` +
|
|
162
|
+
`(GET /api/v1/workflows/?slug=${templateSlug}).`);
|
|
146
163
|
}
|
|
147
164
|
// --- Build contextData + env ---
|
|
148
165
|
const contextData = {
|
|
@@ -298,7 +315,8 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
298
315
|
throw handleExternalServiceError(error, 'DebuggAI', 'crawl execution');
|
|
299
316
|
}
|
|
300
317
|
finally {
|
|
301
|
-
|
|
318
|
+
if (requestSignal)
|
|
319
|
+
requestSignal.removeEventListener('abort', onAbort);
|
|
302
320
|
// Tunnel intentionally NOT torn down (reuse path per bead vwd).
|
|
303
321
|
// If tunnel creation failed after key provision, revoke the orphaned key.
|
|
304
322
|
if (!ctx.tunnelId && keyId) {
|
package/dist/index.js
CHANGED
|
@@ -90,7 +90,7 @@ export function buildConfiguredServer() {
|
|
|
90
90
|
* singleton in main(), and once per request by the HTTP transport (stateless).
|
|
91
91
|
*/
|
|
92
92
|
function registerHandlers(srv) {
|
|
93
|
-
srv.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
93
|
+
srv.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
94
94
|
const typedReq = req;
|
|
95
95
|
const requestId = `req_${Date.now()}`;
|
|
96
96
|
const requestLogger = logger.child({ requestId });
|
|
@@ -123,6 +123,9 @@ function registerHandlers(srv) {
|
|
|
123
123
|
progressToken: typeof progressToken === 'string' ? progressToken : undefined,
|
|
124
124
|
requestId,
|
|
125
125
|
timestamp: new Date(),
|
|
126
|
+
// Cancellation source for long-running handlers: the SDK aborts this
|
|
127
|
+
// signal when the client cancels the call or the transport closes.
|
|
128
|
+
signal: extra?.signal,
|
|
126
129
|
};
|
|
127
130
|
const progressCallback = createProgressCallback(srv, typeof progressToken === 'string' || typeof progressToken === 'number' ? String(progressToken) : undefined);
|
|
128
131
|
requestLogger.info(`Executing tool: ${name}`);
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Project Context Service
|
|
3
|
-
* At startup: detect repo → resolve project →
|
|
4
|
-
* Exposes the result so tool
|
|
3
|
+
* At startup (lazily, on first tool call): detect repo → resolve project →
|
|
4
|
+
* fetch environments + credential LABELS. Exposes the result so tool
|
|
5
|
+
* descriptions can be enriched dynamically (see tools/index.ts).
|
|
6
|
+
*
|
|
7
|
+
* Thin relay: we consume the backend's environment/credential contract and
|
|
8
|
+
* surface LABELS ONLY — never a password or any other secret. The
|
|
9
|
+
* backend→internal field mapping lives in ONE place: mapEnvironments().
|
|
5
10
|
*/
|
|
6
11
|
import { config } from '../config/index.js';
|
|
7
12
|
import { DebuggAIServerClient } from './index.js';
|
|
@@ -10,14 +15,54 @@ import { Logger } from '../utils/logger.js';
|
|
|
10
15
|
const logger = new Logger({ module: 'projectContext' });
|
|
11
16
|
let cached = null;
|
|
12
17
|
let inFlight = null;
|
|
18
|
+
const STARTUP_TIMEOUT_MS = 10_000;
|
|
13
19
|
/**
|
|
14
|
-
*
|
|
20
|
+
* The ONE place the backend environment/credential contract is mapped to our
|
|
21
|
+
* internal shape. Pinned contract (backend epic sentinal-k8x1f.8):
|
|
22
|
+
*
|
|
23
|
+
* GET /api/v1/environments/?project=<uuid>
|
|
24
|
+
* → [{ uuid, name, url, is_default, is_active, endpoint_type,
|
|
25
|
+
* credentials: [{ label, username }] }]
|
|
26
|
+
*
|
|
27
|
+
* (The axios transport auto-converts snake_case → camelCase, so we read
|
|
28
|
+
* `isDefault`/`isActive`/`endpointType` here.) Password is write-only and
|
|
29
|
+
* NEVER returned; we defensively copy only label/username (+ uuid/role if the
|
|
30
|
+
* backend ever includes them) — we never spread a raw credential object, so a
|
|
31
|
+
* secret can't leak into a tool description even if the contract regresses.
|
|
32
|
+
*/
|
|
33
|
+
export function mapEnvironments(rawEnvs) {
|
|
34
|
+
return (rawEnvs ?? [])
|
|
35
|
+
// Surface only active environments (is_active !== false).
|
|
36
|
+
.filter((e) => e?.isActive !== false)
|
|
37
|
+
.map((env) => ({
|
|
38
|
+
uuid: env.uuid,
|
|
39
|
+
name: env.name,
|
|
40
|
+
url: env.url || env.activeUrl || '',
|
|
41
|
+
isDefault: env.isDefault ?? false,
|
|
42
|
+
isActive: env.isActive ?? true,
|
|
43
|
+
endpointType: env.endpointType ?? null,
|
|
44
|
+
credentials: (env.credentials ?? [])
|
|
45
|
+
.filter((c) => c && c.isActive !== false)
|
|
46
|
+
.map((c) => ({
|
|
47
|
+
label: c.label || c.username,
|
|
48
|
+
username: c.username,
|
|
49
|
+
...(c.uuid ? { uuid: c.uuid } : {}),
|
|
50
|
+
...(c.role !== undefined ? { role: c.role } : {}),
|
|
51
|
+
environmentName: env.name,
|
|
52
|
+
environmentUuid: env.uuid,
|
|
53
|
+
})),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the current project context: repo → project → environments →
|
|
58
|
+
* credential labels.
|
|
15
59
|
*
|
|
16
60
|
* Caches the first successful result. Concurrent calls share a single in-flight
|
|
17
|
-
* promise. Failures are NOT cached — the next call
|
|
18
|
-
* network error on the first tool call doesn't permanently disable
|
|
61
|
+
* promise. Failures are NOT cached — the next call retries — so a transient
|
|
62
|
+
* network error on the first tool call doesn't permanently disable enrichment.
|
|
63
|
+
* The whole resolution is bounded by STARTUP_TIMEOUT_MS so a slow/hung backend
|
|
64
|
+
* can never block boot or a tool call.
|
|
19
65
|
*/
|
|
20
|
-
const STARTUP_TIMEOUT_MS = 10_000;
|
|
21
66
|
export async function resolveProjectContext() {
|
|
22
67
|
if (cached)
|
|
23
68
|
return cached;
|
|
@@ -67,40 +112,15 @@ async function resolveProjectContextInner(repoName) {
|
|
|
67
112
|
return null;
|
|
68
113
|
}
|
|
69
114
|
logger.info(`Resolved project: ${project.name} (${project.uuid})`);
|
|
70
|
-
//
|
|
71
|
-
const envResponse = await client.tx.get(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
.map(async (env) => {
|
|
77
|
-
let credentials = [];
|
|
78
|
-
try {
|
|
79
|
-
const credResponse = await client.tx.get(`api/v1/projects/${project.uuid}/environments/${env.uuid}/credentials/`);
|
|
80
|
-
credentials = (credResponse?.results ?? [])
|
|
81
|
-
.filter((c) => c.isActive)
|
|
82
|
-
.map((c) => ({
|
|
83
|
-
uuid: c.uuid,
|
|
84
|
-
label: c.label || c.username,
|
|
85
|
-
username: c.username,
|
|
86
|
-
role: c.role,
|
|
87
|
-
environmentName: env.name,
|
|
88
|
-
environmentUuid: env.uuid,
|
|
89
|
-
}));
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
// Some environments may not support credentials
|
|
93
|
-
}
|
|
94
|
-
return {
|
|
95
|
-
uuid: env.uuid,
|
|
96
|
-
name: env.name,
|
|
97
|
-
url: env.url || env.activeUrl || '',
|
|
98
|
-
credentials,
|
|
99
|
-
};
|
|
100
|
-
}));
|
|
115
|
+
// Pinned contract: environments (with credentials inlined) for the project.
|
|
116
|
+
const envResponse = await client.tx.get('api/v1/environments/', {
|
|
117
|
+
project: project.uuid,
|
|
118
|
+
});
|
|
119
|
+
const rawEnvs = Array.isArray(envResponse) ? envResponse : (envResponse?.results ?? []);
|
|
120
|
+
const environments = mapEnvironments(rawEnvs);
|
|
101
121
|
cached = { repoName, project, environments };
|
|
102
122
|
const totalCreds = environments.reduce((n, e) => n + e.credentials.length, 0);
|
|
103
|
-
logger.info(`Project context ready: ${environments.length} environments, ${totalCreds}
|
|
123
|
+
logger.info(`Project context ready: ${environments.length} environments, ${totalCreds} credential labels`);
|
|
104
124
|
return cached;
|
|
105
125
|
}
|
|
106
126
|
catch (err) {
|
|
@@ -111,3 +131,8 @@ async function resolveProjectContextInner(repoName) {
|
|
|
111
131
|
export function getProjectContext() {
|
|
112
132
|
return cached;
|
|
113
133
|
}
|
|
134
|
+
/** Test-only: reset the module cache so each test starts clean. */
|
|
135
|
+
export function __resetProjectContextForTests() {
|
|
136
|
+
cached = null;
|
|
137
|
+
inFlight = null;
|
|
138
|
+
}
|
|
@@ -13,81 +13,43 @@ const POLL_INTERVAL_MAX_MS = 5000;
|
|
|
13
13
|
const POLL_BACKOFF_MULTIPLIER = 1.5;
|
|
14
14
|
const EXECUTION_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
|
|
15
15
|
/**
|
|
16
|
-
* Stable dispatch
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* Stable dispatch slugs for the three MCP browser workflows (bug clka /
|
|
17
|
+
* bead 56kd.8). All three handlers pin to a STABLE slug so a backend
|
|
18
|
+
* display-name change (or template rework) never breaks dispatch — the
|
|
19
|
+
* fuzzy name-substring fallback is fully retired now that the backend does
|
|
20
|
+
* server-side `?slug=` exact matching (contract sentinal-k8x1f.11).
|
|
21
21
|
*
|
|
22
|
-
*
|
|
22
|
+
* Each is env-overridable to pin a different slug without a code change.
|
|
23
23
|
*/
|
|
24
|
-
export const EVAL_TEMPLATE_SLUG = 'flow/e2es/app-eval';
|
|
25
|
-
|
|
26
|
-
export const
|
|
24
|
+
export const EVAL_TEMPLATE_SLUG = 'flow/e2es/app-eval'; // check_app_in_browser
|
|
25
|
+
export const PAGE_PROBE_TEMPLATE_SLUG = 'flow/tools/probe'; // probe_page
|
|
26
|
+
export const RAW_CRAWL_TEMPLATE_SLUG = 'crawl-execution-workflow-template'; // trigger_crawl
|
|
27
27
|
/** The slug the eval-template dispatch pins to (env-overridable). */
|
|
28
28
|
export function getEvalTemplateSlug() {
|
|
29
29
|
return process.env.DEBUGGAI_EVAL_TEMPLATE || EVAL_TEMPLATE_SLUG;
|
|
30
30
|
}
|
|
31
|
+
/** The slug the page-probe dispatch pins to (env-overridable). */
|
|
32
|
+
export function getPageProbeTemplateSlug() {
|
|
33
|
+
return process.env.DEBUGGAI_PROBE_TEMPLATE || PAGE_PROBE_TEMPLATE_SLUG;
|
|
34
|
+
}
|
|
35
|
+
/** The slug the raw-crawl dispatch pins to (env-overridable). */
|
|
36
|
+
export function getCrawlTemplateSlug() {
|
|
37
|
+
return process.env.DEBUGGAI_CRAWL_TEMPLATE || RAW_CRAWL_TEMPLATE_SLUG;
|
|
38
|
+
}
|
|
31
39
|
export const createWorkflowsService = (tx) => {
|
|
32
40
|
const service = {
|
|
33
|
-
async findTemplateByName(keyword) {
|
|
34
|
-
// Narrow server-side with `search` AND walk every page. The backend caps
|
|
35
|
-
// the page size (it ignores page_size), so reading only page 1 silently
|
|
36
|
-
// hides templates that sort later — that bug made check_app_in_browser
|
|
37
|
-
// fail in prod because "App Evaluation Workflow Template" sat on page 2.
|
|
38
|
-
// `search` collapses the candidate set to one page on backends that
|
|
39
|
-
// support it; `page` paging is the fallback for those that ignore it.
|
|
40
|
-
const needle = keyword.toLowerCase();
|
|
41
|
-
const seenNames = [];
|
|
42
|
-
const MAX_PAGES = 50; // safety valve against a backend that always returns `next`
|
|
43
|
-
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
44
|
-
const response = await tx.get('api/v1/workflows/', { isTemplate: true, search: keyword, page });
|
|
45
|
-
const templates = response?.results ?? [];
|
|
46
|
-
for (const t of templates) {
|
|
47
|
-
seenNames.push(t.name);
|
|
48
|
-
if (t.name.toLowerCase().includes(needle))
|
|
49
|
-
return t;
|
|
50
|
-
}
|
|
51
|
-
if (!response?.next)
|
|
52
|
-
break;
|
|
53
|
-
}
|
|
54
|
-
if (seenNames.length === 0)
|
|
55
|
-
return null;
|
|
56
|
-
throw new Error(`No workflow template matching "${keyword}" found. ` +
|
|
57
|
-
`Available templates: ${seenNames.map(n => `"${n}"`).join(', ')}. ` +
|
|
58
|
-
`Ensure the backend has a template with "${keyword}" in its name.`);
|
|
59
|
-
},
|
|
60
41
|
async findTemplateBySlug(slug) {
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const MAX_PAGES = 50;
|
|
69
|
-
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
70
|
-
const response = await tx.get('api/v1/workflows/', { isTemplate: true, slug, page });
|
|
71
|
-
const templates = response?.results ?? [];
|
|
72
|
-
for (const t of templates) {
|
|
73
|
-
if (t.slug === slug)
|
|
74
|
-
return t;
|
|
75
|
-
}
|
|
76
|
-
if (!response?.next)
|
|
77
|
-
break;
|
|
78
|
-
}
|
|
79
|
-
return null;
|
|
42
|
+
// Pure server-side slug resolve (bead 56kd.8). The backend does an EXACT
|
|
43
|
+
// `?slug=` match server-side (contract sentinal-k8x1f.11) and returns
|
|
44
|
+
// exactly that template, so this is a single GET — NO page-walk, NO
|
|
45
|
+
// client-side name/slug filtering, NO name-substring fallback. A slug is
|
|
46
|
+
// the stable template identity, so a backend rename can never break us.
|
|
47
|
+
const response = await tx.get('api/v1/workflows/', { isTemplate: true, slug });
|
|
48
|
+
return response?.results?.[0] ?? null;
|
|
80
49
|
},
|
|
81
50
|
async findEvaluationTemplate() {
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
const bySlug = await service.findTemplateBySlug(slug);
|
|
85
|
-
if (bySlug)
|
|
86
|
-
return bySlug;
|
|
87
|
-
// Fallback (interim, until backend contract sentinal-k8x1f.8 lands): the
|
|
88
|
-
// name is specific enough to skip 'App Evaluation Brain' (subworkflow, no
|
|
89
|
-
// browser lifecycle) which also contains 'app evaluation'.
|
|
90
|
-
return service.findTemplateByName(EVAL_TEMPLATE_NAME_FALLBACK);
|
|
51
|
+
// Resolve the App Evaluation template purely by its stable slug.
|
|
52
|
+
return service.findTemplateBySlug(getEvalTemplateSlug());
|
|
91
53
|
},
|
|
92
54
|
async executeWorkflow(workflowUuid, contextData, env) {
|
|
93
55
|
const body = { contextData };
|
package/dist/tools/index.js
CHANGED
|
@@ -6,9 +6,36 @@ import { buildEnvironmentTool, buildValidatedEnvironmentTool } from './environme
|
|
|
6
6
|
import { buildExecutionsTool, buildValidatedExecutionsTool } from './executions.js';
|
|
7
7
|
import { buildTestSuiteTool, buildValidatedTestSuiteTool } from './testSuite.js';
|
|
8
8
|
import { buildTestCaseTool, buildValidatedTestCaseTool } from './testCase.js';
|
|
9
|
+
import { resolveProjectContext } from '../services/projectContext.js';
|
|
9
10
|
let _tools = null;
|
|
10
11
|
let _validatedTools = null;
|
|
11
12
|
const toolRegistry = new Map();
|
|
13
|
+
let enrichmentTriggered = false;
|
|
14
|
+
/**
|
|
15
|
+
* Lazily resolve project → environments → credential labels ON FIRST USE and
|
|
16
|
+
* rebuild the tool definitions so the enriched description (available
|
|
17
|
+
* environments + credential labels) is served on the next tools/list.
|
|
18
|
+
*
|
|
19
|
+
* Fire-and-forget: this never blocks the triggering call. resolveProjectContext
|
|
20
|
+
* has its own 10s timeout, so boot and the first tool call are never gated on a
|
|
21
|
+
* slow backend. Idempotent — the first invocation wins; the cached context
|
|
22
|
+
* makes subsequent resolves free.
|
|
23
|
+
*/
|
|
24
|
+
function triggerEnrichmentOnce() {
|
|
25
|
+
if (enrichmentTriggered)
|
|
26
|
+
return;
|
|
27
|
+
enrichmentTriggered = true;
|
|
28
|
+
resolveProjectContext()
|
|
29
|
+
.then((ctx) => {
|
|
30
|
+
// Only rebuild when we actually resolved a linked project; otherwise the
|
|
31
|
+
// base description stays (no project detected / not linked).
|
|
32
|
+
if (ctx)
|
|
33
|
+
initTools(ctx);
|
|
34
|
+
})
|
|
35
|
+
.catch(() => {
|
|
36
|
+
// Best-effort enrichment — a failure leaves the base description in place.
|
|
37
|
+
});
|
|
38
|
+
}
|
|
12
39
|
/**
|
|
13
40
|
* Initialize tools with project context (call once after resolveProjectContext).
|
|
14
41
|
*
|
|
@@ -46,10 +73,12 @@ export function initTools(ctx) {
|
|
|
46
73
|
export function getTools() {
|
|
47
74
|
if (!_tools)
|
|
48
75
|
initTools(null);
|
|
76
|
+
triggerEnrichmentOnce();
|
|
49
77
|
return _tools;
|
|
50
78
|
}
|
|
51
79
|
export function getTool(name) {
|
|
52
80
|
if (!_validatedTools)
|
|
53
81
|
initTools(null);
|
|
82
|
+
triggerEnrichmentOnce();
|
|
54
83
|
return toolRegistry.get(name);
|
|
55
84
|
}
|
|
@@ -26,12 +26,16 @@ export function buildToolDescription(ctx) {
|
|
|
26
26
|
`\nAVAILABLE ENVIRONMENTS & CREDENTIALS (pass environmentId + credentialId for authenticated testing):`,
|
|
27
27
|
];
|
|
28
28
|
for (const env of envsWithCreds) {
|
|
29
|
-
|
|
29
|
+
const defaultTag = env.isDefault ? ' [default]' : '';
|
|
30
|
+
lines.push(`\n Environment: "${env.name}" (${env.uuid})${defaultTag}${env.url ? ` — ${env.url}` : ''}`);
|
|
30
31
|
for (const cred of env.credentials) {
|
|
31
|
-
|
|
32
|
+
// The backend contract surfaces credential LABELS (never passwords). The
|
|
33
|
+
// uuid/role are optional — shown only when the backend includes them.
|
|
34
|
+
const idPart = cred.uuid ? ` (${cred.uuid})` : '';
|
|
35
|
+
let line = ` - "${cred.label}"${idPart} — user: ${cred.username}`;
|
|
32
36
|
if (cred.role)
|
|
33
|
-
|
|
34
|
-
lines.push(
|
|
37
|
+
line += `, role: ${cred.role}`;
|
|
38
|
+
lines.push(line);
|
|
35
39
|
}
|
|
36
40
|
}
|
|
37
41
|
lines.push(`\nTo use: pass environmentId and credentialId from above. Or provide username/password directly.`);
|
|
@@ -82,6 +86,30 @@ export function buildTestPageChangesTool(ctx) {
|
|
|
82
86
|
type: "string",
|
|
83
87
|
description: "GitHub repository name (e.g. 'my-org/my-repo'). Auto-detected from the current git repo — only provide this if you want to run against a different project than the one you're in."
|
|
84
88
|
},
|
|
89
|
+
auth: {
|
|
90
|
+
type: "object",
|
|
91
|
+
description: "Optional auth-precondition for a 'log in THEN deep-navigate' check. Set precondition:'login' to authenticate first (using the environment's credentials), then land on deepUrl. Use this instead of hoping the agent signs itself in at a login wall.",
|
|
92
|
+
properties: {
|
|
93
|
+
environmentId: {
|
|
94
|
+
type: "string",
|
|
95
|
+
description: "UUID of the environment whose credentials to log in with. See available environments in the tool description above."
|
|
96
|
+
},
|
|
97
|
+
precondition: {
|
|
98
|
+
type: "string",
|
|
99
|
+
enum: ["login", "none"],
|
|
100
|
+
description: "'login' = authenticate before evaluating; 'none' (default) = no login precondition."
|
|
101
|
+
},
|
|
102
|
+
entryUrl: {
|
|
103
|
+
type: "string",
|
|
104
|
+
description: "Optional URL of the login page to authenticate on."
|
|
105
|
+
},
|
|
106
|
+
deepUrl: {
|
|
107
|
+
type: "string",
|
|
108
|
+
description: "Optional URL to navigate to and evaluate AFTER login (e.g. a deep settings page). Falls back to `url` if omitted."
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
additionalProperties: false
|
|
112
|
+
},
|
|
85
113
|
},
|
|
86
114
|
required: ["description", "url"],
|
|
87
115
|
additionalProperties: false
|
package/dist/types/index.js
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { normalizeUrl } from '../utils/urlParser.js';
|
|
6
|
+
/**
|
|
7
|
+
* Auth-precondition deep-link block (bead debugg_ai_mcp-56kd.6).
|
|
8
|
+
*
|
|
9
|
+
* A first-class way to express "log in THEN deep-navigate to /settings/x" —
|
|
10
|
+
* forwarded verbatim into the dispatch contextData.auth per backend contract
|
|
11
|
+
* sentinal-k8x1f.8. The MCP is a THIN relay: it accepts the intent and forwards
|
|
12
|
+
* it; the backend authenticates (using the environment's credentials) and then
|
|
13
|
+
* navigates to deepUrl (falling back to the run's target_url). entryUrl/deepUrl
|
|
14
|
+
* are normalized the same way as the top-level url.
|
|
15
|
+
*/
|
|
16
|
+
export const AuthPreconditionSchema = z.object({
|
|
17
|
+
environmentId: z.string().uuid().optional(),
|
|
18
|
+
precondition: z.enum(['login', 'none']).optional(),
|
|
19
|
+
entryUrl: z.preprocess(normalizeUrl, z.string().url('entryUrl must be a valid URL (the login page to authenticate on).').optional()),
|
|
20
|
+
deepUrl: z.preprocess(normalizeUrl, z.string().url('deepUrl must be a valid URL (the page to evaluate AFTER login).').optional()),
|
|
21
|
+
}).strict();
|
|
6
22
|
/**
|
|
7
23
|
* Tool input validation schemas
|
|
8
24
|
*/
|
|
@@ -16,6 +32,8 @@ export const TestPageChangesInputSchema = z.object({
|
|
|
16
32
|
username: z.string().optional(),
|
|
17
33
|
password: z.string().optional(),
|
|
18
34
|
repoName: z.string().optional(),
|
|
35
|
+
// Auth-precondition deep-link intent (bead 56kd.6) — "log in THEN go to X".
|
|
36
|
+
auth: AuthPreconditionSchema.optional(),
|
|
19
37
|
});
|
|
20
38
|
export const TriggerCrawlInputSchema = z.object({
|
|
21
39
|
url: z.preprocess(normalizeUrl, z.string().url('Invalid URL. Pass a full URL like "http://localhost:3000" or "https://example.com". Localhost URLs are auto-tunneled to the remote browser.')),
|