tunnel-mcp 0.1.2 → 0.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
@@ -11,6 +11,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
 
12
12
  - Nothing yet.
13
13
 
14
+ ## [0.1.3] - 2026-07-01
15
+
16
+ ### Changed
17
+
18
+ - **`tunnel_open` no longer hard-fails when the host can't reach
19
+ `*.trycloudflare.com`.** Because this is a cross-network tool, only the guest's
20
+ network has to reach the link — so a host-side reachability-probe failure
21
+ (blocked DNS, or a proxy Node's `fetch` ignores) now **opens the tunnel anyway
22
+ and returns a `reachabilityWarning`** by default, instead of blocking a tunnel
23
+ that would have worked for the guest. Behavior is configurable via
24
+ `TUNNEL_REACHABILITY`: `warn` (default), `strict` (previous hard-fail), or
25
+ `off` (skip the probe). This replaces the `TUNNEL_SKIP_REACHABILITY_CHECK` flag
26
+ from 0.1.2, which is still honored as `off` for backward compatibility.
27
+
14
28
  ## [0.1.2] - 2026-07-01
15
29
 
16
30
  ### Added
@@ -80,7 +94,8 @@ install-skill` copies the `tunnel-etiquette` skill into `~/.claude/skills`
80
94
  declaring a fix "confirmed".
81
95
  - Test suite of 109 tests built with vitest, developed test-first (TDD).
82
96
 
83
- [Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.2...HEAD
97
+ [Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.3...HEAD
98
+ [0.1.3]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.2...v0.1.3
84
99
  [0.1.2]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.1...v0.1.2
85
100
  [0.1.1]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.0...v0.1.1
86
101
  [0.1.0]: https://github.com/zachlikefolio/tunnel-mcp/releases/tag/v0.1.0
package/README.md CHANGED
@@ -178,15 +178,21 @@ an interactive CLI — with no arguments it starts and waits for an MCP client t
178
178
  connect over stdin/stdout. That's working as intended. Register it with a client
179
179
  (above), or run `tunnel-mcp --help`.
180
180
 
181
- **`tunnel_open` fails with "never became reachable" / can't resolve
182
- `*.trycloudflare.com`.** cloudflared reaches Cloudflare's edge over its own
183
- protocol, but the public `*.trycloudflare.com` hostname still has to resolve via
184
- normal DNS — and some networks (corporate/filtered networks, and a few public
185
- DNS resolvers) block `trycloudflare.com`. Both you **and your guest** need to be
186
- able to resolve it. Check with `dig +short <random>.trycloudflare.com` or
187
- `curl -sI https://<the-url>`. If only your guest's network needs to reach the
188
- URL, set `TUNNEL_SKIP_REACHABILITY_CHECK=1` to open the tunnel without the
189
- host-side reachability probe.
181
+ **`tunnel_open` warns that this machine can't reach `*.trycloudflare.com`.**
182
+ cloudflared reaches Cloudflare's edge over its own protocol, but the public
183
+ `*.trycloudflare.com` hostname still has to resolve via normal DNS — and some
184
+ networks (corporate/filtered networks, some public DNS resolvers, or a proxy that
185
+ Node's `fetch` ignores) can't reach it from the host. Because only your
186
+ **guest's** network truly has to reach the link, `tunnel_open` **opens anyway and
187
+ returns a `reachabilityWarning` by default** share the link and have your guest
188
+ confirm they can open it. Control this with `TUNNEL_REACHABILITY`:
189
+
190
+ - `warn` (default) — open, but warn when the host can't reach the URL
191
+ - `strict` — fail `tunnel_open` unless the host can reach the URL first
192
+ - `off` — skip the host-side reachability probe entirely
193
+
194
+ Diagnose the host's DNS with `dig +short <random>.trycloudflare.com` or
195
+ `curl -sI https://<the-url>`.
190
196
 
191
197
  ## Roadmap / not yet supported
192
198
 
package/dist/cli.js CHANGED
@@ -60,10 +60,12 @@ export function helpText(version = readVersion()) {
60
60
  ` --force, -f Overwrite an existing install`,
61
61
  ``,
62
62
  `Environment:`,
63
- ` TUNNEL_SKILLS_DIR Override the skills directory`,
64
- ` TUNNEL_SKIP_SKILL_INSTALL=1 Skip the automatic skill install on npm install`,
65
- ` TUNNEL_SKIP_REACHABILITY_CHECK=1 Open a tunnel even if this machine can't reach`,
66
- ` *.trycloudflare.com (your guest still must)`,
63
+ ` TUNNEL_SKILLS_DIR Override the skills directory`,
64
+ ` TUNNEL_SKIP_SKILL_INSTALL=1 Skip the automatic skill install on npm install`,
65
+ ` TUNNEL_REACHABILITY warn (default) | strict | off how tunnel_open reacts`,
66
+ ` when this host can't reach *.trycloudflare.com. Only the`,
67
+ ` guest's network truly must reach it, so the default warns`,
68
+ ` and opens anyway; strict fails; off skips the check.`,
67
69
  ``,
68
70
  `Docs: https://github.com/zachlikefolio/tunnel-mcp`,
69
71
  ].join('\n');
@@ -1,6 +1,8 @@
1
+ import { ReachabilityMode } from '../env.js';
1
2
  export interface TunnelHandle {
2
3
  publicUrl: string;
3
4
  stop(): void;
5
+ reachabilityWarning?: string;
4
6
  }
5
7
  export interface StartOptions {
6
8
  timeoutMs?: number;
@@ -9,12 +11,13 @@ export interface StartOptions {
9
11
  intervalMs?: number;
10
12
  healthCheck?: (url: string) => Promise<boolean>;
11
13
  probeTimeoutMs?: number;
12
- skipHealthCheck?: boolean;
14
+ reachability?: ReachabilityMode;
13
15
  }
14
16
  export declare function parsePublicUrl(line: string): string | null;
15
17
  export declare function describeProbeError(e: unknown): string;
16
18
  export declare function defaultHealthCheck(url: string): Promise<boolean>;
17
19
  export declare function unreachableMessage(url: string, attempts: number, lastReason?: string): string;
20
+ export declare function reachabilityWarningMessage(url: string, lastReason?: string): string;
18
21
  /**
19
22
  * `extraArgs` exists for tests: it lets a fake binary (e.g. `node fake.mjs`) be
20
23
  * launched in place of `cloudflared tunnel --url ...`. Production passes none.
@@ -35,12 +35,12 @@ async function reachabilityProbe(url, probeTimeoutMs) {
35
35
  export async function defaultHealthCheck(url) {
36
36
  return (await reachabilityProbe(url, DEFAULT_PROBE_TIMEOUT_MS)).ok;
37
37
  }
38
- // Builds the actionable "never became reachable" error. Names the host and,
39
- // when the failure looks like DNS resolution, points at *.trycloudflare.com
40
- // blocking the single most common real-world cause. Always mentions the
41
- // escape hatch, since the guest's network (not the host's) is what must reach
42
- // the URL for messaging.
43
- export function unreachableMessage(url, attempts, lastReason) {
38
+ // The shared DNS sentence: when a probe failure looks like name resolution,
39
+ // point at *.trycloudflare.com being blocked the single most common real-world
40
+ // cause. Returns '' when the failure isn't DNS-shaped.
41
+ function dnsHint(url, reason) {
42
+ if (!reason || !/ENOTFOUND|EAI_AGAIN|getaddrinfo|\bdns\b/i.test(reason))
43
+ return '';
44
44
  let host = url;
45
45
  try {
46
46
  host = new URL(url).host;
@@ -48,18 +48,31 @@ export function unreachableMessage(url, attempts, lastReason) {
48
48
  catch {
49
49
  /* keep the raw url */
50
50
  }
51
- const dnsish = !!lastReason && /ENOTFOUND|EAI_AGAIN|getaddrinfo|\bdns\b/i.test(lastReason);
51
+ return (` This machine can't resolve ${host} — your DNS or network may be blocking *.trycloudflare.com` +
52
+ ` (common on filtered/corporate networks and some public DNS resolvers).`);
53
+ }
54
+ // Fatal error for `strict` mode: the host never confirmed the edge was routing.
55
+ export function unreachableMessage(url, attempts, lastReason) {
52
56
  let msg = `cloudflared reported ${url} but it never became reachable from this machine after ${attempts} probe(s)`;
53
57
  if (lastReason)
54
58
  msg += ` (last error: ${lastReason})`;
55
- msg += '.';
56
- if (dnsish) {
57
- msg +=
58
- ` This machine can't resolve ${host} — your DNS or network may be blocking *.trycloudflare.com` +
59
- ` (common on filtered/corporate networks and some public DNS resolvers). You and your guest both` +
60
- ` need to be able to resolve it.`;
61
- }
62
- msg += ` If you're confident your guest's network can reach the URL, set TUNNEL_SKIP_REACHABILITY_CHECK=1 to open the tunnel anyway.`;
59
+ msg += '.' + dnsHint(url, lastReason);
60
+ msg +=
61
+ ` Both you and your guest must be able to reach it. Set TUNNEL_REACHABILITY=warn (the default) to` +
62
+ ` open anyway with a warning, or TUNNEL_REACHABILITY=off to skip this check entirely.`;
63
+ return msg;
64
+ }
65
+ // Non-fatal warning for `warn` mode: the tunnel is open, but this host couldn't
66
+ // confirm reachability. Only the guest's network has to reach the URL, so this
67
+ // is often a false alarm — but surface it so the human can sanity-check.
68
+ export function reachabilityWarningMessage(url, lastReason) {
69
+ let msg = `Tunnel opened, but this machine could not reach ${url}`;
70
+ if (lastReason)
71
+ msg += ` (${lastReason})`;
72
+ msg += '.' + dnsHint(url, lastReason);
73
+ msg +=
74
+ ` Your guest still needs to reach the link — if they can't open it, check your DNS/proxy. Set` +
75
+ ` TUNNEL_REACHABILITY=strict to require host reachability, or =off to silence this check.`;
63
76
  return msg;
64
77
  }
65
78
  // Races a single probe against a per-attempt timeout so that a caller-supplied
@@ -142,10 +155,12 @@ export function startCloudflared(binPath, localPort, opts = {}) {
142
155
  if (url && !settled) {
143
156
  settled = true;
144
157
  clearTimeout(timer);
145
- // Escape hatch: the reachability probe runs on the *host*, but only the
146
- // guest's network must reach the URL for messaging. A host on a filtered
147
- // network can opt to skip the probe and hand out the link regardless.
148
- if (opts.skipHealthCheck) {
158
+ // The reachability probe runs on the *host*, but only the guest's
159
+ // network must reach the URL for messaging. 'off' skips it entirely;
160
+ // 'warn' (the product default) opens anyway and reports a warning;
161
+ // 'strict' fails open() if the host can't confirm reachability.
162
+ const mode = opts.reachability ?? 'strict';
163
+ if (mode === 'off') {
149
164
  resolve({ publicUrl: url, stop });
150
165
  return;
151
166
  }
@@ -153,6 +168,13 @@ export function startCloudflared(binPath, localPort, opts = {}) {
153
168
  .then((res) => {
154
169
  if (res.ok)
155
170
  resolve({ publicUrl: url, stop });
171
+ else if (mode === 'warn') {
172
+ resolve({
173
+ publicUrl: url,
174
+ stop,
175
+ reachabilityWarning: reachabilityWarningMessage(url, res.reason),
176
+ });
177
+ }
156
178
  else {
157
179
  stop();
158
180
  reject(new Error(unreachableMessage(url, attempts, res.reason)));
package/dist/env.d.ts CHANGED
@@ -5,3 +5,15 @@
5
5
  * truthiness check treats "0"/"false" as true).
6
6
  */
7
7
  export declare function envFlag(name: string): boolean;
8
+ export type ReachabilityMode = 'warn' | 'strict' | 'off';
9
+ /**
10
+ * How `tunnel_open` treats a host-side reachability-probe failure:
11
+ * warn (default) — open anyway, surface a warning; the guest is the real test
12
+ * strict — fail open() if the host can't reach the public URL
13
+ * off — skip the probe entirely
14
+ * Reads `TUNNEL_REACHABILITY`. Only when it is unset/blank does it fall back to
15
+ * the deprecated `TUNNEL_SKIP_REACHABILITY_CHECK` (== off) from 0.1.2 — an
16
+ * explicitly set (even mistyped) `TUNNEL_REACHABILITY` never defers to the alias.
17
+ * Any unrecognized value defaults to warn.
18
+ */
19
+ export declare function reachabilityMode(): ReachabilityMode;
package/dist/env.js CHANGED
@@ -11,3 +11,21 @@ export function envFlag(name) {
11
11
  const s = v.trim().toLowerCase();
12
12
  return s !== '' && s !== '0' && s !== 'false' && s !== 'no' && s !== 'off';
13
13
  }
14
+ /**
15
+ * How `tunnel_open` treats a host-side reachability-probe failure:
16
+ * warn (default) — open anyway, surface a warning; the guest is the real test
17
+ * strict — fail open() if the host can't reach the public URL
18
+ * off — skip the probe entirely
19
+ * Reads `TUNNEL_REACHABILITY`. Only when it is unset/blank does it fall back to
20
+ * the deprecated `TUNNEL_SKIP_REACHABILITY_CHECK` (== off) from 0.1.2 — an
21
+ * explicitly set (even mistyped) `TUNNEL_REACHABILITY` never defers to the alias.
22
+ * Any unrecognized value defaults to warn.
23
+ */
24
+ export function reachabilityMode() {
25
+ const raw = (process.env.TUNNEL_REACHABILITY ?? '').trim().toLowerCase();
26
+ if (raw === 'warn' || raw === 'strict' || raw === 'off')
27
+ return raw;
28
+ if (raw === '' && envFlag('TUNNEL_SKIP_REACHABILITY_CHECK'))
29
+ return 'off';
30
+ return 'warn';
31
+ }
package/dist/session.d.ts CHANGED
@@ -32,6 +32,7 @@ export declare class TunnelSession {
32
32
  joinLink: string;
33
33
  status: string;
34
34
  joinLinkExpiresInSec: number;
35
+ reachabilityWarning?: string;
35
36
  }>;
36
37
  join(joinLink: string, guestName: string): Promise<{
37
38
  tunnelId: string;
package/dist/session.js CHANGED
@@ -7,12 +7,12 @@ import { GuestClient } from './relay/guestClient.js';
7
7
  import { ensureCloudflared as realEnsure } from './cloudflared/provision.js';
8
8
  import { startCloudflared as realStart } from './cloudflared/tunnelProcess.js';
9
9
  import { DEFAULT_LISTEN_TIMEOUT_MS, DEFAULT_IDLE_TEARDOWN_MS, DEFAULT_JOIN_LINK_TTL_MS, OPEN_RETRY_ATTEMPTS, } from './config.js';
10
- import { envFlag } from './env.js';
10
+ import { reachabilityMode } from './env.js';
11
11
  const DEFAULT_DEPS = {
12
12
  ensureCloudflared: realEnsure,
13
- // Read the escape hatch per-call (not at module load) so it can be set right
14
- // before opening a tunnel.
15
- startCloudflared: (bin, port) => realStart(bin, port, envFlag('TUNNEL_SKIP_REACHABILITY_CHECK') ? { skipHealthCheck: true } : {}),
13
+ // Resolve the reachability mode per-call (not at module load) so TUNNEL_REACHABILITY
14
+ // can be set right before opening a tunnel.
15
+ startCloudflared: (bin, port) => realStart(bin, port, { reachability: reachabilityMode() }),
16
16
  };
17
17
  export class TunnelSession {
18
18
  deps;
@@ -84,6 +84,9 @@ export class TunnelSession {
84
84
  joinLink,
85
85
  status: 'waiting_for_guest',
86
86
  joinLinkExpiresInSec: Math.round(joinTtlMs / 1000),
87
+ // Present only in 'warn' mode when the host couldn't confirm reachability;
88
+ // the agent should relay it to the human before sharing the link.
89
+ ...(tunnel.reachabilityWarning ? { reachabilityWarning: tunnel.reachabilityWarning } : {}),
87
90
  };
88
91
  }
89
92
  async join(joinLink, guestName) {
package/dist/tools.js CHANGED
@@ -28,7 +28,7 @@ function register(server, name, schema, cb) {
28
28
  }
29
29
  export function registerTools(server, session, opts) {
30
30
  register(server, 'tunnel_open', {
31
- description: 'Open a tunnel as host and get a join link to share. The link is a secret — share it over a trusted channel. It is single-use (works for exactly one guest) and expires (see joinLinkExpiresInSec in the result), so tell the human to share it promptly.',
31
+ description: 'Open a tunnel as host and get a join link to share. The link is a secret — share it over a trusted channel. It is single-use (works for exactly one guest) and expires (see joinLinkExpiresInSec in the result), so tell the human to share it promptly. If the result includes a reachabilityWarning, relay it to the human: this host could not confirm it can reach the link, so the guest should verify they can open it.',
32
32
  inputSchema: { goal: z.string() },
33
33
  }, async ({ goal }) => ok(await session.open(goal, opts.displayName)));
34
34
  register(server, 'tunnel_join', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnel-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Let two developers' Claude agents talk directly through an ephemeral, end-to-end-encrypted tunnel.",
5
5
  "type": "module",
6
6
  "bin": {