@debugg-ai/debugg-ai-mcp 3.9.3 → 4.0.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.
@@ -1,8 +1,26 @@
1
1
  /**
2
- * Cross-process tunnel registry.
2
+ * Cross-process tunnel registry — WRITE-MOSTLY OBSERVABILITY ONLY.
3
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.
4
+ * Under the old per-port model this registry was load-bearing for
5
+ * correctness: it was how a second MCP instance on the same machine
6
+ * discovered and BORROWED an existing tunnel instead of provisioning a
7
+ * duplicate for the same port. That borrowing mechanism (and everything that
8
+ * existed only to make it safe — freshness TTLs, PID-reuse defenses,
9
+ * adopt/reconcile against the local ngrok agent) is retired outright by
10
+ * docs/local-tunnel-multiplexer-architecture-2026-07-31.md §4: "No sharing,
11
+ * ever, at any granularity. Each session key gets its own Caddy instance and
12
+ * its own ngrok tunnel."
13
+ *
14
+ * What remains is a diagnostic view: `RegistryEntry` rows, keyed by
15
+ * `tunnelId` (not port — a port no longer identifies anything unique once
16
+ * one Caddy instance can be repointed across many ports, and one HTTP-mode
17
+ * process can host many session keys sharing one port namespace). Nothing in
18
+ * `TunnelManager` reads this registry to make a reuse/borrow decision
19
+ * anymore; it only writes to it (best-effort) so `~/.debugg-ai/tunnels.json`
20
+ * stays useful for a human debugging "what tunnels does this machine have
21
+ * open." An over-eager prune or a `$TMPDIR`-split process (bead `fcbm`) can
22
+ * therefore only corrupt the diagnostic view now, never correctness — a
23
+ * deliberate, accepted downgrade in what this file is trusted for (§6).
6
24
  *
7
25
  * The file registry uses an atomic rename-write so concurrent processes never
8
26
  * see a partial JSON file. All operations are best-effort — errors are
@@ -14,27 +32,24 @@
14
32
  * the process was LAUNCHED. Under launchd it is a per-user
15
33
  * `/var/folders/<...>/T`; a shell with a scrubbed environment gets `/tmp`; the
16
34
  * Docker image gets `/tmp`. Two MCPs started differently therefore kept two
17
- * SEPARATE registries, never saw each other's tunnels, and provisioned a
18
- * duplicate per port — silently and permanently. At a 1-hour minimum charge per
19
- * tunnel that is a guaranteed double-bill, not an edge case.
35
+ * SEPARATE registries and never saw each other's diagnostic rows.
20
36
  *
21
37
  * So the path is pinned to `~/.debugg-ai/tunnels.json`, which is the same file
22
38
  * for the same user no matter how the process was started, with a
23
39
  * `DEBUGG_AI_TUNNEL_REGISTRY` override for containers that mount a shared
24
40
  * volume elsewhere. Anything found at the legacy tmpdir() path is merged in
25
- * once, so tunnels already paid for on the old path stay discoverable.
41
+ * once, so rows already written on the old path stay visible.
26
42
  */
27
43
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
28
44
  import { homedir, tmpdir } from 'os';
29
45
  import { dirname, join } from 'path';
30
46
  // ── File location ─────────────────────────────────────────────────────────────
31
47
  /**
32
- * The pre-fcbm location. Still READ (and merged from) so tunnels provisioned by
33
- * an older build, or by a build whose $TMPDIR differed, are not stranded.
48
+ * The pre-fcbm location. Still READ (and merged from) so rows written by an
49
+ * older build, or by a build whose $TMPDIR differed, are not stranded.
34
50
  *
35
- * Never written to, and never deleted: a still-running older MCP is reading it,
36
- * and removing its entries would make it re-provision — the exact double-bill
37
- * this bead exists to stop.
51
+ * Never written to, and never deleted: a still-running older MCP may be
52
+ * reading it.
38
53
  */
39
54
  export function getLegacyRegistryFilePath() {
40
55
  return join(tmpdir(), 'debugg-ai-tunnels.json');
@@ -55,14 +70,9 @@ const migratedPaths = new Set();
55
70
  * Per-path watermark: when we last wrote a complete view of this registry.
56
71
  *
57
72
  * The read-side legacy overlay needs to tell two cases apart that look identical
58
- * in the file: an entry we have ALREADY considered and deliberately swept, and
59
- * one an old-build MCP wrote after we swept. Resurrecting the first hands a dead
60
- * tunnel to the next borrower (bead k34o); missing the second buys a duplicate.
61
- * An entry's `lastAccessedAt` relative to our last write separates them.
62
- *
63
- * Ties and clock skew resolve toward NOT resurrecting, because the two mistakes
64
- * are not equal: a dead entry breaks a run, a missed one costs an hour we were
65
- * already spending before this path moved.
73
+ * in the file: a row we have ALREADY considered and deliberately swept, and
74
+ * one an old-build MCP wrote after we swept. A `lastAccessedAt` relative to
75
+ * our last write separates them.
66
76
  */
67
77
  const sweptAt = new Map();
68
78
  // ── File-backed implementation (production) ───────────────────────────────────
@@ -77,31 +87,15 @@ const sweptAt = new Map();
77
87
  export function createFileRegistry(registryFile = getRegistryFilePath(), legacyFile = getLegacyRegistryFilePath()) {
78
88
  const store = {
79
89
  read() {
80
- // Overlay the legacy registry on EVERY read, not just once at startup.
81
- //
82
- // Moving the path partitions us against every MCP still running the old
83
- // build, which keeps writing to tmpdir() — i.e. for the length of the
84
- // rollout this change would REINTRODUCE the split-registry double-billing
85
- // it exists to fix. That window is not short: long-lived sessions here run
86
- // for days, so a one-shot merge at process start would miss every tunnel
87
- // an old build provisions afterwards.
88
- //
89
- // Reading both and preferring the fresher record closes the direction that
90
- // matters — new builds see old builds' tunnels and borrow them instead of
91
- // buying duplicates. The reverse (old builds seeing ours) cannot be fixed
92
- // from this side without dual-writing, which would hand the old code a file
93
- // it may prune on our behalf; it resolves as old sessions exit.
94
- //
95
- // Cost is one extra existsSync + small JSON read per lookup, against a
96
- // 1-hour minimum charge for getting it wrong.
90
+ // Overlay the legacy registry on EVERY read, not just once at startup
91
+ // see mergeLegacyRegistry()'s doc comment for why a one-shot merge at
92
+ // process start is not enough during a rollout.
97
93
  return overlayLegacy(readRegistryFile(registryFile), legacyFile, registryFile);
98
94
  },
99
95
  write(data) {
100
96
  const tmp = `${registryFile}.${process.pid}.tmp`;
101
97
  try {
102
- // 0o700: the registry names every local port this user is exposing.
103
- // Also covers a first run where ~/.debugg-ai does not exist yet, and a
104
- // later run where someone removed it.
98
+ // 0o700: the registry names every local tunnel this user has open.
105
99
  mkdirSync(dirname(registryFile), { recursive: true, mode: 0o700 });
106
100
  writeFileSync(tmp, JSON.stringify(data));
107
101
  // Same directory as the target, so the rename is same-filesystem and
@@ -128,28 +122,27 @@ export function createFileRegistry(registryFile = getRegistryFilePath(), legacyF
128
122
  /**
129
123
  * One-shot merge of the legacy tmpdir() registry into the stable one.
130
124
  *
131
- * Merge, not move: an entry only wins if this path has nothing for that port or
132
- * has something older. A tunnel that both files know about keeps whichever
133
- * record was touched most recently, which is the one whose `lastAccessedAt`
134
- * actually reflects use.
125
+ * Merge, not move: a row only wins if this path has nothing for that
126
+ * tunnelId or has something older. A tunnel both files know about keeps
127
+ * whichever record was touched most recently.
135
128
  */
136
129
  function mergeLegacyRegistry(store, registryFile, legacyFile) {
137
130
  if (legacyFile === registryFile || migratedPaths.has(registryFile))
138
131
  return;
139
132
  migratedPaths.add(registryFile);
140
133
  const legacy = readRegistryFile(legacyFile);
141
- const ports = Object.keys(legacy);
142
- if (ports.length === 0)
134
+ const ids = Object.keys(legacy);
135
+ if (ids.length === 0)
143
136
  return;
144
137
  const current = store.read();
145
138
  let merged = 0;
146
- for (const port of ports) {
147
- const entry = legacy[port];
139
+ for (const id of ids) {
140
+ const entry = legacy[id];
148
141
  if (!isRegistryEntry(entry))
149
142
  continue;
150
- const mine = current[port];
143
+ const mine = current[id];
151
144
  if (!mine || entry.lastAccessedAt > mine.lastAccessedAt) {
152
- current[port] = entry;
145
+ current[id] = entry;
153
146
  merged++;
154
147
  }
155
148
  }
@@ -158,26 +151,23 @@ function mergeLegacyRegistry(store, registryFile, legacyFile) {
158
151
  }
159
152
  /**
160
153
  * Merge the legacy registry over `current` in memory, fresher record winning.
161
- *
162
- * Read-side only — never writes. An old-build MCP that provisions a tunnel after
163
- * our one-shot migration ran is otherwise invisible to us, and we would buy a
164
- * duplicate for a port it already has covered.
154
+ * Read-side only — never writes.
165
155
  */
166
156
  function overlayLegacy(current, legacyFile, registryFile) {
167
157
  if (legacyFile === registryFile)
168
158
  return current;
169
159
  const watermark = sweptAt.get(registryFile) ?? 0;
170
160
  const legacy = readRegistryFile(legacyFile);
171
- for (const [port, entry] of Object.entries(legacy)) {
161
+ for (const [id, entry] of Object.entries(legacy)) {
172
162
  if (!isRegistryEntry(entry))
173
163
  continue;
174
- // Older than our last complete write => we already saw it and chose not to
175
- // keep it. Bringing it back would undo a prune and re-borrow a dead tunnel.
164
+ // Older than our last complete write => we already saw it and chose not
165
+ // to keep it (or it belongs to a tunnel that no longer exists).
176
166
  if (entry.lastAccessedAt <= watermark)
177
167
  continue;
178
- const mine = current[port];
168
+ const mine = current[id];
179
169
  if (!mine || entry.lastAccessedAt > mine.lastAccessedAt)
180
- current[port] = entry;
170
+ current[id] = entry;
181
171
  }
182
172
  return current;
183
173
  }
@@ -199,7 +189,7 @@ function isRegistryEntry(value) {
199
189
  return (!!e &&
200
190
  typeof e === 'object' &&
201
191
  typeof e.tunnelId === 'string' &&
202
- typeof e.port === 'number' &&
192
+ typeof e.sessionKey === 'string' &&
203
193
  typeof e.ownerPid === 'number' &&
204
194
  typeof e.lastAccessedAt === 'number');
205
195
  }
@@ -243,20 +233,17 @@ function checkPid(pid) {
243
233
  * Shared prune logic — read, filter, write back. Used by both the file-backed
244
234
  * and in-memory implementations so the eviction policy lives in one place.
245
235
  *
246
- * Eviction rule: drop entries where EITHER the owner PID is dead OR the entry
247
- * hasn't been touched within `staleAfterMs`. The freshness check is what
248
- * defends against PID-reuse (bead 3th).
236
+ * Eviction rule: drop entries whose owner PID is dead. That's the whole
237
+ * rule now see the `prune()` doc comment on `RegistryStore` for why the
238
+ * old freshness window is gone.
249
239
  */
250
- function pruneRegistryData(store, opts) {
251
- const now = opts.nowMs ?? Date.now();
240
+ function pruneRegistryData(store, _opts) {
252
241
  const data = store.read();
253
242
  const next = {};
254
243
  let pruned = 0;
255
- for (const [port, entry] of Object.entries(data)) {
256
- const aliveAndFresh = store.isPidAlive(entry.ownerPid) &&
257
- (now - entry.lastAccessedAt) <= opts.staleAfterMs;
258
- if (aliveAndFresh) {
259
- next[port] = entry;
244
+ for (const [id, entry] of Object.entries(data)) {
245
+ if (store.isPidAlive(entry.ownerPid)) {
246
+ next[id] = entry;
260
247
  }
261
248
  else {
262
249
  pruned++;
@@ -80,5 +80,7 @@ export function adaptVerdict(execution, opts = {}) {
80
80
  if (evidence?.loginError && typeof evidence.loginError === 'object') {
81
81
  relay.loginError = evidence.loginError;
82
82
  }
83
+ if (typeof evidence?.report === 'string' && evidence.report)
84
+ relay.report = evidence.report;
83
85
  return relay;
84
86
  }
@@ -56,6 +56,22 @@ export const TelemetryEvents = {
56
56
  TUNNEL_PROVISIONED: 'tunnel.provisioned',
57
57
  TUNNEL_PROVISION_RETRY: 'tunnel.provision_retry',
58
58
  TUNNEL_STOPPED: 'tunnel.stopped',
59
+ // services/ngrok/tunnelManager.ts — the session tunnel's Caddy instance
60
+ // crash-respawned onto a DIFFERENT local proxy port (sticky-port reclaim
61
+ // failed). The existing ngrok tunnel is now dialing a dead port, so the
62
+ // whole session tunnel is evicted and must be recreated on the next call
63
+ // (see docs/local-tunnel-multiplexer-architecture-2026-07-31.md §2.3).
64
+ TUNNEL_EVICTED_PORT_CHANGED: 'tunnel.evicted_port_changed',
65
+ // services/ngrok/tunnelManager.ts — stopTunnel()'s unconditional-removal
66
+ // contract (§2.3): map state is always removed before cleanup I/O runs, so
67
+ // a partial failure here (ngrok disconnect / caddy.stop() / key revoke)
68
+ // never leaves stale-but-discoverable state. Fired once per failed step.
69
+ TUNNEL_TEARDOWN_PARTIAL_FAILURE: 'tunnel.teardown_partial_failure',
59
70
  TEMPLATE_LOOKUP: 'template.lookup',
60
71
  PROJECT_LOOKUP: 'project.lookup',
72
+ // services/caddy/portLock.ts — maxHoldMs is an OBSERVABILITY-ONLY watchdog
73
+ // (see docs/local-tunnel-multiplexer-architecture-2026-07-31.md §2.4/§4):
74
+ // it never force-releases the lock, it only surfaces that a generation has
75
+ // been held unusually long.
76
+ PORT_LOCK_MAX_HOLD_EXCEEDED: 'port_lock.max_hold_exceeded',
61
77
  };
@@ -4,10 +4,11 @@
4
4
  * Centralizes:
5
5
  * - resolving user input url to a concrete URL
6
6
  * - creating / reusing ngrok tunnels after the backend returns a tunnelKey
7
+ * - acquiring/releasing this session's shared Caddy port route (§2.4)
7
8
  * - sanitizing backend responses so callers only ever see the original URL
8
9
  */
9
- import { tunnelManager } from '../services/ngrok/tunnelManager.js';
10
- import { isLocalhostUrl, replaceTunnelUrls, extractLocalhostPort, retargetTunnelUrl } from './urlParser.js';
10
+ import { tunnelManager, getSessionKey } from '../services/ngrok/tunnelManager.js';
11
+ import { isLocalhostUrl, replaceTunnelUrls, retargetTunnelUrl, extractLocalhostPort } from './urlParser.js';
11
12
  // ─── URL resolution ──────────────────────────────────────────────────────────
12
13
  /**
13
14
  * Resolve tool input to a concrete URL string.
@@ -27,24 +28,22 @@ export function buildContext(originalUrl) {
27
28
  }
28
29
  // ─── Tunnel creation ─────────────────────────────────────────────────────────
29
30
  /**
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.
31
+ * Check whether this SESSION already has a tunnel (§2.1 one ngrok tunnel
32
+ * per session key, not per local port). If found, touches its timer and
33
+ * returns an enriched context retargeted at this caller's own path. Returns
34
+ * null for public URLs or when this session has no tunnel yet.
33
35
  *
34
36
  * Call this BEFORE provisioning a new key — if it returns a context, skip the provision.
35
37
  */
36
38
  export function findExistingTunnel(ctx) {
37
39
  if (!ctx.isLocalhost)
38
40
  return null;
39
- const port = extractLocalhostPort(ctx.originalUrl);
40
- if (!port)
41
- return null;
42
- const existing = tunnelManager.getTunnelForPort(port);
41
+ const existing = tunnelManager.getSessionTunnelInfo(getSessionKey());
43
42
  if (!existing)
44
43
  return null;
45
44
  tunnelManager.touchTunnel(existing.tunnelId);
46
- // Bead zmc9: retarget to THIS caller's path — never replay existing.publicUrl,
47
- // which carries the path of whichever call created the (port-keyed) tunnel.
45
+ // Bead zmc9: retarget to THIS caller's path — never replay another call's
46
+ // path baked into a previously-returned URL for this same session tunnel.
48
47
  return {
49
48
  ...ctx,
50
49
  tunnelId: existing.tunnelId,
@@ -52,22 +51,60 @@ export function findExistingTunnel(ctx) {
52
51
  };
53
52
  }
54
53
  /**
55
- * Create (or reuse) a tunnel for a localhost URL.
54
+ * Create (or reuse) this session's tunnel (§2.1) for a localhost URL.
56
55
  *
57
56
  * Call this AFTER the backend returns a `tunnelKey` and `tunnelId`.
58
57
  * No-op for public URLs.
59
58
  *
60
59
  * @param ctx - Context built from `buildContext()`
61
60
  * @param tunnelKey - Auth token from the backend (short-lived ngrok key)
62
- * @param tunnelId - ID to use as the ngrok subdomain
61
+ * @param tunnelId - ID to use as the ngrok subdomain (only takes effect the
62
+ * first time this session creates a tunnel)
63
63
  * @param keyId - Backend key ID; stored on the tunnel so it is revoked on stop
64
64
  * @param revokeKey - Callback that revokes the backend key (called when tunnel stops)
65
65
  */
66
66
  export async function ensureTunnel(ctx, tunnelKey, tunnelId, keyId, revokeKey) {
67
67
  if (!ctx.isLocalhost)
68
68
  return ctx;
69
- const result = await tunnelManager.processUrl(ctx.originalUrl, tunnelKey, tunnelId, keyId, revokeKey);
70
- return { ...ctx, tunnelId: result.tunnelId, targetUrl: result.url };
69
+ const info = await tunnelManager.ensureSessionTunnel(getSessionKey(), tunnelKey, tunnelId, keyId, revokeKey);
70
+ return { ...ctx, tunnelId: info.tunnelId, targetUrl: retargetTunnelUrl(info.tunnelUrl, ctx.originalUrl) };
71
+ }
72
+ // ─── Port route lock (§2.4) ──────────────────────────────────────────────────
73
+ /**
74
+ * Acquire this session's shared Caddy route for `ctx`'s port, blocking until
75
+ * Caddy is CONFIRMED pointed at it (§2.4/§3.1 of the architecture doc). Every
76
+ * localhost-targeting handler must call this immediately after
77
+ * `findExistingTunnel`/`ensureTunnel` and BEFORE `probeTunnelHealth` — probing
78
+ * before the repoint is confirmed would probe whatever port happened to be
79
+ * active a moment ago, not the port this call actually wants.
80
+ *
81
+ * No-op (returns `ctx` unchanged) for public URLs and for any ctx that never
82
+ * got a `tunnelId` (dev mode, or a public URL that never provisioned a
83
+ * tunnel) — there is no shared route to serialize in either case.
84
+ */
85
+ export async function acquirePortRoute(ctx, opts) {
86
+ if (!ctx.isLocalhost || !ctx.tunnelId)
87
+ return ctx;
88
+ const info = tunnelManager.getTunnelInfo(ctx.tunnelId);
89
+ if (!info) {
90
+ throw new Error(`acquirePortRoute: no TunnelInfo for tunnel ${ctx.tunnelId} (session tunnel vanished?)`);
91
+ }
92
+ const port = extractLocalhostPort(ctx.originalUrl);
93
+ if (port === undefined) {
94
+ throw new Error(`acquirePortRoute: could not extract a port from localhost URL: ${ctx.originalUrl}`);
95
+ }
96
+ const isHttpsLocal = ctx.originalUrl.startsWith('https:');
97
+ const routeLock = await info.portLock.acquire({ port, isHttpsLocal }, opts);
98
+ return { ...ctx, routeLock };
99
+ }
100
+ /**
101
+ * Release this request's claim on the session's shared Caddy route, if it
102
+ * holds one. Call from the handler's existing `finally` block — safe/no-op
103
+ * when `ctx.routeLock` was never set (public URL, dev mode, or the call never
104
+ * reached `acquirePortRoute`).
105
+ */
106
+ export function releasePortRoute(ctx) {
107
+ ctx.routeLock?.release();
71
108
  }
72
109
  /**
73
110
  * Stop the tunnel associated with a context (fire-and-forget safe).
@@ -36,7 +36,6 @@
36
36
  * run's own evidence (bead 4bui, the one live-confirmed source of ERR_NGROK_*).
37
37
  */
38
38
  import { tunnelManager } from '../services/ngrok/tunnelManager.js';
39
- import { extractLocalhostPort } from './urlParser.js';
40
39
  import { Logger } from './logger.js';
41
40
  const logger = new Logger({ module: 'tunnelDisposition' });
42
41
  /**
@@ -91,10 +90,13 @@ export function isEndpointGone(ngrokErrorCode) {
91
90
  * them (run_test_suite had already drifted: it evicted on every failure and never
92
91
  * got bead k34o's shared-registry eviction at all).
93
92
  *
94
- * Endpoint proven gone → markTunnelDead: for an owned tunnel that disconnects and
95
- * revokes the key; for a BORROWED one it also evicts the
96
- * shared registry entry, which plain stopTunnel leaves
97
- * behind for every other session to re-borrow (bead k34o).
93
+ * Endpoint proven gone → markTunnelDead: disconnects the tunnel and revokes its
94
+ * key. Under the per-session-tunnel model (§4 of
95
+ * docs/local-tunnel-multiplexer-architecture-2026-07-31.md)
96
+ * every tunnel is created never borrowed by this
97
+ * process, so there is no separate shared-registry
98
+ * adoption record left to evict; bead k34o's borrowed-
99
+ * tunnel half retired along with cross-process borrowing.
98
100
  * Anything else → nothing at all. The caller still returns
99
101
  * TunnelTrafficBlocked, so the user is told; we simply do
100
102
  * not spend two billed hours acting on a verdict this
@@ -113,16 +115,6 @@ export function disposeUnhealthyTunnel(args) {
113
115
  '(1-hour minimum down, another up) and the next call reuses this one for free.');
114
116
  return;
115
117
  }
116
- const port = extractLocalhostPort(originalUrl);
117
- if (typeof port !== 'number') {
118
- // markTunnelDead is keyed by port. Without one we cannot evict the shared
119
- // entry safely, and a blind stopTunnel is exactly the teardown this module
120
- // exists to prevent — so keep the tunnel and say why.
121
- logger.warn(`Tunnel ${tunnelId} reported ${health.ngrokErrorCode} but no port could be parsed from ${originalUrl} — ` +
122
- 'leaving it in place rather than risking a teardown of a live tunnel.');
123
- return;
124
- }
125
- logger.warn(`Tunnel ${tunnelId} on port ${port} reported ${health.ngrokErrorCode} — the endpoint is gone, evicting it ` +
126
- 'so no session re-borrows the corpse (bead k34o).');
127
- tunnelManager.markTunnelDead(port, tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${tunnelId}: ${err}`));
118
+ logger.warn(`Tunnel ${tunnelId} (${originalUrl}) reported ${health.ngrokErrorCode} — the endpoint is gone, evicting it.`);
119
+ tunnelManager.markTunnelDead(tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${tunnelId}: ${err}`));
128
120
  }
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "3.9.3",
3
+ "version": "4.0.0",
4
4
  "description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
5
5
  "type": "module",
6
+ "caddy": "2.11.3",
6
7
  "bin": {
7
8
  "debugg-ai-mcp": "dist/index.js"
8
9
  },
@@ -57,6 +58,7 @@
57
58
  "changelog": "https://github.com/debugg-ai/debugg-ai-mcp/CHANGELOG.md",
58
59
  "dependencies": {
59
60
  "@modelcontextprotocol/sdk": "^1.27.0",
61
+ "@radically-straightforward/caddy": "^2.0.12",
60
62
  "axios": "^1.9.0",
61
63
  "mkdirp": "^3.0.1",
62
64
  "ngrok": "^5.0.0-beta.2",