@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.
- package/dist/handlers/testPageChangesHandler.js +70 -25
- package/dist/services/index.js +3 -0
- package/dist/services/ngrok/tunnelManager.js +250 -225
- package/dist/services/ngrok/tunnelRegistry.js +75 -0
- package/dist/services/tunnels.js +19 -0
- package/dist/services/workflows.js +0 -3
- package/dist/tools/testPageChanges.js +2 -8
- package/dist/types/index.js +3 -3
- package/dist/utils/tunnelContext.js +30 -17
- package/dist/utils/urlParser.js +29 -10
- package/package.json +4 -2
|
@@ -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,
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
}
|
package/dist/services/index.js
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
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 =
|
|
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 =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
}
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
logger.
|
|
106
|
-
|
|
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
|
-
//
|
|
188
|
+
// 2. Deduplicate concurrent creation requests for the same port
|
|
109
189
|
const pending = this.pendingTunnels.get(port);
|
|
110
190
|
if (pending) {
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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:
|
|
254
|
+
addr: localAddr,
|
|
182
255
|
hostname: tunnelDomain,
|
|
183
|
-
authtoken: authToken
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
//
|
|
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}
|
|
292
|
+
logger.info(`Tunnel created: ${publicUrl} → localhost:${port}`);
|
|
227
293
|
return tunnelInfo;
|
|
228
294
|
}
|
|
229
295
|
catch (error) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (
|
|
309
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
};
|
package/dist/types/index.js
CHANGED
|
@@ -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')
|
|
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
|
-
})
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
39
|
-
|
|
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
|
-
*
|
|
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
|
|
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).
|
package/dist/utils/urlParser.js
CHANGED
|
@@ -3,18 +3,37 @@
|
|
|
3
3
|
* Helper functions for parsing and validating URLs, specifically for detecting localhost URLs
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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",
|