@debugg-ai/debugg-ai-mcp 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The hostnames a tunnel can live on, and which transport serves each one.
3
+ *
4
+ * During the migration a single MCP process can hold an ngrok tunnel and a
5
+ * debugg tunnel at the same time, so every place that recognises a tunnel
6
+ * hostname has to know both. The one that matters to users is
7
+ * `replaceTunnelUrls`: it is what keeps a tunnel hostname out of every tool
8
+ * response, so a domain it does not know is a URL that leaks to the caller.
9
+ *
10
+ * A provision response may name a domain we do not ship (the backend moving
11
+ * `tunnel.debugg.ai` elsewhere, or a staging host), so domains can be
12
+ * registered at runtime — that is what lets the server move without an MCP
13
+ * release, the same reasoning as `relayUrl`.
14
+ *
15
+ * Imports NOTHING, deliberately: utils/urlParser.ts, utils/localReachability.ts
16
+ * and services/ngrok/tunnelManager.ts all depend on it, and it must never be
17
+ * the module that creates a cycle.
18
+ */
19
+ const BUILT_IN_DOMAINS = Object.freeze([
20
+ { domain: 'ngrok.debugg.ai', transport: 'ngrok' },
21
+ { domain: 'tunnel.debugg.ai', transport: 'debugg' },
22
+ ]);
23
+ const domains = new Map(BUILT_IN_DOMAINS.map((d) => [d.domain, d.transport]));
24
+ /**
25
+ * A tunnel domain must be a lowercase DNS name of at least THREE labels.
26
+ *
27
+ * The label count is a safety rule, not a style one: a registered domain
28
+ * becomes a rewrite target, so accepting `debugg.ai` would make
29
+ * `replaceTunnelUrls` rewrite every real product link in a backend response
30
+ * (`https://app.debugg.ai/runs/1`) into the caller's localhost origin.
31
+ */
32
+ export function isValidTunnelDomain(domain) {
33
+ if (typeof domain !== 'string')
34
+ return false;
35
+ if (domain.length > 253)
36
+ return false;
37
+ const labels = domain.split('.');
38
+ if (labels.length < 3)
39
+ return false;
40
+ return labels.every((label) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
41
+ }
42
+ /**
43
+ * Teach this process about a tunnel domain. Idempotent, and safe to call on
44
+ * every provision. Returns false for a domain that fails validation, so a
45
+ * caller can turn that into an error rather than silently trusting it.
46
+ */
47
+ export function registerTunnelDomain(domain, transport) {
48
+ if (!isValidTunnelDomain(domain))
49
+ return false;
50
+ domains.set(domain, transport);
51
+ return true;
52
+ }
53
+ /** Every known domain, built-in and registered. */
54
+ export function knownTunnelDomains() {
55
+ return [...domains.entries()].map(([domain, transport]) => ({ domain, transport }));
56
+ }
57
+ /** The known domain a host sits under, or undefined. Accepts a host or a full URL. */
58
+ export function tunnelDomainFor(hostOrUrl) {
59
+ const host = hostOf(hostOrUrl);
60
+ if (!host)
61
+ return undefined;
62
+ for (const [domain, transport] of domains) {
63
+ // A tunnel host is always `<label>.<domain>` — never the bare domain.
64
+ if (host.length > domain.length + 1 && host.endsWith(`.${domain}`)) {
65
+ return { domain, transport };
66
+ }
67
+ }
68
+ return undefined;
69
+ }
70
+ export function isTunnelHost(hostOrUrl) {
71
+ return tunnelDomainFor(hostOrUrl) !== undefined;
72
+ }
73
+ /** The tunnel id (the leftmost label) of a tunnel host, or null. */
74
+ export function extractTunnelIdFromHost(hostOrUrl) {
75
+ const host = hostOf(hostOrUrl);
76
+ const info = host ? tunnelDomainFor(host) : undefined;
77
+ if (!host || !info)
78
+ return null;
79
+ const label = host.slice(0, host.length - info.domain.length - 1);
80
+ return label.includes('.') ? null : label;
81
+ }
82
+ /**
83
+ * `https?://<label>.<known domain>` as one alternation, for whole-string
84
+ * rewriting. Rebuilt per call because the set can grow at runtime; these are
85
+ * short lists and the callers are not hot loops.
86
+ */
87
+ export function tunnelUrlPattern() {
88
+ const alternatives = [...domains.keys()].map(escapeRegExp).join('|');
89
+ return new RegExp(`https?:\\/\\/[^\\s/"']+\\.(?:${alternatives})`, 'g');
90
+ }
91
+ function hostOf(hostOrUrl) {
92
+ if (typeof hostOrUrl !== 'string' || hostOrUrl.length === 0)
93
+ return undefined;
94
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(hostOrUrl)) {
95
+ try {
96
+ return new URL(hostOrUrl).hostname.toLowerCase();
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ }
102
+ // A bare host, possibly with a port.
103
+ return hostOrUrl.split('/')[0].split(':')[0].toLowerCase();
104
+ }
105
+ function escapeRegExp(value) {
106
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
107
+ }
108
+ /** Test hook: drop every runtime-registered domain, keeping the built-ins. */
109
+ export function _resetTunnelDomainsForTests() {
110
+ domains.clear();
111
+ for (const { domain, transport } of BUILT_IN_DOMAINS)
112
+ domains.set(domain, transport);
113
+ }
@@ -2,6 +2,7 @@
2
2
  * URL Parser Utilities
3
3
  * Helper functions for parsing and validating URLs, specifically for detecting localhost URLs
4
4
  */
5
+ import { tunnelUrlPattern } from './tunnelDomains.js';
5
6
  /**
6
7
  * Normalize a user-supplied URL string before validation.
7
8
  * Handles bare hostnames without a scheme (e.g. "localhost:3000" → "http://localhost:3000").
@@ -87,13 +88,18 @@ export function isLocalhostUrl(urlString) {
87
88
  }
88
89
  }
89
90
  /**
90
- * Replace ngrok tunnel URLs with the original localhost origin in any string/object.
91
+ * Replace tunnel URLs with the original localhost origin in any string/object.
91
92
  * Used to sanitize backend responses that contain internal tunnel URLs before
92
93
  * returning them to callers who only know the original localhost address.
94
+ *
95
+ * Covers EVERY known tunnel domain (utils/tunnelDomains.ts), not just ngrok's:
96
+ * during the migration one session can hold an ngrok tunnel and another a
97
+ * debugg tunnel, and a domain this does not know is a hostname that leaks to
98
+ * the caller.
93
99
  */
94
100
  export function replaceTunnelUrls(value, localhostOrigin) {
95
101
  if (typeof value === 'string') {
96
- return value.replace(/https?:\/\/[^\s/"]+\.ngrok\.debugg\.ai/g, localhostOrigin.replace(/\/$/, ''));
102
+ return value.replace(tunnelUrlPattern(), localhostOrigin.replace(/\/$/, ''));
97
103
  }
98
104
  if (Array.isArray(value)) {
99
105
  return value.map(item => replaceTunnelUrls(item, localhostOrigin));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
5
5
  "type": "module",
6
6
  "caddy": "2.11.3",
@@ -65,6 +65,7 @@
65
65
  "posthog-node": "^5.26.0",
66
66
  "uuid": "^11.1.0",
67
67
  "winston": "^3.15.0",
68
+ "ws": "^8.21.3",
68
69
  "zod": "^3.25.32"
69
70
  },
70
71
  "devDependencies": {
@@ -73,6 +74,7 @@
73
74
  "@types/jest": "^30.0.0",
74
75
  "@types/node": "^22.15.24",
75
76
  "@types/uuid": "^10.0.0",
77
+ "@types/ws": "^8.18.1",
76
78
  "eslint": "^10.2.0",
77
79
  "jest": "^30.0.5",
78
80
  "jest-environment-jsdom": "^30.0.5",