@debugg-ai/debugg-ai-mcp 3.5.3 → 3.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.
@@ -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 onStdinClose = () => {
103
+ const onAbort = () => {
96
104
  abortController.abort();
97
105
  progressDisabled = true; // client is gone — stop emitting
98
106
  };
99
- process.stdin.once('close', onStdinClose);
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
- if (repoName && !projectUuid) {
230
- logger.info(`No project found for repo "${repoName}" proceeding without project_id`);
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 = {
@@ -593,7 +621,8 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
593
621
  throw handleExternalServiceError(error, 'DebuggAI', 'test execution');
594
622
  }
595
623
  finally {
596
- process.stdin.removeListener('close', onStdinClose);
624
+ if (requestSignal)
625
+ requestSignal.removeEventListener('abort', onAbort);
597
626
  // Tunnel is intentionally NOT torn down here — tunnelManager reuses it on
598
627
  // subsequent calls to the same port and auto-shutoffs after 55 min idle.
599
628
  // Process-exit cleanup happens via stopAllTunnels() in the SIGINT/SIGTERM
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 → fetch environments + credentials.
4
- * Exposes the result so tool descriptions can be enriched dynamically.
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
- * Resolve the current project context: repo project environments → credentials.
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 will retry — so a transient
18
- * network error on the first tool call doesn't permanently disable the service.
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
- // Fetch environments for this project
71
- const envResponse = await client.tx.get(`api/v1/projects/${project.uuid}/environments/`);
72
- const rawEnvs = envResponse?.results ?? [];
73
- // Fetch credentials for each environment in parallel
74
- const environments = await Promise.all(rawEnvs
75
- .filter((e) => e.isActive)
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} credentials`);
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
+ }
@@ -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
- lines.push(`\n Environment: "${env.name}" (${env.uuid})${env.url ? ` ${env.url}` : ''}`);
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
- const parts = [` - "${cred.label}" (${cred.uuid}) user: ${cred.username}`];
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
- parts[0] += `, role: ${cred.role}`;
34
- lines.push(parts[0]);
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.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "3.5.3",
3
+ "version": "3.6.0",
4
4
  "description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
5
5
  "type": "module",
6
6
  "bin": {