@debugg-ai/debugg-ai-mcp 2.1.2 → 2.1.3
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/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Fixed — MCP now validates local reachability BEFORE hitting the backend (fixes 5-min false-pass regression)
|
|
11
|
+
|
|
12
|
+
- `check_app_in_browser` and `trigger_crawl` now do a pre-flight TCP probe to `127.0.0.1:<port>` before provisioning a backend tunnel key. If the dev server isn't listening, we return a structured `LocalServerUnreachable` error in ~ms instead of letting the browser agent burn its 5-minute step budget on `ERR_NGROK_8012`. Bead `1om`.
|
|
13
|
+
- After the tunnel is established, we do a second `GET /` probe through the tunnel itself and parse the body for `ERR_NGROK_*` markers. If ngrok received traffic but couldn't dial our backend (e.g., the dev server binds to 0.0.0.0/::1 but not 127.0.0.1), we tear down the tunnel, revoke the key, and return `TunnelTrafficBlocked` — again, fast, with a message that points at the actual cause.
|
|
14
|
+
- End-to-end proof: new eval flow `28-localhost-not-listening.mjs` against a guaranteed-free port, asserts response arrives in <10s with `error:'LocalServerUnreachable'`. Measured **9ms** in practice vs. the prior **5-minute false-pass**.
|
|
15
|
+
|
|
10
16
|
### Fixed — ngrok now dials IPv4 loopback explicitly (fixes ERR_NGROK_8012 on macOS Next.js)
|
|
11
17
|
|
|
12
18
|
- `ngrok.connect({addr})` now passes `127.0.0.1:<port>` instead of the bare port number for plain-http localhost URLs. Bare port / `localhost` could resolve to IPv6 `[::1]` first on modern macOS, but Next.js / Vite / most Node dev servers bind to `127.0.0.1` only. Result was a successful tunnel that dialed `[::1]:<port>` and got `connection refused`, surfacing to users as `ERR_NGROK_8012` inside the browser agent trace. Bead `fhg`. Evidenced by real incident log 2026-04-24T19:37Z.
|
|
@@ -11,6 +11,9 @@ import { DebuggAIServerClient } from '../services/index.js';
|
|
|
11
11
|
import { TunnelProvisionError } from '../services/tunnels.js';
|
|
12
12
|
import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
|
|
13
13
|
import { detectRepoName } from '../utils/gitContext.js';
|
|
14
|
+
import { tunnelManager } from '../services/ngrok/tunnelManager.js';
|
|
15
|
+
import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
|
|
16
|
+
import { extractLocalhostPort } from '../utils/urlParser.js';
|
|
14
17
|
const logger = new Logger({ module: 'testPageChangesHandler' });
|
|
15
18
|
// Cache the template UUID and project UUIDs within a server session to avoid re-fetching
|
|
16
19
|
let cachedTemplateUuid = null;
|
|
@@ -86,6 +89,28 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
86
89
|
try {
|
|
87
90
|
// --- Tunnel: reuse existing or provision a fresh one ---
|
|
88
91
|
if (ctx.isLocalhost) {
|
|
92
|
+
// Bead 1om: pre-flight local port probe BEFORE committing to backend
|
|
93
|
+
// provision + ngrok session. If the user's dev server isn't listening,
|
|
94
|
+
// fail in ~1.5s with a structured error instead of burning 5 minutes
|
|
95
|
+
// on a browser agent trying to reach a dead tunnel.
|
|
96
|
+
const localPort = extractLocalhostPort(ctx.originalUrl);
|
|
97
|
+
if (typeof localPort === 'number') {
|
|
98
|
+
const probe = await probeLocalPort(localPort);
|
|
99
|
+
if (!probe.reachable) {
|
|
100
|
+
const payload = {
|
|
101
|
+
error: 'LocalServerUnreachable',
|
|
102
|
+
message: `No server listening on 127.0.0.1:${localPort}. Start your dev server on that port before running check_app_in_browser. Probe result: ${probe.code} (${probe.detail ?? 'no detail'}).`,
|
|
103
|
+
detail: {
|
|
104
|
+
port: localPort,
|
|
105
|
+
probeCode: probe.code,
|
|
106
|
+
probeDetail: probe.detail,
|
|
107
|
+
elapsedMs: probe.elapsedMs,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
logger.warn(`Pre-flight port probe failed for ${ctx.originalUrl}: ${probe.code} in ${probe.elapsedMs}ms`);
|
|
111
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
89
114
|
if (progressCallback) {
|
|
90
115
|
await progressCallback({ progress: 1, total: TOTAL_STEPS, message: 'Provisioning secure tunnel for localhost...' });
|
|
91
116
|
}
|
|
@@ -120,6 +145,37 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
120
145
|
}
|
|
121
146
|
logger.info(`Tunnel ready: ${ctx.targetUrl} (id: ${ctx.tunnelId})`);
|
|
122
147
|
}
|
|
148
|
+
// Bead 1om: verify traffic actually flows through the tunnel. The
|
|
149
|
+
// tunnel can be established (ngrok.connect returns OK) yet refuse
|
|
150
|
+
// to forward traffic — e.g., IPv4/IPv6 bind mismatch, or the dev
|
|
151
|
+
// server died between the pre-flight probe and here. Catch it now,
|
|
152
|
+
// in ~1s, not via a 5-minute browser-agent false-pass.
|
|
153
|
+
if (ctx.targetUrl) {
|
|
154
|
+
const health = await probeTunnelHealth(ctx.targetUrl);
|
|
155
|
+
if (!health.healthy) {
|
|
156
|
+
const payload = {
|
|
157
|
+
error: 'TunnelTrafficBlocked',
|
|
158
|
+
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.`,
|
|
159
|
+
detail: {
|
|
160
|
+
code: health.code,
|
|
161
|
+
status: health.status,
|
|
162
|
+
ngrokErrorCode: health.ngrokErrorCode,
|
|
163
|
+
elapsedMs: health.elapsedMs,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
|
|
167
|
+
// Tear down the broken tunnel so a subsequent call doesn't reuse it.
|
|
168
|
+
// stopTunnel handles both owned (ngrok disconnect + key revoke) and
|
|
169
|
+
// borrowed (just drops local ref) cases.
|
|
170
|
+
if (ctx.tunnelId) {
|
|
171
|
+
tunnelManager.stopTunnel(ctx.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${ctx.tunnelId}: ${err}`));
|
|
172
|
+
}
|
|
173
|
+
// keyId is consumed by stopTunnel's revoke path; clear so the
|
|
174
|
+
// outer finally block doesn't double-revoke.
|
|
175
|
+
keyId = undefined;
|
|
176
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
123
179
|
}
|
|
124
180
|
// --- Find workflow template ---
|
|
125
181
|
if (progressCallback) {
|
|
@@ -15,6 +15,9 @@ import { Logger } from '../utils/logger.js';
|
|
|
15
15
|
import { handleExternalServiceError } from '../utils/errors.js';
|
|
16
16
|
import { DebuggAIServerClient } from '../services/index.js';
|
|
17
17
|
import { TunnelProvisionError } from '../services/tunnels.js';
|
|
18
|
+
import { tunnelManager } from '../services/ngrok/tunnelManager.js';
|
|
19
|
+
import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
|
|
20
|
+
import { extractLocalhostPort } from '../utils/urlParser.js';
|
|
18
21
|
import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
|
|
19
22
|
const logger = new Logger({ module: 'triggerCrawlHandler' });
|
|
20
23
|
const TEMPLATE_KEYWORD = 'raw crawl';
|
|
@@ -52,6 +55,20 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
52
55
|
try {
|
|
53
56
|
// --- Tunnel: reuse existing or provision a fresh one ---
|
|
54
57
|
if (ctx.isLocalhost) {
|
|
58
|
+
// Bead 1om: pre-flight local port probe BEFORE provision/ngrok/backend.
|
|
59
|
+
const localPort = extractLocalhostPort(ctx.originalUrl);
|
|
60
|
+
if (typeof localPort === 'number') {
|
|
61
|
+
const probe = await probeLocalPort(localPort);
|
|
62
|
+
if (!probe.reachable) {
|
|
63
|
+
const payload = {
|
|
64
|
+
error: 'LocalServerUnreachable',
|
|
65
|
+
message: `No server listening on 127.0.0.1:${localPort}. Start your dev server on that port before running trigger_crawl. Probe result: ${probe.code} (${probe.detail ?? 'no detail'}).`,
|
|
66
|
+
detail: { port: localPort, probeCode: probe.code, probeDetail: probe.detail, elapsedMs: probe.elapsedMs },
|
|
67
|
+
};
|
|
68
|
+
logger.warn(`Pre-flight port probe failed for ${ctx.originalUrl}: ${probe.code} in ${probe.elapsedMs}ms`);
|
|
69
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
55
72
|
if (progressCallback) {
|
|
56
73
|
await progressCallback({ progress: 1, total: 4, message: 'Provisioning secure tunnel for localhost...' });
|
|
57
74
|
}
|
|
@@ -74,6 +91,28 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
74
91
|
keyId = tunnel.keyId;
|
|
75
92
|
ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
|
|
76
93
|
}
|
|
94
|
+
// Bead 1om: post-tunnel health check — verify traffic actually flows.
|
|
95
|
+
if (ctx.targetUrl) {
|
|
96
|
+
const health = await probeTunnelHealth(ctx.targetUrl);
|
|
97
|
+
if (!health.healthy) {
|
|
98
|
+
const payload = {
|
|
99
|
+
error: 'TunnelTrafficBlocked',
|
|
100
|
+
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.`,
|
|
101
|
+
detail: {
|
|
102
|
+
code: health.code,
|
|
103
|
+
status: health.status,
|
|
104
|
+
ngrokErrorCode: health.ngrokErrorCode,
|
|
105
|
+
elapsedMs: health.elapsedMs,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
|
|
109
|
+
if (ctx.tunnelId) {
|
|
110
|
+
tunnelManager.stopTunnel(ctx.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${ctx.tunnelId}: ${err}`));
|
|
111
|
+
}
|
|
112
|
+
keyId = undefined;
|
|
113
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
77
116
|
}
|
|
78
117
|
// --- Find the crawl workflow template ---
|
|
79
118
|
if (progressCallback) {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local reachability probes (bead 1om).
|
|
3
|
+
*
|
|
4
|
+
* MCP owns the tunnel lifecycle. It must validate that the user's claimed
|
|
5
|
+
* localhost URL is actually reachable BEFORE calling the backend provision
|
|
6
|
+
* API and BEFORE committing to the slow ngrok/browser-agent path. Without
|
|
7
|
+
* these probes, unreachable apps result in a 5-minute false-positive pass
|
|
8
|
+
* as the browser agent burns its step budget on ERR_NGROK_8012.
|
|
9
|
+
*
|
|
10
|
+
* Two probes:
|
|
11
|
+
* - probeLocalPort(port): pre-flight TCP connect to 127.0.0.1:<port>
|
|
12
|
+
* - probeTunnelHealth(url): HTTP check that traffic actually flows through
|
|
13
|
+
* the tunnel to our local server (catches IPv4/IPv6 bind mismatches,
|
|
14
|
+
* misconfigured ngrok, etc.)
|
|
15
|
+
*/
|
|
16
|
+
import { createConnection } from 'node:net';
|
|
17
|
+
export async function probeLocalPort(port, opts = {}) {
|
|
18
|
+
const host = opts.host ?? '127.0.0.1';
|
|
19
|
+
const timeoutMs = opts.timeoutMs ?? 1500;
|
|
20
|
+
const started = Date.now();
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const socket = createConnection({ host, port, timeout: timeoutMs });
|
|
23
|
+
let settled = false;
|
|
24
|
+
const done = (result) => {
|
|
25
|
+
if (settled)
|
|
26
|
+
return;
|
|
27
|
+
settled = true;
|
|
28
|
+
try {
|
|
29
|
+
socket.destroy();
|
|
30
|
+
}
|
|
31
|
+
catch { /* ignore */ }
|
|
32
|
+
resolve(result);
|
|
33
|
+
};
|
|
34
|
+
socket.once('connect', () => {
|
|
35
|
+
done({ reachable: true, elapsedMs: Date.now() - started });
|
|
36
|
+
});
|
|
37
|
+
socket.once('timeout', () => {
|
|
38
|
+
done({
|
|
39
|
+
reachable: false,
|
|
40
|
+
code: 'ETIMEDOUT',
|
|
41
|
+
detail: `connect timeout after ${timeoutMs}ms`,
|
|
42
|
+
elapsedMs: Date.now() - started,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
socket.once('error', (err) => {
|
|
46
|
+
done({
|
|
47
|
+
reachable: false,
|
|
48
|
+
code: err.code ?? 'UNKNOWN',
|
|
49
|
+
detail: err.message,
|
|
50
|
+
elapsedMs: Date.now() - started,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export async function probeTunnelHealth(tunnelUrl, opts = {}) {
|
|
56
|
+
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
57
|
+
const fetchImpl = opts.fetchFn ?? fetch;
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetchImpl(tunnelUrl, {
|
|
63
|
+
method: 'GET',
|
|
64
|
+
redirect: 'manual',
|
|
65
|
+
signal: controller.signal,
|
|
66
|
+
// Many user apps reject HEAD; stick to GET for broader compatibility.
|
|
67
|
+
headers: { 'User-Agent': 'debugg-ai-mcp/tunnel-health-probe' },
|
|
68
|
+
});
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
// Read body so we can inspect for ngrok error markers. Cap at 4KB —
|
|
71
|
+
// ngrok error pages are small; a full user app body is a waste.
|
|
72
|
+
const bodyText = await readCapped(res, 4096);
|
|
73
|
+
const ngrokErr = extractNgrokErrorCode(bodyText);
|
|
74
|
+
// 502/504 + ngrok error marker → ngrok couldn't reach our server
|
|
75
|
+
if (ngrokErr) {
|
|
76
|
+
return {
|
|
77
|
+
healthy: false,
|
|
78
|
+
status: res.status,
|
|
79
|
+
code: 'NGROK_ERROR',
|
|
80
|
+
ngrokErrorCode: ngrokErr,
|
|
81
|
+
detail: `ngrok returned ${ngrokErr} — tunnel established but traffic could not reach dev server`,
|
|
82
|
+
elapsedMs: Date.now() - started,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (res.status === 502 || res.status === 504) {
|
|
86
|
+
return {
|
|
87
|
+
healthy: false,
|
|
88
|
+
status: res.status,
|
|
89
|
+
code: 'BAD_GATEWAY',
|
|
90
|
+
detail: `tunnel returned ${res.status} without an ngrok error marker — gateway is rejecting upstream`,
|
|
91
|
+
elapsedMs: Date.now() - started,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// Any other response (incl. 4xx from user's app) means traffic reached
|
|
95
|
+
// the dev server — that's healthy from the TUNNEL's perspective. The
|
|
96
|
+
// user's 404 is a user concern, not a tunnel concern.
|
|
97
|
+
return {
|
|
98
|
+
healthy: true,
|
|
99
|
+
status: res.status,
|
|
100
|
+
elapsedMs: Date.now() - started,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
const e = err;
|
|
106
|
+
if (e?.name === 'AbortError' || /abort|timeout/i.test(e?.message ?? '')) {
|
|
107
|
+
return {
|
|
108
|
+
healthy: false,
|
|
109
|
+
code: 'TIMEOUT',
|
|
110
|
+
detail: `tunnel health probe timed out after ${timeoutMs}ms`,
|
|
111
|
+
elapsedMs: Date.now() - started,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
healthy: false,
|
|
116
|
+
code: 'NETWORK_ERROR',
|
|
117
|
+
detail: e?.message ?? String(err),
|
|
118
|
+
elapsedMs: Date.now() - started,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// ─ helpers ───────────────────────────────────────────────────────────────────
|
|
123
|
+
async function readCapped(res, maxBytes) {
|
|
124
|
+
if (!res.body)
|
|
125
|
+
return '';
|
|
126
|
+
const reader = res.body.getReader();
|
|
127
|
+
const decoder = new TextDecoder();
|
|
128
|
+
let total = 0;
|
|
129
|
+
let out = '';
|
|
130
|
+
try {
|
|
131
|
+
while (total < maxBytes) {
|
|
132
|
+
const { value, done } = await reader.read();
|
|
133
|
+
if (done)
|
|
134
|
+
break;
|
|
135
|
+
const remaining = maxBytes - total;
|
|
136
|
+
const chunk = value.length > remaining ? value.slice(0, remaining) : value;
|
|
137
|
+
out += decoder.decode(chunk, { stream: true });
|
|
138
|
+
total += chunk.length;
|
|
139
|
+
if (value.length > remaining) {
|
|
140
|
+
// Got enough; cancel the rest.
|
|
141
|
+
await reader.cancel().catch(() => { });
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
out += decoder.decode();
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
/* ignore read errors — we return what we have */
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
export function extractNgrokErrorCode(body) {
|
|
153
|
+
// ngrok error pages surface codes like "ERR_NGROK_8012", "ERR_NGROK_3200", etc.
|
|
154
|
+
const match = body.match(/ERR_NGROK_\d+/);
|
|
155
|
+
return match ? match[0] : undefined;
|
|
156
|
+
}
|