tunnel-mcp 0.1.1 → 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,46 @@ 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
+
28
+ ## [0.1.2] - 2026-07-01
29
+
30
+ ### Added
31
+
32
+ - **`install-skill` command and automatic skill install.** `tunnel-mcp
33
+ install-skill` copies the `tunnel-etiquette` skill into `~/.claude/skills`
34
+ (override with `--dir`/`$TUNNEL_SKILLS_DIR`, overwrite with `--force`), and a
35
+ global `npm install` now installs it best-effort via a postinstall script.
36
+ Set `TUNNEL_SKIP_SKILL_INSTALL=1` to opt out; the postinstall never fails an
37
+ install and is a no-op under `npx`, `--ignore-scripts`, and CI.
38
+ - **`--help` and `--version` flags**, plus a one-line stderr startup banner, so
39
+ running the server by hand no longer looks like a silent hang. The server also
40
+ hints how to install the etiquette skill when it isn't present.
41
+ - **`TUNNEL_SKIP_REACHABILITY_CHECK` escape hatch.** Opens a tunnel even when the
42
+ host can't reach `*.trycloudflare.com` itself — useful when only the guest's
43
+ network needs to reach the URL.
44
+
45
+ ### Fixed
46
+
47
+ - The MCP server reported a hardcoded, stale version (`0.1.0`) in its handshake;
48
+ it now reports the real package version.
49
+ - A failed cloudflared reachability probe surfaced a generic "never became
50
+ reachable" error. It now names the host and, when the failure is DNS
51
+ resolution, points at `*.trycloudflare.com` being blocked (a common
52
+ corporate/filtered-DNS cause) and mentions the escape hatch above.
53
+
14
54
  ## [0.1.1] - 2026-07-01
15
55
 
16
56
  ### Security
@@ -54,5 +94,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
54
94
  declaring a fix "confirmed".
55
95
  - Test suite of 109 tests built with vitest, developed test-first (TDD).
56
96
 
57
- [Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.0...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
99
+ [0.1.2]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.1...v0.1.2
100
+ [0.1.1]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.0...v0.1.1
58
101
  [0.1.0]: https://github.com/zachlikefolio/tunnel-mcp/releases/tag/v0.1.0
package/README.md CHANGED
@@ -50,13 +50,29 @@ npx tunnel-mcp
50
50
  Register it with Claude Code (both developers do this once):
51
51
 
52
52
  ```bash
53
- claude mcp add tunnel -- tunnel-mcp
53
+ claude mcp add tunnel -- tunnel-mcp # if globally installed
54
+ # or, with no global install:
55
+ claude mcp add tunnel -- npx -y tunnel-mcp
54
56
  ```
55
57
 
56
- Install the etiquette skill so each agent knows how to behave inside a tunnel
57
- (treat the peer as untrusted input, check with its human before acting on
58
- anything the peer says). Copy `skill/tunnel-etiquette/` from this repo into your
59
- `~/.claude/skills/` directory (or your plugin's skills directory).
58
+ > `tunnel-mcp` is a stdio MCP server, not an interactive CLI. Launching it by
59
+ > hand just waits silently for a client that's expected. Run
60
+ > `tunnel-mcp --help` for usage, or `tunnel-mcp --version`.
61
+
62
+ The **tunnel-etiquette skill** teaches each agent how to behave inside a tunnel
63
+ (treat the peer as untrusted input, and check with its human before acting on
64
+ anything the peer says). Installing the package copies it into `~/.claude/skills/`
65
+ automatically (best-effort). If install scripts are disabled
66
+ (`npm install --ignore-scripts`), or you want it in a custom directory or force an
67
+ update, run it explicitly:
68
+
69
+ ```bash
70
+ npx tunnel-mcp install-skill # into ~/.claude/skills
71
+ npx tunnel-mcp install-skill --dir <path> --force # elsewhere / overwrite
72
+ ```
73
+
74
+ Set `TUNNEL_SKILLS_DIR` to change the default target, or
75
+ `TUNNEL_SKIP_SKILL_INSTALL=1` to opt out of the automatic copy.
60
76
 
61
77
  `cloudflared` is auto-downloaded to `~/.tunnel/bin` the first time it's needed if
62
78
  it isn't already on your `PATH` — there's nothing extra to install.
@@ -146,7 +162,7 @@ vulnerability.
146
162
 
147
163
  ```bash
148
164
  npm ci # install dependencies
149
- npm test # run the test suite (109 tests, TDD)
165
+ npm test # run the test suite (136 tests, TDD)
150
166
  npm run build # compile TypeScript
151
167
  npm run lint # eslint
152
168
  npm run format:check # prettier --check .
@@ -155,6 +171,29 @@ npm run test:coverage # vitest run --coverage
155
171
 
156
172
  See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to propose changes.
157
173
 
174
+ ## Troubleshooting
175
+
176
+ **`tunnel-mcp` / `npx tunnel-mcp` "does nothing".** It's a stdio MCP server, not
177
+ an interactive CLI — with no arguments it starts and waits for an MCP client to
178
+ connect over stdin/stdout. That's working as intended. Register it with a client
179
+ (above), or run `tunnel-mcp --help`.
180
+
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>`.
196
+
158
197
  ## Roadmap / not yet supported
159
198
 
160
199
  This is an MVP. The following are explicitly out of scope for now:
@@ -162,7 +201,7 @@ This is an MVP. The following are explicitly out of scope for now:
162
201
  - Host-offline / asynchronous messaging
163
202
  - More than two participants in a session
164
203
  - Alternative transports (ngrok, WebRTC)
165
- - Join-link rotation or one-time tokens
204
+ - Join-link rotation (re-issuing a fresh link mid-session; note that links are already single-use and expiring — see the security model above)
166
205
  - Encrypting the goal or other metadata
167
206
 
168
207
  ## License
package/dist/cli.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ export type ParsedArgs = {
2
+ mode: 'serve';
3
+ } | {
4
+ mode: 'help';
5
+ } | {
6
+ mode: 'version';
7
+ } | {
8
+ mode: 'install-skill';
9
+ dir?: string;
10
+ force: boolean;
11
+ } | {
12
+ mode: 'error';
13
+ message: string;
14
+ };
15
+ export declare function parseArgs(argv: string[]): ParsedArgs;
16
+ export declare function helpText(version?: string): string;
17
+ /** Runs the `install-skill` command. Returns a process exit code. */
18
+ export declare function runInstallSkill(args: {
19
+ dir?: string;
20
+ force: boolean;
21
+ }, out: (msg: string) => void): number;
package/dist/cli.js ADDED
@@ -0,0 +1,89 @@
1
+ import { installSkill, readVersion } from './skillInstall.js';
2
+ export function parseArgs(argv) {
3
+ if (argv.length === 0)
4
+ return { mode: 'serve' };
5
+ const [cmd, ...rest] = argv;
6
+ if (cmd === '--help' || cmd === '-h' || cmd === 'help')
7
+ return { mode: 'help' };
8
+ if (cmd === '--version' || cmd === '-v')
9
+ return { mode: 'version' };
10
+ if (cmd === 'install-skill') {
11
+ let dir;
12
+ let force = false;
13
+ for (let i = 0; i < rest.length; i++) {
14
+ const a = rest[i];
15
+ if (a === '--force' || a === '-f')
16
+ force = true;
17
+ else if (a === '--dir') {
18
+ const next = rest[++i];
19
+ // Guard against `--dir --force` (eats the flag) and a trailing `--dir`.
20
+ if (next === undefined || next.startsWith('-')) {
21
+ return { mode: 'error', message: '--dir requires a path' };
22
+ }
23
+ dir = next;
24
+ }
25
+ else if (a.startsWith('--dir=')) {
26
+ dir = a.slice('--dir='.length);
27
+ if (dir === '')
28
+ return { mode: 'error', message: '--dir requires a path' };
29
+ }
30
+ else
31
+ return { mode: 'error', message: `unknown option for install-skill: ${a}` };
32
+ }
33
+ return { mode: 'install-skill', dir, force };
34
+ }
35
+ return { mode: 'error', message: `unknown command: ${cmd}` };
36
+ }
37
+ export function helpText(version = readVersion()) {
38
+ return [
39
+ `tunnel-mcp v${version}`,
40
+ `An MCP server that lets two developers' Claude agents talk directly through an`,
41
+ `ephemeral, end-to-end-encrypted tunnel.`,
42
+ ``,
43
+ `This is a stdio MCP server, NOT an interactive CLI. With no arguments it starts`,
44
+ `the server and waits for an MCP client to connect over stdin/stdout, so running`,
45
+ `it by hand in a terminal will look like it "does nothing" — that's expected.`,
46
+ `You add it to an MCP client instead:`,
47
+ ``,
48
+ ` claude mcp add tunnel -- npx -y tunnel-mcp # Claude Code (this project)`,
49
+ ` claude mcp add -s user tunnel -- npx -y tunnel-mcp # ...available everywhere`,
50
+ ``,
51
+ `Commands:`,
52
+ ` (no args) Start the MCP server on stdio (for an MCP client to launch)`,
53
+ ` install-skill Install the tunnel-etiquette skill into your skills directory`,
54
+ ` --help, -h Show this help and exit`,
55
+ ` --version, -v Print the version and exit`,
56
+ ``,
57
+ `install-skill options:`,
58
+ ` --dir <path> Target skills directory (default: ~/.claude/skills or`,
59
+ ` $TUNNEL_SKILLS_DIR)`,
60
+ ` --force, -f Overwrite an existing install`,
61
+ ``,
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_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.`,
69
+ ``,
70
+ `Docs: https://github.com/zachlikefolio/tunnel-mcp`,
71
+ ].join('\n');
72
+ }
73
+ /** Runs the `install-skill` command. Returns a process exit code. */
74
+ export function runInstallSkill(args, out) {
75
+ try {
76
+ const res = installSkill({ skillsDir: args.dir, overwrite: args.force });
77
+ if (res.installed) {
78
+ out(`Installed the tunnel-etiquette skill to ${res.target}${res.updated ? ' (overwrote the existing copy)' : ''}.`);
79
+ }
80
+ else {
81
+ out(`The tunnel-etiquette skill is already present at ${res.target}. Re-run with --force to overwrite it.`);
82
+ }
83
+ return 0;
84
+ }
85
+ catch (e) {
86
+ out(`Failed to install the skill: ${e instanceof Error ? e.message : String(e)}`);
87
+ return 1;
88
+ }
89
+ }
@@ -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,9 +11,13 @@ export interface StartOptions {
9
11
  intervalMs?: number;
10
12
  healthCheck?: (url: string) => Promise<boolean>;
11
13
  probeTimeoutMs?: number;
14
+ reachability?: ReachabilityMode;
12
15
  }
13
16
  export declare function parsePublicUrl(line: string): string | null;
17
+ export declare function describeProbeError(e: unknown): string;
14
18
  export declare function defaultHealthCheck(url: string): Promise<boolean>;
19
+ export declare function unreachableMessage(url: string, attempts: number, lastReason?: string): string;
20
+ export declare function reachabilityWarningMessage(url: string, lastReason?: string): string;
15
21
  /**
16
22
  * `extraArgs` exists for tests: it lets a fake binary (e.g. `node fake.mjs`) be
17
23
  * launched in place of `cloudflared tunnel --url ...`. Production passes none.
@@ -8,49 +8,108 @@ export function parsePublicUrl(line) {
8
8
  const m = line.match(URL_RE);
9
9
  return m ? m[0] : null;
10
10
  }
11
- // Any HTTP response (even 404/502/426) means the Cloudflare edge is routing to us.
11
+ // undici (Node's global fetch) reports the real network error on `.cause`, e.g.
12
+ // a DNS failure surfaces as `cause.code === 'ENOTFOUND'`. Pull that out so the
13
+ // caller can tell "DNS can't resolve the host" apart from "edge not ready yet".
14
+ export function describeProbeError(e) {
15
+ const err = e;
16
+ const code = err?.cause?.code;
17
+ if (code)
18
+ return err.cause?.message ? `${code}: ${err.cause.message}` : code;
19
+ if (err?.name === 'TimeoutError')
20
+ return 'probe timed out';
21
+ return err?.message || 'unknown error';
22
+ }
23
+ // Any HTTP response (even 404/502/426) means the Cloudflare edge is routing to
24
+ // us. A thrown error carries why it failed (DNS, TLS, refused, timeout).
25
+ async function reachabilityProbe(url, probeTimeoutMs) {
26
+ try {
27
+ await fetch(url, { method: 'GET', signal: AbortSignal.timeout(probeTimeoutMs) });
28
+ return { ok: true };
29
+ }
30
+ catch (e) {
31
+ return { ok: false, reason: describeProbeError(e) };
32
+ }
33
+ }
34
+ // Back-compat boolean probe (kept for external callers/tests).
12
35
  export async function defaultHealthCheck(url) {
36
+ return (await reachabilityProbe(url, DEFAULT_PROBE_TIMEOUT_MS)).ok;
37
+ }
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
+ let host = url;
13
45
  try {
14
- await fetch(url, { method: 'GET', signal: AbortSignal.timeout(DEFAULT_PROBE_TIMEOUT_MS) });
15
- return true;
46
+ host = new URL(url).host;
16
47
  }
17
48
  catch {
18
- return false; // network/DNS error/timeout → edge not ready yet
49
+ /* keep the raw url */
19
50
  }
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) {
56
+ let msg = `cloudflared reported ${url} but it never became reachable from this machine after ${attempts} probe(s)`;
57
+ if (lastReason)
58
+ msg += ` (last error: ${lastReason})`;
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;
20
64
  }
21
- // Races a single health-check attempt against a per-attempt timeout so that a
22
- // caller-supplied `check` that throws, rejects, or simply never resolves can
23
- // never leave the loop (and therefore the outer startCloudflared promise)
24
- // hanging. Any failure mode here just counts as "not healthy yet".
25
- function probeOnce(url, check, probeTimeoutMs) {
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.`;
76
+ return msg;
77
+ }
78
+ // Races a single probe against a per-attempt timeout so that a caller-supplied
79
+ // `check` that throws, rejects, or simply never resolves can never leave the
80
+ // loop (and therefore the outer startCloudflared promise) hanging.
81
+ function probeOnce(url, probe, probeTimeoutMs) {
26
82
  return new Promise((resolve) => {
27
83
  let settled = false;
28
- const finish = (ok) => {
84
+ const finish = (r) => {
29
85
  if (!settled) {
30
86
  settled = true;
31
- resolve(ok);
87
+ resolve(r);
32
88
  }
33
89
  };
34
- const timer = setTimeout(() => finish(false), probeTimeoutMs);
90
+ const timer = setTimeout(() => finish({ ok: false, reason: 'probe timed out' }), probeTimeoutMs);
35
91
  Promise.resolve()
36
- .then(() => check(url))
37
- .then((ok) => {
92
+ .then(() => probe(url))
93
+ .then((r) => {
38
94
  clearTimeout(timer);
39
- finish(ok);
95
+ finish(r);
40
96
  })
41
- .catch(() => {
97
+ .catch((err) => {
42
98
  clearTimeout(timer);
43
- finish(false);
99
+ finish({ ok: false, reason: describeProbeError(err) });
44
100
  });
45
101
  });
46
102
  }
47
- async function waitHealthy(url, attempts, intervalMs, check, probeTimeoutMs) {
103
+ async function waitHealthy(url, attempts, intervalMs, probe, probeTimeoutMs) {
104
+ let lastReason;
48
105
  for (let i = 0; i < attempts; i++) {
49
- if (await probeOnce(url, check, probeTimeoutMs))
50
- return true;
51
- await new Promise((r) => setTimeout(r, intervalMs));
106
+ const r = await probeOnce(url, probe, probeTimeoutMs);
107
+ if (r.ok)
108
+ return r;
109
+ lastReason = r.reason;
110
+ await new Promise((res) => setTimeout(res, intervalMs));
52
111
  }
53
- return false;
112
+ return { ok: false, reason: lastReason };
54
113
  }
55
114
  /**
56
115
  * `extraArgs` exists for tests: it lets a fake binary (e.g. `node fake.mjs`) be
@@ -63,8 +122,13 @@ export function startCloudflared(binPath, localPort, opts = {}) {
63
122
  const timeoutMs = opts.timeoutMs ?? CLOUDFLARED_URL_TIMEOUT_MS;
64
123
  const attempts = opts.attempts ?? CLOUDFLARED_HEALTH_ATTEMPTS;
65
124
  const intervalMs = opts.intervalMs ?? CLOUDFLARED_HEALTH_INTERVAL_MS;
66
- const check = opts.healthCheck ?? defaultHealthCheck;
67
125
  const probeTimeoutMs = opts.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
126
+ // A caller-supplied boolean healthCheck carries no failure reason; the default
127
+ // probe does. Adapt the former into a ProbeResult either way.
128
+ const custom = opts.healthCheck;
129
+ const probe = custom
130
+ ? async (u) => ({ ok: await custom(u) })
131
+ : (u) => reachabilityProbe(u, probeTimeoutMs);
68
132
  return new Promise((resolve, reject) => {
69
133
  const child = spawn(binPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
70
134
  let settled = false;
@@ -91,13 +155,29 @@ export function startCloudflared(binPath, localPort, opts = {}) {
91
155
  if (url && !settled) {
92
156
  settled = true;
93
157
  clearTimeout(timer);
94
- waitHealthy(url, attempts, intervalMs, check, probeTimeoutMs)
95
- .then((ok) => {
96
- if (ok)
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') {
164
+ resolve({ publicUrl: url, stop });
165
+ return;
166
+ }
167
+ waitHealthy(url, attempts, intervalMs, probe, probeTimeoutMs)
168
+ .then((res) => {
169
+ if (res.ok)
97
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
+ }
98
178
  else {
99
179
  stop();
100
- reject(new Error('cloudflared tunnel never became reachable'));
180
+ reject(new Error(unreachableMessage(url, attempts, res.reason)));
101
181
  }
102
182
  })
103
183
  .catch((err) => {
package/dist/env.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * True only when an env var is set to a meaningfully "on" value. Unset, '', '0',
3
+ * 'false', 'no', and 'off' (any case, trimmed) all read as off — so `FOO=0`
4
+ * disables a flag instead of accidentally enabling it (a plain `process.env.FOO`
5
+ * truthiness check treats "0"/"false" as true).
6
+ */
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 ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * True only when an env var is set to a meaningfully "on" value. Unset, '', '0',
3
+ * 'false', 'no', and 'off' (any case, trimmed) all read as off — so `FOO=0`
4
+ * disables a flag instead of accidentally enabling it (a plain `process.env.FOO`
5
+ * truthiness check treats "0"/"false" as true).
6
+ */
7
+ export function envFlag(name) {
8
+ const v = process.env[name];
9
+ if (v === undefined)
10
+ return false;
11
+ const s = v.trim().toLowerCase();
12
+ return s !== '' && s !== '0' && s !== 'false' && s !== 'no' && s !== 'off';
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/index.js CHANGED
@@ -3,35 +3,71 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { TunnelSession } from './session.js';
5
5
  import { registerTools, defaultDisplayName } from './tools.js';
6
- const session = new TunnelSession();
7
- const server = new McpServer({ name: 'tunnel', version: '0.1.0' });
8
- registerTools(server, session, { displayName: defaultDisplayName() });
9
- let closing = false;
10
- async function teardown() {
11
- if (closing)
12
- return;
13
- closing = true;
14
- try {
15
- await session.close('process exit');
6
+ import { parseArgs, helpText, runInstallSkill } from './cli.js';
7
+ import { readVersion, isSkillInstalled } from './skillInstall.js';
8
+ // Write a one-shot command's output, then exit only once it has flushed — a bare
9
+ // write()+exit() can truncate output piped to another process.
10
+ function writeThenExit(stream, text, code) {
11
+ stream.write(text.endsWith('\n') ? text : text + '\n', () => process.exit(code));
12
+ }
13
+ async function main() {
14
+ const version = readVersion();
15
+ const parsed = parseArgs(process.argv.slice(2));
16
+ // One-shot commands print and exit without ever opening the JSON-RPC channel.
17
+ if (parsed.mode === 'help')
18
+ return writeThenExit(process.stdout, helpText(version), 0);
19
+ if (parsed.mode === 'version')
20
+ return writeThenExit(process.stdout, version, 0);
21
+ if (parsed.mode === 'error') {
22
+ return writeThenExit(process.stderr, `tunnel-mcp: ${parsed.message}\n\n${helpText(version)}`, 2);
23
+ }
24
+ if (parsed.mode === 'install-skill') {
25
+ const lines = [];
26
+ const code = runInstallSkill({ dir: parsed.dir, force: parsed.force }, (m) => lines.push(m));
27
+ // Success → stdout; failure → stderr.
28
+ return writeThenExit(code === 0 ? process.stdout : process.stderr, lines.join('\n'), code);
29
+ }
30
+ // Serve. stdout is reserved for the JSON-RPC transport, so all human-facing
31
+ // output goes to stderr (MCP clients capture it into their logs).
32
+ const session = new TunnelSession();
33
+ const server = new McpServer({ name: 'tunnel', version });
34
+ registerTools(server, session, { displayName: defaultDisplayName() });
35
+ let closing = false;
36
+ async function teardown() {
37
+ if (closing)
38
+ return;
39
+ closing = true;
40
+ try {
41
+ await session.close('process exit');
42
+ }
43
+ catch {
44
+ /* best effort */
45
+ }
46
+ process.exit(0);
16
47
  }
17
- catch {
18
- /* best effort */
48
+ process.on('SIGINT', teardown);
49
+ process.on('SIGTERM', teardown);
50
+ const transport = new StdioServerTransport();
51
+ // The host holds an HTTP/WS listener + a cloudflared child, so the event loop
52
+ // never drains and `beforeExit` would never fire. Drive teardown off the stdio
53
+ // pipe closing instead, which is how an MCP client actually ends the server.
54
+ transport.onclose = () => {
55
+ void teardown();
56
+ };
57
+ process.stdin.on('end', () => {
58
+ void teardown();
59
+ });
60
+ process.stdin.on('close', () => {
61
+ void teardown();
62
+ });
63
+ // Startup banner so a human who runs this by hand isn't staring at a silent,
64
+ // seemingly-hung process (this is a server, not an interactive CLI).
65
+ process.stderr.write(`tunnel-mcp v${version} ready on stdio — this is an MCP server, not an interactive CLI. ` +
66
+ `Add it to an MCP client (run \`tunnel-mcp --help\`).\n`);
67
+ if (!isSkillInstalled()) {
68
+ process.stderr.write(`tip: run \`npx tunnel-mcp install-skill\` to install the tunnel-etiquette skill ` +
69
+ `(teaches agents to treat the peer as untrusted input).\n`);
19
70
  }
20
- process.exit(0);
71
+ await server.connect(transport);
21
72
  }
22
- process.on('SIGINT', teardown);
23
- process.on('SIGTERM', teardown);
24
- const transport = new StdioServerTransport();
25
- // The host holds an HTTP/WS listener + a cloudflared child, so the event loop
26
- // never drains and `beforeExit` would never fire. Drive teardown off the stdio
27
- // pipe closing instead, which is how an MCP client actually ends the server.
28
- transport.onclose = () => {
29
- void teardown();
30
- };
31
- process.stdin.on('end', () => {
32
- void teardown();
33
- });
34
- process.stdin.on('close', () => {
35
- void teardown();
36
- });
37
- await server.connect(transport);
73
+ void main();
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,9 +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 { reachabilityMode } from './env.js';
10
11
  const DEFAULT_DEPS = {
11
12
  ensureCloudflared: realEnsure,
12
- startCloudflared: (bin, port) => realStart(bin, port),
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() }),
13
16
  };
14
17
  export class TunnelSession {
15
18
  deps;
@@ -81,6 +84,9 @@ export class TunnelSession {
81
84
  joinLink,
82
85
  status: 'waiting_for_guest',
83
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 } : {}),
84
90
  };
85
91
  }
86
92
  async join(joinLink, guestName) {
@@ -0,0 +1,23 @@
1
+ export declare function bundledSkillDir(): string;
2
+ export declare function readVersion(): string;
3
+ export declare function defaultSkillsDir(): string;
4
+ /** Precedence: explicit arg → $TUNNEL_SKILLS_DIR → ~/.claude/skills. */
5
+ export declare function resolveSkillsDir(explicit?: string): string;
6
+ export declare function isSkillInstalled(skillsDir?: string): boolean;
7
+ export interface InstallResult {
8
+ installed: boolean;
9
+ updated: boolean;
10
+ target: string;
11
+ source: string;
12
+ }
13
+ export declare function installSkill(opts?: {
14
+ skillsDir?: string;
15
+ overwrite?: boolean;
16
+ }): InstallResult;
17
+ /**
18
+ * Called from the postinstall script. Best-effort by contract: it must never
19
+ * throw (a failed skill copy must not fail `npm install`), it only installs
20
+ * when absent (never clobbers a user's copy on reinstall), and it bows out
21
+ * under CI or when the user opts out.
22
+ */
23
+ export declare function installSkillBestEffort(log?: (msg: string) => void): void;
@@ -0,0 +1,69 @@
1
+ import { cpSync, existsSync, readFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { envFlag } from './env.js';
6
+ const SKILL_NAME = 'tunnel-etiquette';
7
+ // dist/skillInstall.js → package root is one level up. During tests (tsx runs
8
+ // src/skillInstall.ts) this resolves to the repo root, which has the same
9
+ // `skill/` and `package.json` layout as the published package.
10
+ function packageRoot() {
11
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ }
13
+ export function bundledSkillDir() {
14
+ return path.join(packageRoot(), 'skill', SKILL_NAME);
15
+ }
16
+ export function readVersion() {
17
+ try {
18
+ const pkg = JSON.parse(readFileSync(path.join(packageRoot(), 'package.json'), 'utf8'));
19
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
20
+ }
21
+ catch {
22
+ return '0.0.0';
23
+ }
24
+ }
25
+ export function defaultSkillsDir() {
26
+ return path.join(os.homedir(), '.claude', 'skills');
27
+ }
28
+ /** Precedence: explicit arg → $TUNNEL_SKILLS_DIR → ~/.claude/skills. */
29
+ export function resolveSkillsDir(explicit) {
30
+ return explicit || process.env.TUNNEL_SKILLS_DIR || defaultSkillsDir();
31
+ }
32
+ function targetDir(skillsDir) {
33
+ return path.join(skillsDir, SKILL_NAME);
34
+ }
35
+ export function isSkillInstalled(skillsDir = resolveSkillsDir()) {
36
+ return existsSync(path.join(targetDir(skillsDir), 'SKILL.md'));
37
+ }
38
+ export function installSkill(opts = {}) {
39
+ const source = bundledSkillDir();
40
+ if (!existsSync(path.join(source, 'SKILL.md'))) {
41
+ throw new Error(`bundled tunnel-etiquette skill not found at ${source}`);
42
+ }
43
+ const skillsDir = resolveSkillsDir(opts.skillsDir);
44
+ const target = targetDir(skillsDir);
45
+ const existed = existsSync(path.join(target, 'SKILL.md'));
46
+ if (existed && !opts.overwrite) {
47
+ return { installed: false, updated: false, target, source };
48
+ }
49
+ cpSync(source, target, { recursive: true });
50
+ return { installed: true, updated: existed, target, source };
51
+ }
52
+ /**
53
+ * Called from the postinstall script. Best-effort by contract: it must never
54
+ * throw (a failed skill copy must not fail `npm install`), it only installs
55
+ * when absent (never clobbers a user's copy on reinstall), and it bows out
56
+ * under CI or when the user opts out.
57
+ */
58
+ export function installSkillBestEffort(log = () => { }) {
59
+ if (envFlag('TUNNEL_SKIP_SKILL_INSTALL') || envFlag('CI'))
60
+ return;
61
+ try {
62
+ const res = installSkill({ overwrite: false });
63
+ if (res.installed)
64
+ log(`tunnel-mcp: installed the tunnel-etiquette skill to ${res.target}`);
65
+ }
66
+ catch {
67
+ /* never break an install */
68
+ }
69
+ }
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.1",
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": {
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "dist",
13
13
  "skill",
14
+ "postinstall.mjs",
14
15
  "README.md",
15
16
  "LICENSE",
16
17
  "CHANGELOG.md",
@@ -29,6 +30,7 @@
29
30
  "format:check": "prettier --check .",
30
31
  "dev": "tsx src/index.ts",
31
32
  "e2e": "tsx scripts/e2e-two-agents.ts",
33
+ "postinstall": "node postinstall.mjs",
32
34
  "prepublishOnly": "npm run build"
33
35
  },
34
36
  "keywords": [
@@ -0,0 +1,13 @@
1
+ // Best-effort: install the tunnel-etiquette skill into ~/.claude/skills when the
2
+ // package is installed. This MUST never fail an install and is deliberately a
3
+ // no-op in the cases where it can't or shouldn't run:
4
+ // - `npm install --ignore-scripts` (and hosts that disable scripts) skip it
5
+ // - CI, or TUNNEL_SKIP_SKILL_INSTALL=1, opt out (see installSkillBestEffort)
6
+ // - a dev checkout before `npm run build` has no dist/ yet → import throws → ignored
7
+ // It IS idempotent, so running again (e.g. npx populating its cache) is harmless.
8
+ try {
9
+ const { installSkillBestEffort } = await import('./dist/skillInstall.js');
10
+ installSkillBestEffort((m) => console.error(m));
11
+ } catch {
12
+ /* dist not built yet, or any other error — never break the install */
13
+ }