@debugg-ai/debugg-ai-mcp 1.0.31 → 1.0.32

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.
@@ -8,7 +8,7 @@ import { Logger } from '../utils/logger.js';
8
8
  import { handleExternalServiceError } from '../utils/errors.js';
9
9
  import { fetchImageAsBase64, imageContentBlock } from '../utils/imageUtils.js';
10
10
  import { DebuggAIServerClient } from '../services/index.js';
11
- import { resolveTargetUrl, buildContext, ensureTunnel, releaseTunnel, sanitizeResponseUrls, } from '../utils/tunnelContext.js';
11
+ import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, } from '../utils/tunnelContext.js';
12
12
  const logger = new Logger({ module: 'testPageChangesHandler' });
13
13
  // Cache the template UUID within a server session to avoid re-fetching
14
14
  let cachedTemplateUuid = null;
@@ -19,14 +19,32 @@ export async function testPageChangesHandler(input, context, progressCallback) {
19
19
  await client.init();
20
20
  const originalUrl = resolveTargetUrl(input);
21
21
  let ctx = buildContext(originalUrl);
22
- let ngrokKeyId;
22
+ let keyId;
23
23
  const abortController = new AbortController();
24
24
  const onStdinClose = () => abortController.abort();
25
25
  process.stdin.once('close', onStdinClose);
26
26
  try {
27
+ // --- Tunnel: reuse existing or provision a fresh one ---
28
+ if (ctx.isLocalhost) {
29
+ if (progressCallback) {
30
+ await progressCallback({ progress: 1, total: 10, message: 'Provisioning secure tunnel for localhost...' });
31
+ }
32
+ const reused = findExistingTunnel(ctx);
33
+ if (reused) {
34
+ ctx = reused;
35
+ logger.info(`Reusing tunnel: ${ctx.targetUrl} (id: ${ctx.tunnelId})`);
36
+ }
37
+ else {
38
+ const tunnel = await client.tunnels.provision();
39
+ keyId = tunnel.keyId;
40
+ // revokeKey is stored on the TunnelInfo and fires when the tunnel auto-stops.
41
+ ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
42
+ logger.info(`Tunnel ready: ${ctx.targetUrl} (id: ${ctx.tunnelId})`);
43
+ }
44
+ }
27
45
  // --- Find workflow template ---
28
46
  if (progressCallback) {
29
- await progressCallback({ progress: 1, total: 10, message: 'Locating evaluation workflow template...' });
47
+ await progressCallback({ progress: 2, total: 10, message: 'Locating evaluation workflow template...' });
30
48
  }
31
49
  if (!cachedTemplateUuid) {
32
50
  const template = await client.workflows.findEvaluationTemplate();
@@ -37,9 +55,9 @@ export async function testPageChangesHandler(input, context, progressCallback) {
37
55
  cachedTemplateUuid = template.uuid;
38
56
  logger.info(`Using workflow template: ${template.name} (${template.uuid})`);
39
57
  }
40
- // --- Build context data ---
58
+ // --- Build context data (targetUrl is the tunnel URL for localhost, original URL otherwise) ---
41
59
  const contextData = {
42
- targetUrl: originalUrl,
60
+ targetUrl: ctx.targetUrl ?? originalUrl,
43
61
  goal: input.description,
44
62
  };
45
63
  // --- Build env (credentials/environment) ---
@@ -56,23 +74,11 @@ export async function testPageChangesHandler(input, context, progressCallback) {
56
74
  env.password = input.password;
57
75
  // --- Execute ---
58
76
  if (progressCallback) {
59
- await progressCallback({ progress: 2, total: 10, message: 'Queuing workflow execution...' });
77
+ await progressCallback({ progress: 3, total: 10, message: 'Queuing workflow execution...' });
60
78
  }
61
79
  const executeResponse = await client.workflows.executeWorkflow(cachedTemplateUuid, contextData, Object.keys(env).length > 0 ? env : undefined);
62
80
  const executionUuid = executeResponse.executionUuid;
63
- ngrokKeyId = executeResponse.ngrokKeyId ?? undefined;
64
81
  logger.info(`Execution queued: ${executionUuid}`);
65
- // --- Tunnel (after execute — backend returns tunnelKey, executionUuid is the subdomain) ---
66
- if (ctx.isLocalhost) {
67
- if (progressCallback) {
68
- await progressCallback({ progress: 3, total: 10, message: 'Creating secure tunnel for localhost...' });
69
- }
70
- if (!executeResponse.tunnelKey) {
71
- throw new Error('Backend did not return a tunnel key for localhost execution');
72
- }
73
- ctx = await ensureTunnel(ctx, executeResponse.tunnelKey, executionUuid);
74
- logger.info(`Tunnel ready for ${originalUrl} (id: ${executionUuid})`);
75
- }
76
82
  // --- Poll ---
77
83
  // nodeExecutions grows as each node completes: trigger → browser.setup → surfer.execute_task → browser.teardown
78
84
  const NODE_PHASE_LABELS = {
@@ -101,6 +107,17 @@ export async function testPageChangesHandler(input, context, progressCallback) {
101
107
  // --- Format result ---
102
108
  const outcome = finalExecution.state?.outcome ?? finalExecution.status;
103
109
  const surferNode = finalExecution.nodeExecutions?.find(n => n.nodeType === 'surfer.execute_task');
110
+ // Log all node executions to diagnose what the backend returns
111
+ logger.info('Node executions raw data', {
112
+ nodeCount: finalExecution.nodeExecutions?.length ?? 0,
113
+ nodes: finalExecution.nodeExecutions?.map(n => ({
114
+ nodeId: n.nodeId,
115
+ nodeType: n.nodeType,
116
+ status: n.status,
117
+ outputKeys: n.outputData ? Object.keys(n.outputData) : [],
118
+ outputData: n.outputData,
119
+ })),
120
+ });
104
121
  const responsePayload = {
105
122
  outcome,
106
123
  success: finalExecution.state?.success ?? false,
@@ -130,16 +147,40 @@ export async function testPageChangesHandler(input, context, progressCallback) {
130
147
  const content = [
131
148
  { type: 'text', text: JSON.stringify(responsePayload, null, 2) },
132
149
  ];
133
- // Embed screenshot / GIF from the surfer node output when URLs are present
134
- const outputData = surferNode?.outputData ?? {};
135
- const screenshotUrl = outputData.finalScreenshot ?? outputData.screenshot ?? outputData.screenshotUrl ?? null;
136
- const gifUrl = outputData.runGif ?? outputData.gifUrl ?? null;
150
+ // Search all node outputs for screenshot/gif URLs not just the surfer node
151
+ const SCREENSHOT_KEYS = ['finalScreenshot', 'screenshot', 'screenshotUrl', 'screenshotUri'];
152
+ const GIF_KEYS = ['runGif', 'gifUrl', 'gif', 'videoUrl', 'recordingUrl'];
153
+ let screenshotUrl = null;
154
+ let gifUrl = null;
155
+ for (const node of finalExecution.nodeExecutions ?? []) {
156
+ const data = node.outputData ?? {};
157
+ if (!screenshotUrl) {
158
+ for (const key of SCREENSHOT_KEYS) {
159
+ if (typeof data[key] === 'string' && data[key]) {
160
+ screenshotUrl = data[key];
161
+ break;
162
+ }
163
+ }
164
+ }
165
+ if (!gifUrl) {
166
+ for (const key of GIF_KEYS) {
167
+ if (typeof data[key] === 'string' && data[key]) {
168
+ gifUrl = data[key];
169
+ break;
170
+ }
171
+ }
172
+ }
173
+ if (screenshotUrl && gifUrl)
174
+ break;
175
+ }
137
176
  if (screenshotUrl) {
177
+ logger.info(`Embedding screenshot: ${screenshotUrl}`);
138
178
  const img = await fetchImageAsBase64(screenshotUrl).catch(() => null);
139
179
  if (img)
140
180
  content.push(imageContentBlock(img.data, img.mimeType));
141
181
  }
142
182
  if (gifUrl) {
183
+ logger.info(`Embedding GIF/video: ${gifUrl}`);
143
184
  const gif = await fetchImageAsBase64(gifUrl).catch(() => null);
144
185
  if (gif)
145
186
  content.push(imageContentBlock(gif.data, 'image/gif'));
@@ -156,9 +197,13 @@ export async function testPageChangesHandler(input, context, progressCallback) {
156
197
  }
157
198
  finally {
158
199
  process.stdin.removeListener('close', onStdinClose);
159
- if (ngrokKeyId) {
160
- client.revokeNgrokKey(ngrokKeyId).catch(err => logger.warn(`Failed to revoke ngrok key ${ngrokKeyId}: ${err}`));
200
+ // Tunnels stay alive for reuse — the 55-min auto-shutoff on TunnelManager
201
+ // fires revokeKey when the tunnel actually stops.
202
+ //
203
+ // Only revoke explicitly when we provisioned a key but tunnel creation failed
204
+ // (keyId set, ctx.tunnelId not set → key was never attached to a tunnel).
205
+ if (keyId && !ctx.tunnelId) {
206
+ client.revokeNgrokKey(keyId).catch(err => logger.warn(`Failed to revoke unused ngrok key ${keyId}: ${err}`));
161
207
  }
162
- releaseTunnel(ctx).catch(err => logger.warn(`Failed to stop tunnel: ${err}`));
163
208
  }
164
209
  }
@@ -1,4 +1,5 @@
1
1
  import { createWorkflowsService } from "./workflows.js";
2
+ import { createTunnelsService } from "./tunnels.js";
2
3
  import { AxiosTransport } from "../utils/axiosTransport.js";
3
4
  import { config } from "../config/index.js";
4
5
  /**
@@ -33,6 +34,7 @@ export class DebuggAIServerClient {
33
34
  tx;
34
35
  url;
35
36
  workflows;
37
+ tunnels;
36
38
  constructor(userApiKey) {
37
39
  this.userApiKey = userApiKey;
38
40
  // Note: init() is async and should be called separately
@@ -42,6 +44,7 @@ export class DebuggAIServerClient {
42
44
  this.url = new URL(serverUrl);
43
45
  this.tx = new DebuggTransport({ baseUrl: serverUrl, apiKey: this.userApiKey, tokenType: config.api.tokenType });
44
46
  this.workflows = createWorkflowsService(this.tx);
47
+ this.tunnels = createTunnelsService(this.tx);
45
48
  }
46
49
  /**
47
50
  * Revoke an ngrok API key by its key ID.
@@ -1,18 +1,30 @@
1
1
  /**
2
2
  * Tunnel Management Service
3
- * Provides high-level tunnel management abstraction for localhost URLs
3
+ *
4
+ * Manages per-port ngrok tunnels with two layers of reuse:
5
+ *
6
+ * 1. Within-process — activeTunnels map, 55-min auto-shutoff timer.
7
+ * 2. Cross-process — file-backed RegistryStore so a second MCP instance
8
+ * on the same machine borrows an existing tunnel instead
9
+ * of provisioning a new one for the same port.
10
+ *
11
+ * Lifecycle:
12
+ * - Owned tunnels (isOwned=true) : this process created them; it disconnects
13
+ * and revokes the key on stop.
14
+ * - Borrowed tunnels (isOwned=false): another process owns them; on stop we
15
+ * only remove the local reference.
16
+ * - Auto-shutoff timer checks the shared registry before firing: if another
17
+ * process recently touched the entry the timer resets instead of stopping.
4
18
  */
5
19
  import { Logger } from '../../utils/logger.js';
6
20
  import { isLocalhostUrl, extractLocalhostPort, generateTunnelUrl } from '../../utils/urlParser.js';
7
21
  import { v4 as uuidv4 } from 'uuid';
8
- import { createRequire } from 'module';
9
- // Use createRequire to avoid ES module resolution issues
10
- const require = createRequire(import.meta.url);
22
+ import { getDefaultRegistry, } from './tunnelRegistry.js';
11
23
  let ngrokModule = null;
12
24
  async function getNgrok() {
13
25
  if (!ngrokModule) {
14
26
  try {
15
- ngrokModule = require('ngrok');
27
+ ngrokModule = await import('ngrok');
16
28
  }
17
29
  catch (error) {
18
30
  throw new Error(`Failed to load ngrok module: ${error}`);
@@ -21,103 +33,189 @@ async function getNgrok() {
21
33
  return ngrokModule;
22
34
  }
23
35
  const logger = new Logger({ module: 'tunnelManager' });
36
+ // ── TunnelManager ─────────────────────────────────────────────────────────────
24
37
  class TunnelManager {
38
+ reg;
25
39
  activeTunnels = new Map();
26
40
  pendingTunnels = new Map();
27
41
  initialized = false;
28
- TUNNEL_TIMEOUT_MS = 60 * 55 * 1000; // 55 minutes (we get billed by the hour, so dont want to run 1 min past the hour)
29
- async ensureInitialized() {
30
- if (!this.initialized) {
31
- try {
32
- const ngrok = await getNgrok();
33
- // Try to get the API to check if ngrok is running
34
- const api = ngrok.getApi();
35
- if (!api) {
36
- logger.debug('ngrok API not available, may need to start first tunnel');
37
- }
38
- this.initialized = true;
39
- }
40
- catch (error) {
41
- logger.debug(`ngrok initialization check: ${error}`);
42
- this.initialized = true; // Continue anyway, let connection attempt handle the error
43
- }
42
+ TUNNEL_TIMEOUT_MS = 55 * 60 * 1000;
43
+ constructor(reg = getDefaultRegistry()) {
44
+ this.reg = reg;
45
+ }
46
+ // ── Public API ──────────────────────────────────────────────────────────────
47
+ async processUrl(url, authToken, specificTunnelId, keyId, revokeKey) {
48
+ if (!isLocalhostUrl(url)) {
49
+ return { url, isLocalhost: false };
50
+ }
51
+ const port = extractLocalhostPort(url);
52
+ if (!port) {
53
+ throw new Error(`Could not extract port from localhost URL: ${url}`);
44
54
  }
55
+ if (!authToken) {
56
+ throw new Error('Auth token required to create tunnel for localhost URL');
57
+ }
58
+ const tunnelId = specificTunnelId || uuidv4();
59
+ return this.processPerPort(url, port, authToken, tunnelId, keyId, revokeKey);
45
60
  }
46
61
  /**
47
- * Reset the auto-shutoff timer for a tunnel
62
+ * Return an active tunnel for the given local port, or undefined.
63
+ * For borrowed tunnels, evicts the entry if the owning process has died.
48
64
  */
49
- resetTunnelTimer(tunnelInfo) {
50
- // Clear existing timer
51
- if (tunnelInfo.autoShutoffTimer) {
52
- clearTimeout(tunnelInfo.autoShutoffTimer);
53
- }
54
- // Update last access time
55
- tunnelInfo.lastAccessedAt = Date.now();
56
- // Set new timer
57
- tunnelInfo.autoShutoffTimer = setTimeout(async () => {
58
- logger.info(`Auto-shutting down tunnel ${tunnelInfo.tunnelId} after 60 minutes of inactivity`);
59
- try {
60
- await this.stopTunnel(tunnelInfo.tunnelId);
61
- }
62
- catch (error) {
63
- logger.error(`Failed to auto-shutdown tunnel ${tunnelInfo.tunnelId}:`, error);
65
+ getTunnelForPort(port) {
66
+ const existing = this.findTunnelByPort(port);
67
+ if (!existing)
68
+ return undefined;
69
+ if (!existing.isOwned) {
70
+ // Verify the owning process is still alive
71
+ const entry = this.reg.read()[String(port)];
72
+ if (!entry || !this.reg.isPidAlive(entry.ownerPid)) {
73
+ this.activeTunnels.delete(existing.tunnelId);
74
+ logger.info(`Evicted stale borrowed tunnel ${existing.tunnelId} (owner PID ${entry?.ownerPid} dead)`);
75
+ return undefined;
64
76
  }
65
- }, this.TUNNEL_TIMEOUT_MS);
66
- logger.debug(`Reset timer for tunnel ${tunnelInfo.tunnelId}, will auto-shutdown at ${new Date(tunnelInfo.lastAccessedAt + this.TUNNEL_TIMEOUT_MS).toISOString()}`);
77
+ }
78
+ return existing;
67
79
  }
68
- /**
69
- * Touch a tunnel to reset its timer (called when the tunnel is used)
70
- */
71
80
  touchTunnel(tunnelId) {
72
81
  const tunnelInfo = this.activeTunnels.get(tunnelId);
73
- if (tunnelInfo) {
74
- this.resetTunnelTimer(tunnelInfo);
82
+ if (!tunnelInfo)
83
+ return;
84
+ // Refresh the shared registry entry so the owning process won't auto-shutoff
85
+ // while we're actively using the tunnel (even if we're borrowing it).
86
+ try {
87
+ const registry = this.reg.read();
88
+ const entry = registry[String(tunnelInfo.port)];
89
+ if (entry) {
90
+ entry.lastAccessedAt = Date.now();
91
+ this.reg.write(registry);
92
+ }
93
+ }
94
+ catch {
95
+ // best-effort
75
96
  }
97
+ this.resetTunnelTimer(tunnelInfo);
76
98
  }
77
- /**
78
- * Touch a tunnel by URL (convenience method)
79
- */
80
99
  touchTunnelByUrl(url) {
81
100
  const tunnelId = this.extractTunnelId(url);
82
101
  if (tunnelId) {
83
102
  this.touchTunnel(tunnelId);
84
103
  }
85
104
  }
86
- /**
87
- * Process a URL and create a tunnel if needed
88
- * Returns the URL to use (either original or tunneled) and tunnel metadata
89
- */
90
- async processUrl(url, authToken, specificTunnelId) {
91
- if (!isLocalhostUrl(url)) {
92
- return {
93
- url,
94
- isLocalhost: false
95
- };
105
+ isTunnelUrl(url) {
106
+ return url.includes('.ngrok.debugg.ai');
107
+ }
108
+ extractTunnelId(url) {
109
+ const match = url.match(/https?:\/\/([^.]+)\.ngrok\.debugg\.ai/);
110
+ return match ? match[1] : null;
111
+ }
112
+ getTunnelInfo(tunnelId) {
113
+ return this.activeTunnels.get(tunnelId);
114
+ }
115
+ getActiveTunnels() {
116
+ return Array.from(this.activeTunnels.values());
117
+ }
118
+ async stopTunnel(tunnelId) {
119
+ const tunnelInfo = this.activeTunnels.get(tunnelId);
120
+ if (!tunnelInfo) {
121
+ logger.warn(`Tunnel ${tunnelId} not found for cleanup`);
122
+ return;
96
123
  }
97
- const port = extractLocalhostPort(url);
98
- if (!port) {
99
- throw new Error(`Could not extract port from localhost URL: ${url}`);
124
+ if (tunnelInfo.autoShutoffTimer) {
125
+ clearTimeout(tunnelInfo.autoShutoffTimer);
126
+ }
127
+ this.activeTunnels.delete(tunnelId);
128
+ if (!tunnelInfo.isOwned) {
129
+ // Borrowed — just drop the local reference; owner manages the real tunnel
130
+ logger.info(`Released borrowed tunnel reference: ${tunnelInfo.publicUrl}`);
131
+ return;
132
+ }
133
+ // Owned — remove from shared registry, then disconnect + revoke
134
+ try {
135
+ const registry = this.reg.read();
136
+ delete registry[String(tunnelInfo.port)];
137
+ this.reg.write(registry);
138
+ }
139
+ catch {
140
+ // best-effort
141
+ }
142
+ try {
143
+ const ngrok = await getNgrok();
144
+ await ngrok.disconnect(tunnelInfo.tunnelUrl);
145
+ logger.info(`Cleaned up tunnel: ${tunnelInfo.publicUrl}`);
100
146
  }
101
- // Check if we already have an active tunnel for this port
102
- const existingTunnel = this.findTunnelByPort(port);
103
- if (existingTunnel) {
104
- const publicUrl = generateTunnelUrl(url, existingTunnel.tunnelId);
105
- logger.info(`Reusing existing tunnel for port ${port}: ${publicUrl}`);
106
- return { url: publicUrl, tunnelId: existingTunnel.tunnelId, isLocalhost: true };
147
+ catch (error) {
148
+ logger.warn(`ngrok.disconnect failed for tunnel ${tunnelId} (already cleaned up):`, error);
149
+ }
150
+ if (tunnelInfo.revokeKey) {
151
+ tunnelInfo.revokeKey().catch((err) => logger.warn(`Failed to revoke key for tunnel ${tunnelId}:`, err));
152
+ }
153
+ }
154
+ async stopAllTunnels() {
155
+ const ids = Array.from(this.activeTunnels.keys());
156
+ await Promise.all(ids.map((id) => this.stopTunnel(id).catch((err) => logger.error(`Failed to stop tunnel ${id}:`, err))));
157
+ logger.info(`Stopped ${ids.length} tunnel(s)`);
158
+ }
159
+ getTunnelStatus(tunnelId) {
160
+ const tunnel = this.activeTunnels.get(tunnelId);
161
+ if (!tunnel)
162
+ return null;
163
+ const now = Date.now();
164
+ return {
165
+ tunnel,
166
+ age: now - tunnel.createdAt,
167
+ timeSinceLastAccess: now - tunnel.lastAccessedAt,
168
+ timeUntilAutoShutoff: Math.max(0, tunnel.lastAccessedAt + this.TUNNEL_TIMEOUT_MS - now),
169
+ };
170
+ }
171
+ getAllTunnelStatuses() {
172
+ const statuses = [];
173
+ for (const tunnelId of this.activeTunnels.keys()) {
174
+ const status = this.getTunnelStatus(tunnelId);
175
+ if (status)
176
+ statuses.push(status);
177
+ }
178
+ return statuses;
179
+ }
180
+ // ── Per-port tunnel ─────────────────────────────────────────────────────────
181
+ async processPerPort(url, port, authToken, tunnelId, keyId, revokeKey) {
182
+ // 1. Check local in-process map (handles owned + borrowed with liveness check)
183
+ const existing = this.getTunnelForPort(port);
184
+ if (existing) {
185
+ logger.info(`Reusing existing tunnel for port ${port}: ${existing.publicUrl}`);
186
+ return { url: existing.publicUrl, tunnelId: existing.tunnelId, isLocalhost: true };
107
187
  }
108
- // If a tunnel creation is already in-flight for this port, wait for it
188
+ // 2. Deduplicate concurrent creation requests for the same port
109
189
  const pending = this.pendingTunnels.get(port);
110
190
  if (pending) {
111
- logger.info(`Waiting for in-flight tunnel creation for port ${port}`);
112
- const tunnelInfo = await pending;
113
- return { url: tunnelInfo.publicUrl, tunnelId: tunnelInfo.tunnelId, isLocalhost: true };
191
+ const info = await pending;
192
+ return { url: info.publicUrl, tunnelId: info.tunnelId, isLocalhost: true };
114
193
  }
115
- // Create new tunnel
116
- if (!authToken) {
117
- throw new Error('Auth token required to create tunnel for localhost URL');
194
+ // 3. Check cross-process registry — another MCP instance may own a tunnel
195
+ const registry = this.reg.read();
196
+ const regEntry = registry[String(port)];
197
+ if (regEntry && this.reg.isPidAlive(regEntry.ownerPid)) {
198
+ logger.info(`Borrowing tunnel from PID ${regEntry.ownerPid} for port ${port}: ${regEntry.publicUrl}`);
199
+ const now = Date.now();
200
+ const borrowed = {
201
+ tunnelId: regEntry.tunnelId,
202
+ originalUrl: url,
203
+ tunnelUrl: regEntry.tunnelUrl,
204
+ publicUrl: regEntry.publicUrl,
205
+ port,
206
+ createdAt: now,
207
+ lastAccessedAt: now,
208
+ isOwned: false,
209
+ };
210
+ this.activeTunnels.set(regEntry.tunnelId, borrowed);
211
+ // Touch registry so the owner knows not to auto-shutoff
212
+ regEntry.lastAccessedAt = now;
213
+ this.reg.write(registry);
214
+ this.resetTunnelTimer(borrowed);
215
+ return { url: regEntry.publicUrl, tunnelId: regEntry.tunnelId, isLocalhost: true };
118
216
  }
119
- const tunnelId = specificTunnelId || uuidv4();
120
- const creationPromise = this.createTunnel(url, port, tunnelId, authToken);
217
+ // 4. Create a new tunnel (this process becomes the owner)
218
+ const creationPromise = this.createTunnel(url, port, tunnelId, authToken, keyId, revokeKey);
121
219
  this.pendingTunnels.set(port, creationPromise);
122
220
  let tunnelInfo;
123
221
  try {
@@ -126,90 +224,40 @@ class TunnelManager {
126
224
  finally {
127
225
  this.pendingTunnels.delete(port);
128
226
  }
129
- return {
130
- url: tunnelInfo.publicUrl,
131
- tunnelId: tunnelInfo.tunnelId,
132
- isLocalhost: true
133
- };
227
+ return { url: tunnelInfo.publicUrl, tunnelId: tunnelInfo.tunnelId, isLocalhost: true };
134
228
  }
135
- /**
136
- * Check if a URL is a tunnel URL
137
- */
138
- isTunnelUrl(url) {
139
- return url.includes('.ngrok.debugg.ai');
140
- }
141
- /**
142
- * Extract tunnel ID from a tunnel URL
143
- */
144
- extractTunnelId(url) {
145
- const match = url.match(/https?:\/\/([^.]+)\.ngrok\.debugg\.ai/);
146
- return match ? match[1] : null;
147
- }
148
- /**
149
- * Get tunnel info by ID
150
- */
151
- getTunnelInfo(tunnelId) {
152
- return this.activeTunnels.get(tunnelId);
153
- }
154
- /**
155
- * Find tunnel by port
156
- */
157
229
  findTunnelByPort(port) {
158
230
  for (const tunnel of this.activeTunnels.values()) {
159
- if (tunnel.port === port) {
231
+ if (tunnel.port === port)
160
232
  return tunnel;
161
- }
162
233
  }
163
234
  return undefined;
164
235
  }
165
- /**
166
- * Create a new tunnel
167
- */
168
- async createTunnel(originalUrl, port, tunnelId, authToken) {
236
+ async createTunnel(originalUrl, port, tunnelId, authToken, keyId, revokeKey) {
169
237
  await this.ensureInitialized();
170
238
  const tunnelDomain = `${tunnelId}.ngrok.debugg.ai`;
171
- logger.info(`Creating tunnel for localhost:${port} with domain ${tunnelDomain}`);
239
+ logger.info(`Creating tunnel for localhost:${port} (domain: ${tunnelDomain})`);
240
+ const isHttpsLocal = originalUrl.startsWith('https:');
241
+ const inDocker = process.env.DOCKER_CONTAINER === 'true';
242
+ const dockerHost = 'host.docker.internal';
243
+ let localAddr;
244
+ if (isHttpsLocal) {
245
+ localAddr = inDocker ? `https://${dockerHost}:${port}` : `https://localhost:${port}`;
246
+ }
247
+ else {
248
+ localAddr = inDocker ? `${dockerHost}:${port}` : port;
249
+ }
172
250
  try {
173
- // Get ngrok module dynamically
174
251
  const ngrok = await getNgrok();
175
- // Set auth token first
176
- logger.debug(`Setting ngrok auth token`);
177
- await ngrok.authtoken({ authtoken: authToken });
178
- // Create tunnel options
179
- const tunnelOptions = {
252
+ const tunnelUrl = await ngrok.connect({
180
253
  proto: 'http',
181
- addr: process.env.DOCKER_CONTAINER === "true" ? `host.docker.internal:${port}` : port,
254
+ addr: localAddr,
182
255
  hostname: tunnelDomain,
183
- authtoken: authToken
184
- // Don't override configPath - let ngrok use its default configuration
185
- };
186
- logger.debug(`Connecting tunnel with options: ${JSON.stringify({ ...tunnelOptions, authtoken: '[REDACTED]' })}`);
187
- // For ngrok v5, we might need to handle the connection differently
188
- let tunnelUrl;
189
- try {
190
- tunnelUrl = await ngrok.connect(tunnelOptions);
191
- }
192
- catch (connectError) {
193
- // If connection fails due to ngrok not running, try with different options
194
- if (connectError instanceof Error && connectError.message.includes('ECONNREFUSED')) {
195
- logger.info('ngrok daemon not running, attempting to start tunnel with minimal options');
196
- const minimalOptions = {
197
- proto: 'http',
198
- addr: process.env.DOCKER_CONTAINER === "true" ? `host.docker.internal:${port}` : port,
199
- authtoken: authToken
200
- };
201
- tunnelUrl = await ngrok.connect(minimalOptions);
202
- }
203
- else {
204
- throw connectError;
205
- }
206
- }
207
- if (!tunnelUrl) {
208
- throw new Error('Failed to create tunnel');
209
- }
210
- // Generate the public URL maintaining path, search, and hash from original
256
+ authtoken: authToken,
257
+ });
258
+ if (!tunnelUrl)
259
+ throw new Error('ngrok.connect() returned empty URL');
211
260
  const publicUrl = generateTunnelUrl(originalUrl, tunnelId);
212
- // Store tunnel info
213
261
  const now = Date.now();
214
262
  const tunnelInfo = {
215
263
  tunnelId,
@@ -218,101 +266,78 @@ class TunnelManager {
218
266
  publicUrl,
219
267
  port,
220
268
  createdAt: now,
221
- lastAccessedAt: now
269
+ lastAccessedAt: now,
270
+ isOwned: true,
271
+ keyId,
272
+ revokeKey,
222
273
  };
223
274
  this.activeTunnels.set(tunnelId, tunnelInfo);
224
- // Start the auto-shutoff timer
275
+ // Register in shared cross-process registry
276
+ try {
277
+ const registry = this.reg.read();
278
+ registry[String(port)] = {
279
+ tunnelId,
280
+ publicUrl,
281
+ tunnelUrl,
282
+ port,
283
+ ownerPid: process.pid,
284
+ lastAccessedAt: now,
285
+ };
286
+ this.reg.write(registry);
287
+ }
288
+ catch {
289
+ // best-effort
290
+ }
225
291
  this.resetTunnelTimer(tunnelInfo);
226
- logger.info(`Tunnel created: ${publicUrl} -> localhost:${port}`);
292
+ logger.info(`Tunnel created: ${publicUrl} localhost:${port}`);
227
293
  return tunnelInfo;
228
294
  }
229
295
  catch (error) {
230
- logger.error(`Failed to create tunnel for ${originalUrl}:`, error);
231
- // Try to provide more helpful error messages
232
- if (error instanceof Error && error.message.includes('ECONNREFUSED')) {
233
- throw new Error(`Failed to create tunnel: ngrok daemon not running or connection refused. Original error: ${error.message}`);
234
- }
235
- else if (error instanceof Error && error.message.includes('authtoken')) {
236
- throw new Error(`Failed to create tunnel: Invalid or missing auth token. Original error: ${error.message}`);
237
- }
238
- else {
239
- throw new Error(`Failed to create tunnel: ${error instanceof Error ? error.message : 'Unknown error'}`);
296
+ const msg = error instanceof Error ? error.message : 'Unknown error';
297
+ if (msg.includes('authtoken')) {
298
+ throw new Error(`Failed to create tunnel: invalid auth token. ${msg}`);
240
299
  }
300
+ throw new Error(`Failed to create tunnel: ${msg}`);
241
301
  }
242
302
  }
243
- /**
244
- * Stop a tunnel by ID
245
- */
246
- async stopTunnel(tunnelId) {
247
- const tunnelInfo = this.activeTunnels.get(tunnelId);
248
- if (!tunnelInfo) {
249
- logger.warn(`Tunnel ${tunnelId} not found for cleanup`);
250
- return;
251
- }
252
- try {
253
- // Clear the auto-shutoff timer
254
- if (tunnelInfo.autoShutoffTimer) {
255
- clearTimeout(tunnelInfo.autoShutoffTimer);
303
+ // ── Helpers ─────────────────────────────────────────────────────────────────
304
+ async ensureInitialized() {
305
+ if (!this.initialized) {
306
+ try {
307
+ const ngrok = await getNgrok();
308
+ ngrok.getApi();
256
309
  }
257
- const ngrok = await getNgrok();
258
- await ngrok.disconnect(tunnelInfo.tunnelUrl);
259
- this.activeTunnels.delete(tunnelId);
260
- logger.info(`Cleaned up tunnel: ${tunnelInfo.publicUrl}`);
261
- }
262
- catch (error) {
263
- logger.error(`Failed to cleanup tunnel ${tunnelId}:`, error);
264
- throw error;
265
- }
266
- }
267
- /**
268
- * Stop all active tunnels
269
- */
270
- async stopAllTunnels() {
271
- const tunnelIds = Array.from(this.activeTunnels.keys());
272
- const cleanupPromises = tunnelIds.map(tunnelId => this.stopTunnel(tunnelId).catch(error => logger.error(`Failed to stop tunnel ${tunnelId}:`, error)));
273
- await Promise.all(cleanupPromises);
274
- logger.info(`Stopped ${tunnelIds.length} tunnels`);
275
- }
276
- /**
277
- * Get all active tunnels
278
- */
279
- getActiveTunnels() {
280
- return Array.from(this.activeTunnels.values());
281
- }
282
- /**
283
- * Get tunnel status with timing information
284
- */
285
- getTunnelStatus(tunnelId) {
286
- const tunnel = this.activeTunnels.get(tunnelId);
287
- if (!tunnel) {
288
- return null;
310
+ catch {
311
+ // ignore — let connect surface real errors
312
+ }
313
+ this.initialized = true;
289
314
  }
290
- const now = Date.now();
291
- const age = now - tunnel.createdAt;
292
- const timeSinceLastAccess = now - tunnel.lastAccessedAt;
293
- const timeUntilAutoShutoff = Math.max(0, (tunnel.lastAccessedAt + this.TUNNEL_TIMEOUT_MS) - now);
294
- return {
295
- tunnel,
296
- age,
297
- timeSinceLastAccess,
298
- timeUntilAutoShutoff
299
- };
300
315
  }
301
- /**
302
- * Get all tunnel statuses
303
- */
304
- getAllTunnelStatuses() {
305
- const statuses = [];
306
- for (const tunnelId of this.activeTunnels.keys()) {
307
- const status = this.getTunnelStatus(tunnelId);
308
- if (status) {
309
- statuses.push(status);
316
+ resetTunnelTimer(tunnelInfo) {
317
+ if (tunnelInfo.autoShutoffTimer)
318
+ clearTimeout(tunnelInfo.autoShutoffTimer);
319
+ tunnelInfo.lastAccessedAt = Date.now();
320
+ tunnelInfo.autoShutoffTimer = setTimeout(async () => {
321
+ // For owned tunnels: if another process recently touched the registry entry,
322
+ // reset the timer rather than disconnecting — that process is still using it.
323
+ if (tunnelInfo.isOwned) {
324
+ try {
325
+ const entry = this.reg.read()[String(tunnelInfo.port)];
326
+ if (entry && Date.now() - entry.lastAccessedAt < this.TUNNEL_TIMEOUT_MS) {
327
+ logger.info(`Tunnel ${tunnelInfo.tunnelId} accessed by another process — extending lifetime`);
328
+ this.resetTunnelTimer(tunnelInfo);
329
+ return;
330
+ }
331
+ }
332
+ catch {
333
+ // best-effort; proceed with shutoff
334
+ }
310
335
  }
311
- }
312
- return statuses;
336
+ logger.info(`Auto-shutting down tunnel ${tunnelInfo.tunnelId} after inactivity`);
337
+ await this.stopTunnel(tunnelInfo.tunnelId).catch((err) => logger.error(`Failed to auto-shutdown tunnel ${tunnelInfo.tunnelId}:`, err));
338
+ }, this.TUNNEL_TIMEOUT_MS);
313
339
  }
314
340
  }
315
- // Singleton instance
316
341
  const tunnelManager = new TunnelManager();
317
342
  export { tunnelManager };
318
343
  export default TunnelManager;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Cross-process tunnel registry.
3
+ *
4
+ * Lets multiple MCP server instances on the same machine discover and share
5
+ * ngrok tunnels instead of each provisioning a duplicate for the same port.
6
+ *
7
+ * The file registry uses an atomic rename-write so concurrent processes never
8
+ * see a partial JSON file. All operations are best-effort — errors are
9
+ * swallowed so a broken registry never blocks tunnel creation.
10
+ */
11
+ import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
12
+ import { tmpdir } from 'os';
13
+ import { join } from 'path';
14
+ // ── File-backed implementation (production) ───────────────────────────────────
15
+ const REGISTRY_FILE = join(tmpdir(), 'debugg-ai-tunnels.json');
16
+ export function createFileRegistry() {
17
+ return {
18
+ read() {
19
+ try {
20
+ if (!existsSync(REGISTRY_FILE))
21
+ return {};
22
+ return JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
23
+ }
24
+ catch {
25
+ return {};
26
+ }
27
+ },
28
+ write(data) {
29
+ const tmp = `${REGISTRY_FILE}.${process.pid}.tmp`;
30
+ try {
31
+ writeFileSync(tmp, JSON.stringify(data));
32
+ renameSync(tmp, REGISTRY_FILE);
33
+ }
34
+ catch {
35
+ // best-effort
36
+ }
37
+ },
38
+ isPidAlive(pid) {
39
+ return checkPid(pid);
40
+ },
41
+ };
42
+ }
43
+ // ── In-memory implementation (tests / injectable) ─────────────────────────────
44
+ export function createInMemoryRegistry(isPidAliveImpl) {
45
+ let store = {};
46
+ return {
47
+ read: () => ({ ...store }),
48
+ write: (data) => { store = { ...data }; },
49
+ isPidAlive: isPidAliveImpl ?? checkPid,
50
+ };
51
+ }
52
+ // ── No-op implementation (tests that don't exercise registry) ─────────────────
53
+ export const noopRegistry = {
54
+ read: () => ({}),
55
+ write: () => { },
56
+ isPidAlive: () => false,
57
+ };
58
+ // ── Default selection ─────────────────────────────────────────────────────────
59
+ /**
60
+ * Returns the appropriate registry for the current environment.
61
+ * Tests (NODE_ENV=test) get the no-op registry; production gets file-backed.
62
+ */
63
+ export function getDefaultRegistry() {
64
+ return process.env.NODE_ENV === 'test' ? noopRegistry : createFileRegistry();
65
+ }
66
+ // ── Helpers ───────────────────────────────────────────────────────────────────
67
+ function checkPid(pid) {
68
+ try {
69
+ process.kill(pid, 0); // signal 0 = existence check, no signal sent
70
+ return true;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Tunnels Service
3
+ * Provisions short-lived ngrok keys for MCP-managed tunnel setup.
4
+ * Called before executeWorkflow so the tunnel URL is known before execution starts.
5
+ */
6
+ export const createTunnelsService = (tx) => ({
7
+ async provision(purpose = 'workflow') {
8
+ const response = await tx.post('api/v1/tunnels/', { purpose });
9
+ if (!response?.tunnelId || !response?.tunnelKey) {
10
+ throw new Error('Tunnel provisioning failed: missing tunnelId or tunnelKey in response');
11
+ }
12
+ return {
13
+ tunnelId: response.tunnelId,
14
+ tunnelKey: response.tunnelKey,
15
+ keyId: response.keyId,
16
+ expiresAt: response.expiresAt,
17
+ };
18
+ },
19
+ });
@@ -31,9 +31,6 @@ export const createWorkflowsService = (tx) => {
31
31
  }
32
32
  return {
33
33
  executionUuid: response.resourceUuid,
34
- tunnelKey: response.tunnelKey ?? null,
35
- ngrokKeyId: response.ngrokKeyId ?? null,
36
- ngrokExpiresAt: response.ngrokExpiresAt ?? null,
37
34
  resolvedEnvironmentId: response.resolvedEnvironmentId ?? null,
38
35
  resolvedCredentialId: response.resolvedCredentialId ?? null,
39
36
  };
@@ -21,13 +21,7 @@ export const testPageChangesTool = {
21
21
  },
22
22
  url: {
23
23
  type: "string",
24
- description: "Target URL for the browser agent to navigate to (e.g., 'https://example.com' or 'http://localhost:3000'). Use this for external URLs. For local dev servers, use localPort instead."
25
- },
26
- localPort: {
27
- type: "number",
28
- description: "Port of your local dev server (e.g. 3000, 8080). A secure tunnel is created automatically so the remote browser can reach it.",
29
- minimum: 1,
30
- maximum: 65535
24
+ description: "URL to navigate to. Accepts any URL including localhost (e.g. 'http://localhost:3000', 'https://example.com'). Localhost URLs are automatically tunneled so the remote browser can reach them."
31
25
  },
32
26
  environmentId: {
33
27
  type: "string",
@@ -50,7 +44,7 @@ export const testPageChangesTool = {
50
44
  description: "Password to log in with (used together with username)"
51
45
  },
52
46
  },
53
- required: ["description"],
47
+ required: ["description", "url"],
54
48
  additionalProperties: false
55
49
  },
56
50
  };
@@ -2,20 +2,20 @@
2
2
  * Comprehensive type definitions for DebuggAI MCP Server
3
3
  */
4
4
  import { z } from 'zod';
5
+ import { normalizeUrl } from '../utils/urlParser.js';
5
6
  /**
6
7
  * Tool input validation schemas
7
8
  */
8
9
  export const TestPageChangesInputSchema = z.object({
9
10
  description: z.string().min(1, 'Description is required'),
10
- url: z.string().url('Must be a valid URL').optional(),
11
- localPort: z.number().int().min(1).max(65535).optional(),
11
+ url: z.preprocess(normalizeUrl, z.string().url('Must be a valid URL — accepts localhost (e.g. "http://localhost:3000") or any public URL')),
12
12
  // Credential/environment resolution
13
13
  environmentId: z.string().uuid().optional(),
14
14
  credentialId: z.string().uuid().optional(),
15
15
  credentialRole: z.string().optional(),
16
16
  username: z.string().optional(),
17
17
  password: z.string().optional(),
18
- }).refine((data) => data.url !== undefined || data.localPort !== undefined, { message: 'Provide a target via "url" (e.g. "https://example.com") or "localPort" for a local dev server' });
18
+ });
19
19
  /**
20
20
  * Error types
21
21
  */
@@ -2,24 +2,18 @@
2
2
  * Shared tunnel and URL resolution context used by all MCP tools.
3
3
  *
4
4
  * Centralizes:
5
- * - resolving user input (url / localPort) to a concrete URL
5
+ * - resolving user input url to a concrete URL
6
6
  * - creating / reusing ngrok tunnels after the backend returns a tunnelKey
7
7
  * - sanitizing backend responses so callers only ever see the original URL
8
8
  */
9
9
  import { tunnelManager } from '../services/ngrok/tunnelManager.js';
10
- import { isLocalhostUrl, replaceTunnelUrls } from './urlParser.js';
10
+ import { isLocalhostUrl, replaceTunnelUrls, extractLocalhostPort } from './urlParser.js';
11
11
  // ─── URL resolution ──────────────────────────────────────────────────────────
12
12
  /**
13
13
  * Resolve tool input to a concrete URL string.
14
- * Accepts either a `url` string or a `localPort` number; throws if neither provided.
15
14
  */
16
15
  export function resolveTargetUrl(input) {
17
- if (input.url)
18
- return input.url;
19
- if (input.localPort)
20
- return `http://localhost:${input.localPort}`;
21
- throw new Error('Provide a target URL via "url" (e.g. "https://example.com") ' +
22
- 'or "localPort" for a local dev server.');
16
+ return input.url;
23
17
  }
24
18
  /**
25
19
  * Build a TunnelContext for a resolved URL.
@@ -33,22 +27,41 @@ export function buildContext(originalUrl) {
33
27
  }
34
28
  // ─── Tunnel creation ─────────────────────────────────────────────────────────
35
29
  /**
36
- * Create (or reuse) a tunnel for a localhost URL.
30
+ * Check whether an active tunnel already exists for the same local port.
31
+ * If found, touches its timer and returns an enriched context pointing at it.
32
+ * Returns null for public URLs or when no tunnel is active for that port.
37
33
  *
38
- * Call this AFTER the backend returns a `tunnelKey` and `tunnelId`
39
- * (e.g. executionUuid from executeWorkflow, sessionId from startSession).
34
+ * Call this BEFORE provisioning a new key — if it returns a context, skip the provision.
35
+ */
36
+ export function findExistingTunnel(ctx) {
37
+ if (!ctx.isLocalhost)
38
+ return null;
39
+ const port = extractLocalhostPort(ctx.originalUrl);
40
+ if (!port)
41
+ return null;
42
+ const existing = tunnelManager.getTunnelForPort(port);
43
+ if (!existing)
44
+ return null;
45
+ tunnelManager.touchTunnel(existing.tunnelId);
46
+ return { ...ctx, tunnelId: existing.tunnelId, targetUrl: existing.publicUrl };
47
+ }
48
+ /**
49
+ * Create (or reuse) a tunnel for a localhost URL.
40
50
  *
41
- * No-op and returns null for public URLs.
51
+ * Call this AFTER the backend returns a `tunnelKey` and `tunnelId`.
52
+ * No-op for public URLs.
42
53
  *
43
54
  * @param ctx - Context built from `buildContext()`
44
55
  * @param tunnelKey - Auth token from the backend (short-lived ngrok key)
45
- * @param tunnelId - ID to use as the ngrok subdomain (must match what the backend expects)
56
+ * @param tunnelId - ID to use as the ngrok subdomain
57
+ * @param keyId - Backend key ID; stored on the tunnel so it is revoked on stop
58
+ * @param revokeKey - Callback that revokes the backend key (called when tunnel stops)
46
59
  */
47
- export async function ensureTunnel(ctx, tunnelKey, tunnelId) {
60
+ export async function ensureTunnel(ctx, tunnelKey, tunnelId, keyId, revokeKey) {
48
61
  if (!ctx.isLocalhost)
49
62
  return ctx;
50
- const result = await tunnelManager.processUrl(ctx.originalUrl, tunnelKey, tunnelId);
51
- return { ...ctx, tunnelId: result.tunnelId };
63
+ const result = await tunnelManager.processUrl(ctx.originalUrl, tunnelKey, tunnelId, keyId, revokeKey);
64
+ return { ...ctx, tunnelId: result.tunnelId, targetUrl: result.url };
52
65
  }
53
66
  /**
54
67
  * Stop the tunnel associated with a context (fire-and-forget safe).
@@ -3,18 +3,37 @@
3
3
  * Helper functions for parsing and validating URLs, specifically for detecting localhost URLs
4
4
  */
5
5
  /**
6
- * Check if a hostname represents localhost
6
+ * Normalize a user-supplied URL string before validation.
7
+ * Handles bare hostnames without a scheme (e.g. "localhost:3000" → "http://localhost:3000").
8
+ */
9
+ export function normalizeUrl(input) {
10
+ if (typeof input !== 'string')
11
+ return input;
12
+ if (/^https?:\/\//i.test(input))
13
+ return input;
14
+ // Bare local hostname (no scheme) — prepend http://
15
+ if (/^(localhost\.?|127\.0\.0\.1|0\.0\.0\.0|host\.docker\.internal|\[::1\])(:\d+)?(\/.*)?$/i.test(input)) {
16
+ return `http://${input}`;
17
+ }
18
+ return input;
19
+ }
20
+ /**
21
+ * Check if a hostname represents a local/tunnelable address.
7
22
  */
8
23
  function isLocalhostHostname(hostname) {
9
- const lowercaseHostname = hostname.toLowerCase();
10
- return (lowercaseHostname === 'localhost' ||
11
- lowercaseHostname === '127.0.0.1' ||
12
- lowercaseHostname === '::1' ||
13
- lowercaseHostname.startsWith('192.168.') ||
14
- lowercaseHostname.startsWith('10.') ||
15
- (lowercaseHostname.startsWith('172.') &&
16
- parseInt(lowercaseHostname.split('.')[1], 10) >= 16 &&
17
- parseInt(lowercaseHostname.split('.')[1], 10) <= 31));
24
+ // Strip IPv6 brackets: new URL('http://[::1]:3000').hostname === '[::1]' in WHATWG spec
25
+ const h = hostname.replace(/^\[|\]$/g, '').toLowerCase();
26
+ return (h === 'localhost' ||
27
+ h === 'localhost.' || // trailing-dot FQDN notation
28
+ h === '127.0.0.1' ||
29
+ h === '::1' ||
30
+ h === '0.0.0.0' ||
31
+ h === 'host.docker.internal' ||
32
+ h.startsWith('192.168.') ||
33
+ h.startsWith('10.') ||
34
+ (h.startsWith('172.') &&
35
+ parseInt(h.split('.')[1], 10) >= 16 &&
36
+ parseInt(h.split('.')[1], 10) <= 31));
18
37
  }
19
38
  /**
20
39
  * Parse a URL and determine if it's a localhost URL
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "1.0.31",
3
+ "version": "1.0.32",
4
4
  "description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,9 @@
24
24
  "version:minor": "npm version minor --no-git-tag-version",
25
25
  "version:major": "npm version major --no-git-tag-version",
26
26
  "publish:check": "npm pack --dry-run",
27
- "prepublishOnly": "npm test && npm run build"
27
+ "prepublishOnly": "npm test && npm run build",
28
+ "mcp:local": "npm run build && claude mcp remove debugg-ai 2>/dev/null; claude mcp add debugg-ai -s user -e DEBUGGAI_API_KEY=KrvXlzVFXVZO82UErXye5N7CtnmBTu1GKULrJnwXRRU -- node /Users/qosha/Repos/debugg-ai/debugg-ai-mcp/dist/index.js",
29
+ "mcp:npm": "claude mcp remove debugg-ai 2>/dev/null; claude mcp add debugg-ai -s user -e DEBUGGAI_API_KEY=KrvXlzVFXVZO82UErXye5N7CtnmBTu1GKULrJnwXRRU -- npx -y @debugg-ai/debugg-ai-mcp"
28
30
  },
29
31
  "keywords": [
30
32
  "debugg",