@debugg-ai/debugg-ai-mcp 2.8.0 → 2.9.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.
@@ -40,6 +40,10 @@ function isTelemetryDisabled() {
40
40
  const v = (process.env.DEBUGGAI_TELEMETRY_DISABLED || '').toLowerCase();
41
41
  return v === '1' || v === 'true' || v === 'yes' || v === 'on';
42
42
  }
43
+ function isDevMode() {
44
+ const v = (process.env.DEBUGGAI_DEV_MODE || '').toLowerCase();
45
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
46
+ }
43
47
  function resolvePosthogKey() {
44
48
  if (isTelemetryDisabled())
45
49
  return undefined;
@@ -50,6 +54,7 @@ const configSchema = z.object({
50
54
  name: z.string().default('DebuggAI MCP Server'),
51
55
  version: z.string(),
52
56
  }),
57
+ devMode: z.boolean().default(false),
53
58
  api: z.object({
54
59
  // key is validated at tool-call time (not at boot) so MCP clients can surface
55
60
  // a proper error message instead of seeing the subprocess die → "Failed to
@@ -74,11 +79,12 @@ export function loadConfig() {
74
79
  name: 'DebuggAI MCP Server',
75
80
  version: _version,
76
81
  },
82
+ devMode: isDevMode(),
77
83
  api: {
78
84
  // Priority: DEBUGGAI_API_TOKEN → DEBUGGAI_JWT_TOKEN → DEBUGGAI_API_KEY
79
85
  key: process.env.DEBUGGAI_API_TOKEN || process.env.DEBUGGAI_JWT_TOKEN || process.env.DEBUGGAI_API_KEY || '',
80
86
  tokenType: process.env.DEBUGGAI_TOKEN_TYPE || 'token',
81
- baseUrl: process.env.DEBUGGAI_API_URL || 'https://api.debugg.ai',
87
+ baseUrl: process.env.DEBUGGAI_API_URL || (isDevMode() ? 'http://localhost:8012' : 'https://api.debugg.ai'),
82
88
  },
83
89
  defaults: {},
84
90
  logging: {
@@ -106,6 +112,7 @@ export function loadConfig() {
106
112
  let _config;
107
113
  export const config = {
108
114
  get server() { return getConfig().server; },
115
+ get devMode() { return getConfig().devMode; },
109
116
  get api() { return getConfig().api; },
110
117
  get defaults() { return getConfig().defaults; },
111
118
  get logging() { return getConfig().logging; },
@@ -117,3 +124,6 @@ function getConfig() {
117
124
  }
118
125
  return _config;
119
126
  }
127
+ export function _resetConfigForTest() {
128
+ _config = undefined;
129
+ }
@@ -89,53 +89,60 @@ export async function probePageHandler(input, context, rawProgressCallback) {
89
89
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
90
90
  }
91
91
  }
92
- // Reuse existing tunnel for this port if any; otherwise provision.
93
- const reused = findExistingTunnel(ctx);
94
- if (reused) {
95
- targetContexts.push(reused);
92
+ if (config.devMode) {
93
+ // Dev mode: local backend can reach localhost directly — no tunnel needed.
94
+ logger.info(`probe_page: dev mode — using localhost URL directly: ${ctx.originalUrl}`);
95
+ targetContexts.push(ctx);
96
96
  }
97
97
  else {
98
- let tunnel;
99
- try {
100
- tunnel = await client.tunnels.provisionWithRetry();
98
+ // Reuse existing tunnel for this port if any; otherwise provision.
99
+ const reused = findExistingTunnel(ctx);
100
+ if (reused) {
101
+ targetContexts.push(reused);
101
102
  }
102
- catch (provisionError) {
103
- const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
104
- const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
105
- throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
106
- `(Detail: ${msg})${diag}`);
107
- }
108
- acquiredKeyIds.push(tunnel.keyId);
109
- let tunneled;
110
- try {
111
- tunneled = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
112
- }
113
- catch (tunnelError) {
114
- const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
115
- throw new Error(`Tunnel creation failed for ${ctx.originalUrl}. (Detail: ${msg})`);
116
- }
117
- // Tunnel health probe: catch the IPv4/IPv6 bind / dead-server case
118
- // before committing to a full backend execution.
119
- if (tunneled.targetUrl) {
120
- const health = await probeTunnelHealth(tunneled.targetUrl);
121
- if (!health.healthy) {
122
- const payload = {
123
- error: 'TunnelTrafficBlocked',
124
- message: `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`,
125
- detail: {
126
- code: health.code,
127
- status: health.status,
128
- ngrokErrorCode: health.ngrokErrorCode,
129
- elapsedMs: health.elapsedMs,
130
- },
131
- };
132
- if (tunneled.tunnelId) {
133
- tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
103
+ else {
104
+ let tunnel;
105
+ try {
106
+ tunnel = await client.tunnels.provisionWithRetry();
107
+ }
108
+ catch (provisionError) {
109
+ const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
110
+ const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
111
+ throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
112
+ `(Detail: ${msg})${diag}`);
113
+ }
114
+ acquiredKeyIds.push(tunnel.keyId);
115
+ let tunneled;
116
+ try {
117
+ tunneled = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
118
+ }
119
+ catch (tunnelError) {
120
+ const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
121
+ throw new Error(`Tunnel creation failed for ${ctx.originalUrl}. (Detail: ${msg})`);
122
+ }
123
+ // Tunnel health probe: catch the IPv4/IPv6 bind / dead-server case
124
+ // before committing to a full backend execution.
125
+ if (tunneled.targetUrl) {
126
+ const health = await probeTunnelHealth(tunneled.targetUrl);
127
+ if (!health.healthy) {
128
+ const payload = {
129
+ error: 'TunnelTrafficBlocked',
130
+ message: `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`,
131
+ detail: {
132
+ code: health.code,
133
+ status: health.status,
134
+ ngrokErrorCode: health.ngrokErrorCode,
135
+ elapsedMs: health.elapsedMs,
136
+ },
137
+ };
138
+ if (tunneled.tunnelId) {
139
+ tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
140
+ }
141
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
134
142
  }
135
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
136
143
  }
144
+ targetContexts.push(tunneled);
137
145
  }
138
- targetContexts.push(tunneled);
139
146
  }
140
147
  }
141
148
  else {
@@ -46,47 +46,53 @@ export async function runTestSuiteHandler(input, _context) {
46
46
  return errorResp('LocalServerUnreachable', `No server listening on 127.0.0.1:${port}. Start your dev server before running the suite. (${probe.code}: ${probe.detail ?? 'no detail'})`, { port, probeCode: probe.code, elapsedMs: probe.elapsedMs });
47
47
  }
48
48
  }
49
- // Reuse an existing tunnel for this port if one is already active.
50
- const reused = findExistingTunnel(ctx);
51
- if (reused) {
52
- effectiveTargetUrl = reused.targetUrl ?? input.targetUrl;
53
- tunnelId = reused.tunnelId;
49
+ if (config.devMode) {
50
+ // Dev mode: local backend can reach localhost directly — no tunnel needed.
51
+ logger.info(`run_test_suite: dev mode — using localhost URL directly: ${input.targetUrl}`);
54
52
  }
55
53
  else {
56
- // Provision a new tunnel.
57
- let tunnel;
58
- try {
59
- tunnel = await client.tunnels.provisionWithRetry();
54
+ // Reuse an existing tunnel for this port if one is already active.
55
+ const reused = findExistingTunnel(ctx);
56
+ if (reused) {
57
+ effectiveTargetUrl = reused.targetUrl ?? input.targetUrl;
58
+ tunnelId = reused.tunnelId;
60
59
  }
61
- catch (provisionError) {
62
- const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
63
- const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
64
- return errorResp('TunnelProvisionFailed', `Failed to provision tunnel for ${input.targetUrl}. (Detail: ${msg})${diag}`);
65
- }
66
- acquiredKeyId = tunnel.keyId;
67
- let tunneled;
68
- try {
69
- tunneled = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
70
- }
71
- catch (tunnelError) {
72
- const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
73
- return errorResp('TunnelCreationFailed', `Tunnel creation failed for ${input.targetUrl}. (Detail: ${msg})`);
74
- }
75
- // Health probe catches ERR_NGROK_8012 and bind mismatches before
76
- // the remote agent wastes steps trying to reach the server.
77
- if (tunneled.targetUrl) {
78
- const health = await probeTunnelHealth(tunneled.targetUrl);
79
- if (!health.healthy) {
80
- if (tunneled.tunnelId) {
81
- tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
60
+ else {
61
+ // Provision a new tunnel.
62
+ let tunnel;
63
+ try {
64
+ tunnel = await client.tunnels.provisionWithRetry();
65
+ }
66
+ catch (provisionError) {
67
+ const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
68
+ const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
69
+ return errorResp('TunnelProvisionFailed', `Failed to provision tunnel for ${input.targetUrl}. (Detail: ${msg})${diag}`);
70
+ }
71
+ acquiredKeyId = tunnel.keyId;
72
+ let tunneled;
73
+ try {
74
+ tunneled = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
75
+ }
76
+ catch (tunnelError) {
77
+ const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
78
+ return errorResp('TunnelCreationFailed', `Tunnel creation failed for ${input.targetUrl}. (Detail: ${msg})`);
79
+ }
80
+ // Health probe catches ERR_NGROK_8012 and bind mismatches before
81
+ // the remote agent wastes steps trying to reach the server.
82
+ if (tunneled.targetUrl) {
83
+ const health = await probeTunnelHealth(tunneled.targetUrl);
84
+ if (!health.healthy) {
85
+ if (tunneled.tunnelId) {
86
+ tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
87
+ }
88
+ return errorResp('TunnelTrafficBlocked', `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`, { code: health.code, ngrokErrorCode: health.ngrokErrorCode, elapsedMs: health.elapsedMs });
82
89
  }
83
- return errorResp('TunnelTrafficBlocked', `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`, { code: health.code, ngrokErrorCode: health.ngrokErrorCode, elapsedMs: health.elapsedMs });
84
90
  }
91
+ effectiveTargetUrl = tunneled.targetUrl ?? input.targetUrl;
92
+ tunnelId = tunneled.tunnelId;
85
93
  }
86
- effectiveTargetUrl = tunneled.targetUrl ?? input.targetUrl;
87
- tunnelId = tunneled.tunnelId;
94
+ logger.info(`run_test_suite: localhost detected, tunneled ${input.targetUrl} ${effectiveTargetUrl}`);
88
95
  }
89
- logger.info(`run_test_suite: localhost detected, tunneled ${input.targetUrl} → ${effectiveTargetUrl}`);
90
96
  }
91
97
  }
92
98
  const result = await client.runTestSuite(suiteUuid, { targetUrl: effectiveTargetUrl });
@@ -125,69 +125,75 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
125
125
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
126
126
  }
127
127
  }
128
- if (progressCallback) {
129
- await progressCallback({ progress: 1, total: TOTAL_STEPS, message: 'Provisioning secure tunnel for localhost...' });
130
- }
131
- const reused = findExistingTunnel(ctx);
132
- if (reused) {
133
- ctx = reused;
134
- logger.info(`Reusing tunnel: ${ctx.targetUrl} (id: ${ctx.tunnelId})`);
128
+ if (config.devMode) {
129
+ // Dev mode: local backend can reach localhost directly no tunnel needed.
130
+ logger.info(`check_app_in_browser: dev mode — using localhost URL directly: ${ctx.originalUrl}`);
135
131
  }
136
132
  else {
137
- let tunnel;
138
- try {
139
- tunnel = await client.tunnels.provisionWithRetry();
140
- }
141
- catch (provisionError) {
142
- const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
143
- const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
144
- throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
145
- `The remote browser needs a secure tunnel to reach your local dev server. ` +
146
- `Make sure your dev server is running on the specified port and try again. ` +
147
- `(Detail: ${msg})${diag}`);
133
+ if (progressCallback) {
134
+ await progressCallback({ progress: 1, total: TOTAL_STEPS, message: 'Provisioning secure tunnel for localhost...' });
148
135
  }
149
- keyId = tunnel.keyId;
150
- try {
151
- ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
136
+ const reused = findExistingTunnel(ctx);
137
+ if (reused) {
138
+ ctx = reused;
139
+ logger.info(`Reusing tunnel: ${ctx.targetUrl} (id: ${ctx.tunnelId})`);
152
140
  }
153
- catch (tunnelError) {
154
- const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
155
- throw new Error(`Tunnel creation failed for ${ctx.originalUrl}. ` +
156
- `Could not establish a secure connection between the remote browser and your local port. ` +
157
- `Verify your dev server is running and the port is accessible. ` +
158
- `(Detail: ${msg})`);
141
+ else {
142
+ let tunnel;
143
+ try {
144
+ tunnel = await client.tunnels.provisionWithRetry();
145
+ }
146
+ catch (provisionError) {
147
+ const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
148
+ const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
149
+ throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
150
+ `The remote browser needs a secure tunnel to reach your local dev server. ` +
151
+ `Make sure your dev server is running on the specified port and try again. ` +
152
+ `(Detail: ${msg})${diag}`);
153
+ }
154
+ keyId = tunnel.keyId;
155
+ try {
156
+ ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
157
+ }
158
+ catch (tunnelError) {
159
+ const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
160
+ throw new Error(`Tunnel creation failed for ${ctx.originalUrl}. ` +
161
+ `Could not establish a secure connection between the remote browser and your local port. ` +
162
+ `Verify your dev server is running and the port is accessible. ` +
163
+ `(Detail: ${msg})`);
164
+ }
165
+ logger.info(`Tunnel ready: ${ctx.targetUrl} (id: ${ctx.tunnelId})`);
159
166
  }
160
- logger.info(`Tunnel ready: ${ctx.targetUrl} (id: ${ctx.tunnelId})`);
161
- }
162
- // Bead 1om: verify traffic actually flows through the tunnel. The
163
- // tunnel can be established (ngrok.connect returns OK) yet refuse
164
- // to forward traffic e.g., IPv4/IPv6 bind mismatch, or the dev
165
- // server died between the pre-flight probe and here. Catch it now,
166
- // in ~1s, not via a 5-minute browser-agent false-pass.
167
- if (ctx.targetUrl) {
168
- const health = await probeTunnelHealth(ctx.targetUrl);
169
- if (!health.healthy) {
170
- const payload = {
171
- error: 'TunnelTrafficBlocked',
172
- message: `Tunnel was established but traffic isn't reaching the dev server. ${health.detail ?? ''} Common causes: dev server binds to 0.0.0.0 or ::1 but not 127.0.0.1; dev server crashed; firewall.`,
173
- detail: {
174
- code: health.code,
175
- status: health.status,
176
- ngrokErrorCode: health.ngrokErrorCode,
177
- elapsedMs: health.elapsedMs,
178
- },
179
- };
180
- logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
181
- // Tear down the broken tunnel so a subsequent call doesn't reuse it.
182
- // stopTunnel handles both owned (ngrok disconnect + key revoke) and
183
- // borrowed (just drops local ref) cases.
184
- if (ctx.tunnelId) {
185
- tunnelManager.stopTunnel(ctx.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${ctx.tunnelId}: ${err}`));
167
+ // Bead 1om: verify traffic actually flows through the tunnel. The
168
+ // tunnel can be established (ngrok.connect returns OK) yet refuse
169
+ // to forward traffic e.g., IPv4/IPv6 bind mismatch, or the dev
170
+ // server died between the pre-flight probe and here. Catch it now,
171
+ // in ~1s, not via a 5-minute browser-agent false-pass.
172
+ if (ctx.targetUrl) {
173
+ const health = await probeTunnelHealth(ctx.targetUrl);
174
+ if (!health.healthy) {
175
+ const payload = {
176
+ error: 'TunnelTrafficBlocked',
177
+ message: `Tunnel was established but traffic isn't reaching the dev server. ${health.detail ?? ''} Common causes: dev server binds to 0.0.0.0 or ::1 but not 127.0.0.1; dev server crashed; firewall.`,
178
+ detail: {
179
+ code: health.code,
180
+ status: health.status,
181
+ ngrokErrorCode: health.ngrokErrorCode,
182
+ elapsedMs: health.elapsedMs,
183
+ },
184
+ };
185
+ logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
186
+ // Tear down the broken tunnel so a subsequent call doesn't reuse it.
187
+ // stopTunnel handles both owned (ngrok disconnect + key revoke) and
188
+ // borrowed (just drops local ref) cases.
189
+ if (ctx.tunnelId) {
190
+ tunnelManager.stopTunnel(ctx.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${ctx.tunnelId}: ${err}`));
191
+ }
192
+ // keyId is consumed by stopTunnel's revoke path; clear so the
193
+ // outer finally block doesn't double-revoke.
194
+ keyId = undefined;
195
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
186
196
  }
187
- // keyId is consumed by stopTunnel's revoke path; clear so the
188
- // outer finally block doesn't double-revoke.
189
- keyId = undefined;
190
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
191
197
  }
192
198
  }
193
199
  }
@@ -82,48 +82,54 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
82
82
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
83
83
  }
84
84
  }
85
- if (progressCallback) {
86
- await progressCallback({ progress: 1, total: 4, message: 'Provisioning secure tunnel for localhost...' });
87
- }
88
- const reused = findExistingTunnel(ctx);
89
- if (reused) {
90
- ctx = reused;
85
+ if (config.devMode) {
86
+ // Dev mode: local backend can reach localhost directly no tunnel needed.
87
+ logger.info(`trigger_crawl: dev mode — using localhost URL directly: ${ctx.originalUrl}`);
91
88
  }
92
89
  else {
93
- let tunnel;
94
- try {
95
- tunnel = await client.tunnels.provisionWithRetry();
90
+ if (progressCallback) {
91
+ await progressCallback({ progress: 1, total: 4, message: 'Provisioning secure tunnel for localhost...' });
96
92
  }
97
- catch (provisionError) {
98
- const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
99
- const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
100
- throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
101
- `The remote browser needs a secure tunnel to reach your local dev server. ` +
102
- `(Detail: ${msg})${diag}`);
93
+ const reused = findExistingTunnel(ctx);
94
+ if (reused) {
95
+ ctx = reused;
103
96
  }
104
- keyId = tunnel.keyId;
105
- ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
106
- }
107
- // Bead 1om: post-tunnel health check — verify traffic actually flows.
108
- if (ctx.targetUrl) {
109
- const health = await probeTunnelHealth(ctx.targetUrl);
110
- if (!health.healthy) {
111
- const payload = {
112
- error: 'TunnelTrafficBlocked',
113
- message: `Tunnel was established but traffic isn't reaching the dev server. ${health.detail ?? ''} Common causes: dev server binds to 0.0.0.0 or ::1 but not 127.0.0.1; dev server crashed; firewall.`,
114
- detail: {
115
- code: health.code,
116
- status: health.status,
117
- ngrokErrorCode: health.ngrokErrorCode,
118
- elapsedMs: health.elapsedMs,
119
- },
120
- };
121
- logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
122
- if (ctx.tunnelId) {
123
- tunnelManager.stopTunnel(ctx.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${ctx.tunnelId}: ${err}`));
97
+ else {
98
+ let tunnel;
99
+ try {
100
+ tunnel = await client.tunnels.provisionWithRetry();
101
+ }
102
+ catch (provisionError) {
103
+ const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
104
+ const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
105
+ throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
106
+ `The remote browser needs a secure tunnel to reach your local dev server. ` +
107
+ `(Detail: ${msg})${diag}`);
108
+ }
109
+ keyId = tunnel.keyId;
110
+ ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
111
+ }
112
+ // Bead 1om: post-tunnel health check — verify traffic actually flows.
113
+ if (ctx.targetUrl) {
114
+ const health = await probeTunnelHealth(ctx.targetUrl);
115
+ if (!health.healthy) {
116
+ const payload = {
117
+ error: 'TunnelTrafficBlocked',
118
+ message: `Tunnel was established but traffic isn't reaching the dev server. ${health.detail ?? ''} Common causes: dev server binds to 0.0.0.0 or ::1 but not 127.0.0.1; dev server crashed; firewall.`,
119
+ detail: {
120
+ code: health.code,
121
+ status: health.status,
122
+ ngrokErrorCode: health.ngrokErrorCode,
123
+ elapsedMs: health.elapsedMs,
124
+ },
125
+ };
126
+ logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
127
+ if (ctx.tunnelId) {
128
+ tunnelManager.stopTunnel(ctx.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${ctx.tunnelId}: ${err}`));
129
+ }
130
+ keyId = undefined;
131
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
124
132
  }
125
- keyId = undefined;
126
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
127
133
  }
128
134
  }
129
135
  }
@@ -29,7 +29,23 @@ export const createWorkflowsService = (tx) => {
29
29
  return match;
30
30
  },
31
31
  async findEvaluationTemplate() {
32
- return service.findTemplateByName('app evaluation');
32
+ // Try keywords in priority order — allows prod ('app evaluation') and
33
+ // dev backends with different naming ('Browser Use Evaluation Workflow Template')
34
+ // to both resolve without config changes. 'evaluation workflow' is specific
35
+ // enough to exclude 'Browser Use Evaluation Brain'.
36
+ const envOverride = process.env.DEBUGGAI_EVAL_TEMPLATE;
37
+ const keywords = envOverride
38
+ ? [envOverride]
39
+ : ['app evaluation', 'evaluation workflow'];
40
+ for (const keyword of keywords) {
41
+ try {
42
+ return await service.findTemplateByName(keyword);
43
+ }
44
+ catch {
45
+ // keyword not matched on this backend, try next
46
+ }
47
+ }
48
+ return null;
33
49
  },
34
50
  async executeWorkflow(workflowUuid, contextData, env) {
35
51
  const body = { contextData };
@@ -1,6 +1,18 @@
1
1
  import { UpdateEnvironmentInputSchema } from '../types/index.js';
2
2
  import { updateEnvironmentHandler } from '../handlers/updateEnvironmentHandler.js';
3
- const DESCRIPTION = `Patch an environment by UUID. Only specified fields (name, url, description) change — other fields are left intact. Returns {updated: true, environment: {...}} with the updated resource. Defaults to the project resolved from the current git repo; pass projectUuid to target a different project. Returns isError:true with NotFound when the uuid doesn't exist.`;
3
+ const DESCRIPTION = `Patch an environment by UUID. Updates fields and/or manages credentials in a single call.
4
+
5
+ ENVIRONMENT FIELDS (all optional — only specified fields change):
6
+ - name, url, description
7
+
8
+ CREDENTIAL MANAGEMENT:
9
+ - addCredentials: [{label, username, password, role?}] — add one or more login credentials to this environment
10
+ - updateCredentials: [{uuid, label?, username?, password?, role?}] — patch existing credentials by UUID
11
+ - removeCredentialIds: ["<uuid>", ...] — delete credentials by UUID
12
+
13
+ Operations run in order: remove → update → add. All credential ops are best-effort — failures go to credentialWarnings without blocking the rest. Passwords are write-only and NEVER returned in responses.
14
+
15
+ Returns {updated, environment, addedCredentials?, updatedCredentials?, removedCredentialIds?, credentialWarnings?}. Returns isError:true with NotFound when the env uuid doesn't exist.`;
4
16
  export function buildUpdateEnvironmentTool() {
5
17
  return {
6
18
  name: 'update_environment',
@@ -14,6 +26,42 @@ export function buildUpdateEnvironmentTool() {
14
26
  url: { type: 'string', description: 'Optional: new base URL.' },
15
27
  description: { type: 'string', description: 'Optional: new description.' },
16
28
  projectUuid: { type: 'string', description: 'Optional: UUID of the target project. Defaults to git-auto-detect.' },
29
+ addCredentials: {
30
+ type: 'array',
31
+ description: 'Add new login credentials to the environment. Each entry requires label, username, password. role is optional.',
32
+ items: {
33
+ type: 'object',
34
+ properties: {
35
+ label: { type: 'string', description: 'Human-readable name (e.g. "admin user", "test account").' },
36
+ username: { type: 'string', description: 'Login email or username.' },
37
+ password: { type: 'string', description: 'Password. Write-only — never returned.' },
38
+ role: { type: 'string', description: 'Optional role tag (e.g. "admin", "guest").' },
39
+ },
40
+ required: ['label', 'username', 'password'],
41
+ additionalProperties: false,
42
+ },
43
+ },
44
+ updateCredentials: {
45
+ type: 'array',
46
+ description: 'Patch existing credentials by UUID. Only specified fields change.',
47
+ items: {
48
+ type: 'object',
49
+ properties: {
50
+ uuid: { type: 'string', description: 'UUID of the credential to update.' },
51
+ label: { type: 'string' },
52
+ username: { type: 'string' },
53
+ password: { type: 'string', description: 'Write-only — never returned.' },
54
+ role: { type: 'string' },
55
+ },
56
+ required: ['uuid'],
57
+ additionalProperties: false,
58
+ },
59
+ },
60
+ removeCredentialIds: {
61
+ type: 'array',
62
+ description: 'UUIDs of credentials to delete.',
63
+ items: { type: 'string' },
64
+ },
17
65
  },
18
66
  required: ['uuid'],
19
67
  additionalProperties: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "2.8.0",
3
+ "version": "2.9.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": {